Browse Source

massive cleanup of Graph and Editor code

mj-deploy
Vašek Šraier 4 years ago
parent
commit
ab13f0b726
  1. 4
      frontend/src/App.svelte
  2. 29
      frontend/src/Editor.svelte
  3. 123
      frontend/src/Graph.svelte
  4. 13
      frontend/src/GraphEdge.svelte
  5. 23
      frontend/src/GraphNode.svelte
  6. 5
      frontend/src/TaskDetailEditor.svelte
  7. 2
      frontend/src/TaskDisplay.svelte
  8. 4
      frontend/src/TaskPanel.svelte
  9. 2
      frontend/src/TasksLoader.svelte
  10. 106
      frontend/src/force-simulation.ts
  11. 64
      frontend/src/graph-types.ts
  12. 50
      frontend/src/task-force.ts
  13. 38
      frontend/src/tasks.ts

4
frontend/src/App.svelte

@ -1,7 +1,7 @@
<script lang="ts">
import Graph from "./Graph.svelte";
import { loadTasks } from "./task-loader";
import type { TasksFile, TaskDescriptor } from "./task-loader";
import { loadTasks } from "./tasks";
import type { TasksFile, TaskDescriptor } from "./tasks";
import TasksLoader from "./TasksLoader.svelte";
import TaskPanel from "./TaskPanel.svelte";
import Editor from "./Editor.svelte";

29
frontend/src/Editor.svelte

