massive cleanup of Graph and Editor code
This commit is contained in:
parent
f0e6e79364
commit
ab13f0b726
13 changed files with 218 additions and 253 deletions
|
@ -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";
|
||||
|
|
|
@ -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] ?? "???"}
|
||||
-> {clicked[clicked.length - 1] ?? "???"}</button>
|
||||
<button disabled={clicked.length <= 1} on:click={addEdge}>Přidat hranu {clicked[clicked.length - 2] ?? '???'}
|
||||
-> {clicked[clicked.length - 1] ?? '???'}</button>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={showHiddenEdges} /> Zobrazit skryté
|
||||
|
@ -275,10 +280,10 @@
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
function nodeClick(task: TaskDescriptor) {
|
||||
function eventHandler(e: CustomEvent<MouseEvent>) {
|
||||
eventDispatcher("selectTask", 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 nodeDoubleClick(task: TaskDescriptor) {
|
||||
function eventHandler(e: CustomEvent<MouseEvent>) {
|
||||
eventDispatcher("openTask", task);
|
||||
}
|
||||
return eventHandler;
|
||||
}
|
||||
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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
Normal file
106
frontend/src/force-simulation.ts
Normal file
|
@ -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);
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
||||
}
|
Reference in a new issue