@ -3,10 +3,11 @@
import Graph from "./Graph.svelte";
import { nonNull } from "./helpers";
import type { TaskDescriptor, TasksFile } from "./task-loader";
import { saveTasks, getCategories } from "./task-loader";
import type { TaskDescriptor, TasksFile } from "./tasks";
import { saveTasks, getCategories } from "./tasks";
import TaskDisplay from "./TaskDisplay.svelte";
import TaskDetailEditor from "./TaskDetailEditor.svelte";
import { forceSimulation } from "./force-simulation";
export let tasks: TasksFile;
@ -46,9 +47,15 @@
}
}
async function saveCurrentStateWithPositions() {
tasks.positions = graph.getNodePositions();
await saveTasks(tasks);
function runSimulation() {
forceSimulation(
tasks,
(positions) => {
tasks.tasks.forEach((t) => (t.position = positions.get(t.id)));
tasks = tasks;
},
repulsionForce
);
}
async function saveCurrentState() {
@ -254,20 +261,18 @@
<h3>Toolbox</h3>
<div>
<button on:click={saveCurrentState}>Uložit aktuální stav</button>
<button on:click={saveCurrentStateWithPositions}>Uložit aktuální stav
včetně pozic nodů</button>
</div>
<div class="gap" />
<div>
<button on:click={addTask}>Nový node</button>
<button
disabled={clicked.length == 0}
on:click={() => removeTask(clicked[clicked.length - 1])}>Odstranit {clicked[clicked.length - 1] ?? "???"}</button>
on:click={() => removeTask(clicked[clicked.length - 1])}>Odstranit {clicked[clicked.length - 1] ?? '???'}</button>
</div>
<div class="gap" />
<div>
<button disabled={clicked.length <= 1} on:click={addEdge}>Přidat hranu {clicked[clicked.length - 2] ?? "???"}
-&gt; {clicked[clicked.length - 1] ?? "???"}</button>
<button disabled={clicked.length <= 1} on:click={addEdge}>Přidat hranu {clicked[clicked.length - 2] ?? '???'}
-&gt; {clicked[clicked.length - 1] ?? '???'}</button>
<div class="checkbox">
<label>
<input type="checkbox" bind:checked={showHiddenEdges} /> Zobrazit skryté
@ -278,7 +283,7 @@
<div class="gap" />
<div>
<button on:click={graph.runSimulation}>Spustit simulaci</button>
<button on:click={runSimulation}>Spustit simulaci</button>
<div class="checkbox">
<label>
<input type="checkbox" bind:checked={nodeDraggingEnabled} /> Povolit
@ -290,7 +295,7 @@
Repulsion force: <input type="number" bind:value={repulsionForce} name="repulsionForceInput" max="1000" min="-10000" />
</div>
<div class="gap" />
{#if clicked.length > 0 && getTask(clicked[clicked.length - 1]).type == 'label'}
{#if clicked.length > 0 && getTask(clicked[clicked.length - 1])?.type == 'label'}
<div>
Úhel rotace: <input bind:value={angle} type="range" max="360" min="0" on:change={setAngleToTheCurrentLabel} />
</div>

123
frontend/src/Graph.svelte

@ -3,13 +3,11 @@
import GraphEdge from "./GraphEdge.svelte";
import { createEventDispatcher, onMount } from "svelte";
import * as d3 from "d3";
import type { TasksFile, TaskDescriptor } from "./task-loader";
import { createNodesAndEdges } from "./graph-types";
import { taskForce } from "./task-force";
import { taskStatuses } from './task-status-cache'
import type { TasksFile, TaskDescriptor } from "./tasks";
import { createEdges } from "./tasks";
import { taskStatuses } from "./task-status-cache";
export let tasks: TasksFile;
export let repulsionForce: number = -1000;
export let nodeDraggingEnabled: boolean = false;
export let showHiddenEdges: boolean = false;
@ -21,89 +19,50 @@
let clientWidth: number;
let svgElement: SVGElement;
// this prevents svelte from updating nodes and edges
// when we update nodes and edges
let [nodes, edges] = createNodesAndEdges(tasks);
function hack() {
[nodes, edges] = createNodesAndEdges(tasks, nodes, edges);
}
$: {
tasks;
hack();
}
$: nodes = tasks.tasks;
$: edges = createEdges(nodes);
const eventDispatcher = createEventDispatcher();
const nodeClick = (task: TaskDescriptor) => (e: CustomEvent<MouseEvent>) => {
eventDispatcher("selectTask", task);
};
const nodeDoubleClick = (task: TaskDescriptor) => (e: CustomEvent<MouseEvent>) => {
eventDispatcher("openTask", task);
};
const nodeHover = (task: TaskDescriptor) => (
hovering: CustomEvent<boolean>
) => {
if (hovering.detail) {
hoveredTask = task.id;
eventDispatcher("preSelectTask", task);
} else {
if (hoveredTask == task.id) hoveredTask = null;
eventDispatcher("unPreSelectTask", task);
function nodeClick(task: TaskDescriptor) {
function eventHandler(e: CustomEvent<MouseEvent>) {
eventDispatcher("selectTask", task);
}
};
export function runSimulation() {
// Let's list the force we wanna apply on the network
let simulation = d3
.forceSimulation(nodes) // Force algorithm is applied to data.nodes
.force(
"link",
d3
.forceLink() // This force provides links between nodes
.id(function (d) {
return d.id;
}) // This provide the id of a node
.links(edges) // and this the list of links
)
.force("charge", d3.forceManyBody().strength(repulsionForce)) // This adds repulsion between nodes. Play with the -400 for the repulsion strength
.force("x", d3.forceX()) // attracts elements to the zero X coord
.force("y", d3.forceY().strength(0.5)) // attracts elements to the zero Y coord
.force("dependencies", taskForce())
.on("tick", ticked)
.on("end", ticked);
// This function is run at each iteration of the force algorithm, updating the nodes position.
function ticked() {
edges = edges;
nodes = nodes;
return eventHandler;
}
function nodeDoubleClick(task: TaskDescriptor) {
function eventHandler(e: CustomEvent<MouseEvent>) {
eventDispatcher("openTask", task);
}
return eventHandler;
}
export function getNodePositions(): Map<string, [number, number]> {
let res = new Map();
for (let n of nodes) {
if (n.x != undefined && n.y != undefined) {
res.set(n.id, [n.x, n.y]);
function nodeHover(task: TaskDescriptor) {
function eventHandler(hovering: CustomEvent<boolean>) {
if (hovering.detail) {
hoveredTask = task.id;
eventDispatcher("preSelectTask", task);
} else {
if (hoveredTask == task.id) hoveredTask = null;
eventDispatcher("unPreSelectTask", task);
}
}
return res
return eventHandler;
}
// start simulation and center view on create
onMount(() => {
// set center of the SVG at (0,0)
let svg = d3.select(svgElement).select("g")
// setup zoom
/**
* Make the SVG drag&zoomable
**/
function setupZoom() {
function zoomed(e) {
let svg = d3.select(svgElement).select("g");
svg.attr("transform", e.transform);
}
const zoomer = d3.zoom().scaleExtent([0.1, 2]).clickDistance(10)
const zoomer = d3.zoom().scaleExtent([0.1, 2]).clickDistance(10);
zoomer.on("zoom", zoomed);
d3.select(container).call(zoomer);
}
onMount(() => {
setupZoom();
});
</script>
@ -116,7 +75,7 @@
left: 0px;
}
:global(#header) {
z-index: 20
z-index: 20;
}
:global(#wrapper) {
display: flex;
@ -131,20 +90,22 @@
<div bind:this={container} bind:clientHeight bind:clientWidth>
<svg bind:this={svgElement} viewBox="{0},{0},{clientWidth},{clientHeight}">
<g>
<g transform="translate({clientWidth/2}, {clientHeight/2})">
<g transform="translate({clientWidth / 2}, {clientHeight / 2})">
{#each edges as edge}
<GraphEdge {edge} showLabelEdge={showHiddenEdges}/>
<GraphEdge {edge} showLabelEdge={showHiddenEdges} />
{/each}
{#each nodes as task}
<GraphNode
{task}
on:taskClick
on:click={nodeClick(task.task)}
on:hoveringChange={nodeHover(task.task)}
on:positionChange={() => { tasks = tasks; }}
on:click={nodeClick(task)}
on:hoveringChange={nodeHover(task)}
on:positionChange={() => {
tasks = tasks;
}}
status={$taskStatuses.get(task.id)}
draggingEnabled={nodeDraggingEnabled}
on:dblclick={nodeDoubleClick(task.task)} />
on:dblclick={nodeDoubleClick(task)} />
{/each}
</g>
</g>

13
frontend/src/GraphEdge.svelte

@ -1,18 +1,15 @@
<script lang="ts">
import type { SimulationLinkDatum } from "d3";
import type { TaskId } from "./graph-types";
import type { TaskEdge } from "./graph";
export let edge: SimulationLinkDatum<TaskId>;
export let edge: TaskEdge;
export let showLabelEdge: boolean = false;
$: x1 = edge?.source?.x ?? 0;
$: y1 = edge?.source?.y ?? 0;
$: x2 = edge?.target?.x ?? 0;
$: y2 = edge?.target?.y ?? 0;
$: [x1, y1] = edge?.dependency?.position ?? [0,0];
$: [x2, y2] = edge?.dependee?.position ?? [0, 0];
$: dx = x1 - x2
$: dy = y1 - y2
</script>
{#if showLabelEdge || (edge?.target?.task?.type ?? null) != "label"}
{#if showLabelEdge || (edge?.dependee?.type ?? null) != "label"}
<path d="m {x2} {y2+0} c 0 0 {dx} {dy-40} {dx} {dy-20}" style="fill:none; stroke: #aaa; stroke-width: 3px" />
{/if}

23
frontend/src/GraphNode.svelte

@ -2,10 +2,10 @@
import * as d3 from "d3";
import { createEventDispatcher, onMount } from "svelte";
import type { TaskId } from "./graph-types";
import type { TaskStatus } from "./ksp-task-grabber";
import type { TaskDescriptor } from "./tasks";
export let task: TaskId;
export let task: TaskDescriptor;
export let draggingEnabled: boolean = false;
export let status: TaskStatus | undefined = undefined;
@ -13,8 +13,7 @@
let text_element: SVGTextElement;
let mainGroup: SVGGElement;
$: cx = task === undefined || task.x === undefined ? 0 : task.x;
$: cy = task === undefined || task.y === undefined ? 0 : task.y;
$: [cx, cy] = task.position ?? [0, 0];
const eventDispatcher = createEventDispatcher();
function enter() {
@ -43,7 +42,7 @@
});
// every time after that
$: {
task.task.title;
task.title;
if (text_element)
ensureTextFits();
}
@ -64,9 +63,7 @@
if (!draggingEnabled) return;
if (!dragging) return;
let [x, y] = d3.pointer(e, mainGroup);
task.x = x;
task.y = y;
task.position = d3.pointer(e, mainGroup);
eventDispatcher("positionChange");
e.preventDefault();
e.stopPropagation();
@ -124,8 +121,8 @@
on:click={click}
on:mousedown={dragStart}
on:dblclick={dblclick}
class="{status == null ? '' : status.solved ? 'solved' : status.submitted ? 'submitted' : ''} {task.task.type}">
{#if task.task.type == 'label'}
class="{status == null ? '' : status.solved ? 'solved' : status.submitted ? 'submitted' : ''} {task.type}">
{#if task.type == 'label'}
{#if draggingEnabled }
<ellipse rx={ellipse_rx} ry={20} {cx} {cy} />
{/if}
@ -135,8 +132,8 @@
y={cy + 5}
text-anchor="middle"
alignment-baseline="middle"
transform="translate({cx}, {cy}) rotate({task.task.rotationAngle ?? 0}) translate({-cx}, {-cy})">
{task.task.title == null ? task.id : task.task.title}
transform="translate({cx}, {cy}) rotate({task.rotationAngle ?? 0}) translate({-cx}, {-cy})">
{task.title == null ? task.id : task.title}
</text>
{:else}
<ellipse class="taskNode" rx={ellipse_rx} ry={20} {cx} {cy} />
@ -146,7 +143,7 @@
y={cy + 5}
text-anchor="middle"
alignment-baseline="middle">
{task.task.title == null ? task.id : task.task.title}
{task.title == null ? task.id : task.title}
</text>
{/if}
</g>

5
frontend/src/TaskDetailEditor.svelte

@ -1,10 +1,9 @@
<script lang="ts">
import { getCategories } from "./task-loader";
import type { TaskDescriptor, TasksFile } from "./task-loader";
import { getCategories } from "./tasks";
import type { TaskDescriptor, TasksFile } from "./tasks";
import { getContext } from "svelte";
import { onMount } from "svelte";
import ClassicEditor from "@ckeditor/ckeditor5-build-classic";
import App from "./App.svelte";
const { close } = getContext("simple-modal");

2
frontend/src/TaskDisplay.svelte

@ -4,7 +4,7 @@
import { nonNull } from './helpers'
import App from "./App.svelte";
import { taskStatuses } from "./task-status-cache";
import type { TaskDescriptor } from "./task-loader";
import type { TaskDescriptor } from "./tasks";
export let task: TaskDescriptor | null | undefined

4
frontend/src/TaskPanel.svelte

@ -1,7 +1,5 @@
<script lang="ts">
import { grabAssignment } from "./ksp-task-grabber";
import type { TaskAssignmentData } from "./ksp-task-grabber";
import type { TasksFile, TaskDescriptor } from "./task-loader";
import type { TasksFile, TaskDescriptor } from "./tasks";
import TaskDisplay from "./TaskDisplay.svelte";
export let tasks: TasksFile;

2
frontend/src/TasksLoader.svelte

@ -1,5 +1,5 @@
<script lang="ts">
import type { TasksFile } from "./task-loader";
import type { TasksFile } from "./tasks";
import { refresh } from './task-status-cache'
export let promise: Promise<TasksFile>;

106
frontend/src/force-simulation.ts

@ -0,0 +1,106 @@
import type { SimulationLinkDatum, SimulationNodeDatum } from "d3";
import * as d3 from "d3";
import { createEdges, TaskDescriptor, TasksFile } from "./tasks";
type TaskId = {
id: string;
task: TaskDescriptor;
} & SimulationNodeDatum;
function toMapById(nodes: TaskId[]): Map<string, TaskId> {
let nodeMap = new Map<string, TaskId>();
for (let task of nodes) {
if (task.id in nodeMap)
throw 'duplicate IDs';
nodeMap.set(task.id, task);
}
return nodeMap;
}
function taskForce(): d3.Force<TaskId, undefined> {
let myNodes: TaskId[] | null = null;
let deps: Map<string, number> = new Map();
let idMap: Map<string, TaskId> = new Map();
function getNumberOfDeps(task: TaskId): number {
if (deps.has(task.id)) return deps.get(task.id)!;
if (task.task.requires.length == 0) return 0;
let res = 0;
for (let r of task.task.requires) {
res += getNumberOfDeps(idMap.get(r)!) + 1;
}
deps.set(task.id, res);
return res;
}
let force: d3.Force<TaskId, undefined> = function (alpha: number) {
if (myNodes == null) throw 'nodes not initialized';
for (let task of myNodes) {
if (task.vy == null) {
task.vy = 0
}
task.vy += getNumberOfDeps(task) * 25 * alpha;
}
}
force.initialize = function (nodes: TaskId[]) {
myNodes = nodes;
idMap = toMapById(myNodes);
}
return force;
}
/**
*
* @param nodes
* @param edges
* @param ticked function that gets run every tick of a running simulation
*/
export function forceSimulation(tasks: TasksFile, ticked: (positions: Map<string, [number, number]>) => void, repulsionForce?: number) {
repulsionForce = repulsionForce ?? -1000;
let nodes: TaskId[] = tasks.tasks.map(
(t) => {
return {
id: t.id,
task: t,
x: (t.position ?? [0, 0])[0],
y: (t.position ?? [0, 0])[1]
}
})
let edges: SimulationLinkDatum<TaskId>[] = createEdges(tasks.tasks).map((e) => {
return {
// FIXME are we sure its not the other way round?
source: nodes.find((n) => n.id == e.dependee.id)!,
target: nodes.find((n) => n.id == e.dependency.id)!
}
});
function tickHandler() {
ticked(new Map(nodes.map((n) => [n.id!, [n.x!, n.y!]])))
}
// Let's list the force we wanna apply on the network
let simulation = d3
.forceSimulation(nodes) // Force algorithm is applied to data.nodes
.force(
"link",
d3
.forceLink() // This force provides links between nodes
.id(function (d) {
return d.id;
}) // This provide the id of a node
.links(edges) // and this the list of links
)
.force("charge", d3.forceManyBody().strength(repulsionForce)) // This adds repulsion between nodes. Play with the -400 for the repulsion strength
.force("x", d3.forceX()) // attracts elements to the zero X coord
.force("y", d3.forceY().strength(0.5)) // attracts elements to the zero Y coord
.force("dependencies", taskForce())
.on("tick", tickHandler)
.on("end", tickHandler);
}

64
frontend/src/graph-types.ts

@ -1,64 +0,0 @@
import type { SimulationLinkDatum, SimulationNodeDatum } from "d3";
import type { TaskDescriptor, TasksFile } from "./task-loader";
import { createTaskMap } from "./task-loader";
export type TaskId = {
id: string;
task: TaskDescriptor;
} & SimulationNodeDatum;
function toMapById(nodes: TaskId[]): Map<string, TaskId> {
let nodeMap = new Map<string, TaskId>();
for (let task of nodes) {
if (task.id in nodeMap)
throw 'duplicate IDs';
nodeMap.set(task.id, task);
}
return nodeMap;
}
function createNodes(tasks: TasksFile, old?: TaskId[]): TaskId[] {
let m = (old == undefined) ? new Map<string, TaskId>() : toMapById(old);
let res: TaskId[] = tasks.tasks.map((t) => {
return { id: t.id, task: t };
});
for (let t of res) {
if (tasks.positions.has(t.id)) {
[t.x, t.y] = tasks.positions.get(t.id)!
}
if (m.has(t.id)) {
Object.assign(t, m.get(t.id))
}
}
return res;
}
export function createNodesAndEdges(tasks: TasksFile, oldNodes?: TaskId[], oldEdges?: SimulationLinkDatum<TaskId>[]): [TaskId[], SimulationLinkDatum<TaskId>[]] {
let nodes = createNodes(tasks, oldNodes);
// create mapping from ID to node
let nodeMap = toMapById(nodes);
let links: SimulationLinkDatum<TaskId>[] = [];
for (const task of tasks.tasks) {
const src = nodeMap.get(task.id)!;
for (const id of task.requires) {
const t = nodeMap.get(id);
if (t === undefined) throw `missing task with id ${id}`;
const l: SimulationLinkDatum<TaskId> =
{
source: src,
target: t
};
links.push(l);
}
}
return [nodes, links];
}

50
frontend/src/task-force.ts

@ -1,50 +0,0 @@
import type { TaskId } from "./graph-types";
/* copied from graph-types.ts */
function toMapById(nodes: TaskId[]): Map<string, TaskId> {
let nodeMap = new Map<string, TaskId>();
for (let task of nodes) {
if (task.id in nodeMap)
throw 'duplicate IDs';
nodeMap.set(task.id, task);
}
return nodeMap;
}
export function taskForce(): d3.Force<TaskId, undefined> {
let myNodes: TaskId[] | null = null;
let deps: Map<string, number> = new Map();
let idMap: Map<string, TaskId> = new Map();
function getNumberOfDeps(task: TaskId): number {
if (deps.has(task.id)) return deps.get(task.id)!;
if (task.task.requires.length == 0) return 0;
let res = 0;
for (let r of task.task.requires) {
res += getNumberOfDeps(idMap.get(r)!) + 1;
}
deps.set(task.id, res);
return res;
}
let force: d3.Force<TaskId, undefined> = function(alpha: number) {
if (myNodes == null) throw 'nodes not initialized';
for (let task of myNodes) {
if (task.vy == null) {
task.vy = 0
}
task.vy += getNumberOfDeps(task) * 25 * alpha;
}
}
force.initialize = function(nodes: TaskId[]) {
myNodes = nodes;
idMap = toMapById(myNodes);
}
return force;
}

38
frontend/src/task-loader.ts → frontend/src/tasks.ts

@ -5,6 +5,7 @@ export type TaskDescriptor = {
title?: string
requires: string[]
comment?: string
position?: [number, number]
} & (
{
type: "open-data",
@ -25,18 +26,38 @@ export type TaskDescriptor = {
export type TasksFile = {
tasks: TaskDescriptor[]
clusters: { [name: string]: string[] }
positions: Map<string, [number, number]>
}
export type TaskMap = Map<string, TaskDescriptor>;
export type TaskEdge = {
dependee: TaskDescriptor
dependency: TaskDescriptor
}
export function createEdges(nodes: TaskDescriptor[]): TaskEdge[] {
let edges: TaskEdge[] = [];
for (const n of nodes) {
for (const r of n.requires) {
const a = nodes.find((t) => t.id == r);
if (a == undefined) throw `broken dependency, missing task with ${r}`
edges.push({dependee: a, dependency: n})
}
}
return edges
}
export async function loadTasks(): Promise<TasksFile> {
const r = await fetch("/tasks.json")
const j = await r.json()
if (j.positions == null)
j.positions = new Map();
else
j.positions = new Map(Object.entries(j.positions))
// backward compatibility
if (j.positions != null) {
for (const [id, pos] of Object.entries(j.positions)) {
j.tasks.find((t) => t.id == id).position = pos;
}
}
return j
}
@ -47,15 +68,10 @@ function normalizeTasks(tasks: TasksFile) {
export async function saveTasks(tasks: TasksFile) {
normalizeTasks(tasks);
let p: any = {}
for (let [key, val] of tasks.positions.entries())
p[key] = val;
const data = { ...tasks, positions: p }
// request options
const options = {
method: 'POST',
body: JSON.stringify(data, null, 4),
body: JSON.stringify(tasks, null, 4),
headers: {
'Content-Type': 'application/json'
}