From ce0821ebb62a4186ba9eff2464ea0ef1834a5033 Mon Sep 17 00:00:00 2001 From: poanse <50020771+poanse@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:37:44 +0300 Subject: [PATCH] added position calculations --- src/NodePositionsCalculator.ts | 355 ++++++++++++++++++++++++++++ src/ProjectData.svelte.ts | 28 ++- src/TaskmapView.ts | 8 +- src/components/AddTaskButton.svelte | 59 +++-- src/components/Task.svelte | 2 + src/pixi/GlobalState.svelte.ts | 82 ++++--- src/types.ts | 11 +- 7 files changed, 477 insertions(+), 68 deletions(-) create mode 100644 src/NodePositionsCalculator.ts diff --git a/src/NodePositionsCalculator.ts b/src/NodePositionsCalculator.ts new file mode 100644 index 0000000..b4d47bc --- /dev/null +++ b/src/NodePositionsCalculator.ts @@ -0,0 +1,355 @@ +// Assumed types and constants based on usage +import type { TaskData, TaskId, Vector2 } from "./types"; + +export const NoNodeId = -1 as TaskId; +export const RootNodeId = 0 as TaskId; + +// Helper for Vector2 operations since TS doesn't support operator overloading +const V2 = { + Zero: { x: 0, y: 0 }, + One: { x: 1, y: 1 }, + add: (v1: Vector2, v2: Vector2): Vector2 => ({ + x: v1.x + v2.x, + y: v1.y + v2.y, + }), + sub: (v1: Vector2, v2: Vector2): Vector2 => ({ + x: v1.x - v2.x, + y: v1.y - v2.y, + }), + mult: (v: Vector2, s: number | Vector2): Vector2 => { + if (typeof s === "number") return { x: v.x * s, y: v.y * s }; + return { x: v.x * s.x, y: v.y * s.y }; + }, +}; + +export enum AlgorithmEnum { + DefaultTree, + SingleRow, + DoubleRow, +} + +export class NodePositionsCalculator { + public readonly ParentToChildHorizontalShift = 400; + public readonly SiblingDelta = 90; + public Algorithm: AlgorithmEnum = AlgorithmEnum.DefaultTree; + public subtreeWidthByHalfPriority: Map = new Map< + number, + number + >(); + public xshift = 0.25; + + public RootFramePositions: Map = new Map< + TaskId, + Vector2 + >(); + + public CalculatePositionsInGlobalFrame( + tasks: TaskData[], + rootPosition: Vector2, + ): Map { + const rootFrame = this.CalculatePositionsInRootFrame(tasks); + const result: Map = new Map(); + + for (const [key, value] of rootFrame) { + result.set(key, V2.add(value, rootPosition)); + } + return result; + } + + /** + * Позиции относительно рута + */ + private CalculatePositionsInRootFrame( + tasks: TaskData[], + ): Map { + const parentFramePositions = + this.CalculatePositionsInParentFrame(tasks); + const positions: Map = new Map(); + positions.set(NoNodeId, V2.Zero); + + tasks.forEach((t) => { + const parentPos = positions.get(t.parentId)!; + const relativePos = parentFramePositions.get(t.taskId)!; + positions.set(t.taskId, V2.add(parentPos, relativePos)); + }); + + this.RootFramePositions = positions; + return positions; + } + + /** + * Считаем позиции на основе веса поддеревьев и константы alignmentRatio + */ + private CalculatePositionsInParentFrame( + tasks: TaskData[], + ): Map { + // Расположение ноды родителя относительно высоты поддерева. 0 - top, 0.5 - center, 1 - bottom + const alignmentRatio: Vector2 = { x: 0, y: 0.5 }; + // Дополнительный сдвиг в виде доли от siblingDelta, чтобы дети сиблингов не сливались в один список + const parentDelta: Vector2 = { x: 0, y: 0.15 }; + + //// Вспомогательные структуры данных + // Сортируем таски, чтобы упросить реализацию DP: depth DESC, parentId ASC/DESC, priority ASC + // TODO: мб поддерживать порядок сортировки в DataModel? + const sortedTasks = [...tasks].sort((a, b) => { + if (b.depth !== a.depth) { + return b.depth - a.depth; + } else if (a.parentId !== b.parentId) { + return a.parentId - b.parentId; + } else { + return a.priority - b.priority; + } + }); + + const taskById = new Map(); + tasks.forEach((t) => taskById.set(t.taskId, t)); + + const childrenIdsByParentId: Map = new Map< + TaskId, + TaskId[] + >(); + sortedTasks.forEach((t) => { + const pId = t.parentId; + if (!childrenIdsByParentId.has(pId)) { + childrenIdsByParentId.set(pId, []); + } + childrenIdsByParentId.get(pId)!.push(t.taskId); + }); + + const parentIds = [...childrenIdsByParentId.keys()]; + + //// Расчеты + // ключ - айди рута поддерева + const subtreeSizeByNodeId: Map = new Map< + TaskId, + Vector2 + >(); + + const allIdsToProcess = [...sortedTasks.map((t) => t.taskId), NoNodeId]; + + allIdsToProcess.forEach((id) => { + if (childrenIdsByParentId.has(id)) { + const childrenIds = childrenIdsByParentId.get(id)!; + const xAgg = + Math.max( + ...childrenIds.map( + (x) => subtreeSizeByNodeId.get(x)!.x, + ), + ) + 1; + const yAgg = childrenIds + .map((x) => subtreeSizeByNodeId.get(x)!.y) + .reduce((a, b) => a + b, 0); + subtreeSizeByNodeId.set( + id, + V2.add({ x: xAgg, y: yAgg }, parentDelta), + ); + } else { + subtreeSizeByNodeId.set(id, V2.One); + } + }); + + // Шифт дочерних нод относительно левой-верхней точки прямоугольника + const siblingShiftsRel: Map = new Map< + TaskId, + Vector2 + >(); + parentIds.forEach((parentId) => { + const children = childrenIdsByParentId.get(parentId)!; + children.forEach((childId, idx) => { + let s = V2.mult( + subtreeSizeByNodeId.get(childId)!, + alignmentRatio, + ); + if (idx > 0) { + const previousSibling = children[idx - 1]; + const prevSize = V2.mult( + subtreeSizeByNodeId.get(previousSibling)!, + V2.sub(V2.One, alignmentRatio), + ); + s = V2.add(s, prevSize); + s = V2.add(s, siblingShiftsRel.get(previousSibling)!); + } + // Дополнительный сдвиг для отрисовки блокеров + // var blockerCount = BlockerDataManager.GetConnections() + // .Count(x => x.Item1 == childId); + // s += blockerCount; + siblingShiftsRel.set(childId, s); + }); + }); + + // TODO: походу в проекте что-то не так с данными. + // Нужно перейти на мета уровень и сделать удобный способ смотреть данные. Какой? + // - Логировать десятки тасок в виде json может быть полезно, но не удобно. + // - Можно добавить этап валидации данных, где будут проверяться предположения. + // - Можно добавить дебажную инфу с данными выбранной таски. Не поможет, если проблема в удаленных. + + // Шифт родителя относительно левой-верхней точки прямоугольника. + // Вычитаем parentDelta, чтобы родитель был выровнен по детям + const parentAlignmentShift: Map = new Map< + TaskId, + Vector2 + >(); + parentIds.forEach((parentId) => { + parentAlignmentShift.set( + parentId, + V2.mult( + V2.sub(subtreeSizeByNodeId.get(parentId)!, parentDelta), + alignmentRatio, + ), + ); + }); + + // Шифт дочерних нод относительно родителя и уже в нормальных координатах + const finalChildShifts: Map = new Map< + TaskId, + Vector2 + >(); + sortedTasks.forEach((t) => { + const parentShift = parentAlignmentShift.get(t.parentId); + const siblingShift = siblingShiftsRel.get(t.taskId); + if (parentShift === undefined) { + throw new Error(); + } + if (siblingShift === undefined) { + throw new Error(); + } + + const verticalComponent = V2.mult( + { x: 0, y: this.SiblingDelta }, + V2.sub(siblingShift, parentShift), + ); + + finalChildShifts.set( + t.taskId, + V2.add( + { x: this.ParentToChildHorizontalShift, y: 0 }, + verticalComponent, + ), + ); + }); + + const rootTaskHasMultipleVisibleChildren = + (childrenIdsByParentId.get(RootNodeId)! || []).length > 1; + + if ( + rootTaskHasMultipleVisibleChildren && + [AlgorithmEnum.SingleRow, AlgorithmEnum.DoubleRow].includes( + this.Algorithm, + ) + ) { + const rowMinYShift = 0.3; + const depthOneTasks = sortedTasks.filter((x) => x.depth === 1); + + // Group by RowIndex + const depthOneTasksByRow: Map = new Map< + number, + TaskData[] + >(); + depthOneTasks.forEach((t) => { + const idx = this.RowIndex(t); + if (!depthOneTasksByRow.has(idx)) { + depthOneTasksByRow.set(idx, []); + } + depthOneTasksByRow.get(idx)!.push(t); + }); + + const yShiftByRowId: Map = {} as Map< + number, + number + >; + Object.keys(depthOneTasksByRow).forEach((key) => { + const rowIdx = Number(key); + const elements = depthOneTasksByRow.get(rowIdx)!; + + const shifts = elements.map((n) => + parentAlignmentShift.has(n.taskId) + ? parentAlignmentShift.get(n.taskId)!.y + : V2.mult(alignmentRatio, V2.One).y, + ); + + const maxYShift = shifts.length > 0 ? Math.max(...shifts) : 0; // MaxOrDefault(0) equivalent + yShiftByRowId.set( + rowIdx, + (maxYShift + rowMinYShift) * this.SiblingDelta, + ); + }); + + if (this.Algorithm === AlgorithmEnum.SingleRow) { + let subtreeRelWidthAccumulator = this.xshift; + depthOneTasks.forEach((x) => { + finalChildShifts.set(x.taskId, { + x: + finalChildShifts.get(x.taskId)!.x + + subtreeRelWidthAccumulator * + this.ParentToChildHorizontalShift, + y: yShiftByRowId.get(this.RowIndex(x))!, + }); + subtreeRelWidthAccumulator += + subtreeSizeByNodeId.get(x.taskId)!.x + this.xshift; + }); + } else if (this.Algorithm === AlgorithmEnum.DoubleRow) { + // Размеры поддеревьев по иксу. Если 2 таски друг над другом, то используется большее. + this.subtreeWidthByHalfPriority = {} as Map; + + const rootChildrenByPriority: Map = {} as Map< + number, + TaskId + >; + const rootChildren = + childrenIdsByParentId.get(RootNodeId) ?? []; + rootChildren.forEach((id, idx) => { + // reenumerate priorities based on index + rootChildrenByPriority.set(idx, id); + }); + + rootChildrenByPriority.forEach((priority, chId) => { + const x = taskById.get(chId)!; + if (this.RowIndex(x) === 0) { + const neighbourId = + rootChildrenByPriority.get(x.priority + 1) ?? + x.taskId; + + this.subtreeWidthByHalfPriority.set( + Math.floor(x.priority / 2), + Math.max( + subtreeSizeByNodeId.get(x.taskId)!.x, + subtreeSizeByNodeId.get(neighbourId)!.x, + ), + ); + } + }); + + depthOneTasks.forEach((x) => { + const t = [...this.subtreeWidthByHalfPriority] + .filter(([k, _]) => k < Math.floor(x.priority / 2)) + .map(([_, v]) => v); + + const tSum = t.reduce((a, b) => a + b, 0); + + finalChildShifts.set(x.taskId, { + x: + finalChildShifts.get(x.taskId)!.x + + (this.xshift + this.xshift * t.length + tSum) * + this.ParentToChildHorizontalShift, + y: + this.RowIndex(x) === 0 + ? yShiftByRowId.get(this.RowIndex(x))! + : -yShiftByRowId.get(this.RowIndex(x))!, + }); + }); + } + } + return finalChildShifts; + } + + public RowIndex(node: TaskData): number { + switch (this.Algorithm) { + case AlgorithmEnum.SingleRow: + return 0; + case AlgorithmEnum.DoubleRow: + return node.priority % 2; + default: + throw new Error("AssumptionViolation"); + } + } +} diff --git a/src/ProjectData.svelte.ts b/src/ProjectData.svelte.ts index f44ee9e..7061f5e 100644 --- a/src/ProjectData.svelte.ts +++ b/src/ProjectData.svelte.ts @@ -1,9 +1,9 @@ -import { StatusCode, type TaskData } from "./types"; -import TaskmapPlugin from "./main"; +import { StatusCode, type TaskData, type TaskId } from "./types"; +import { NoNodeId, RootNodeId } from "./NodePositionsCalculator"; export class ProjectData { tasks = $state(new Array()); - curTaskId = 0; + curTaskId = RootNodeId; public static getDefault(): ProjectData { return new ProjectData({ @@ -24,19 +24,35 @@ export class ProjectData { this.tasks = obj.tasks; this.curTaskId = obj.curTaskId; if (this.tasks.length == 0) { - this.addTask(); + this.addRootTask(); } } - public addTask() { + public addRootTask() { + this.tasks.push({ + taskId: this.curTaskId, + parentId: NoNodeId, + status: StatusCode.IN_PROGRESS, + name: "root", + priority: 0, + depth: 0, + deleted: false, + }); + this.curTaskId++; + } + + public addTask(parentId: TaskId) { const id = this.curTaskId; this.tasks.push({ taskId: id, + parentId: parentId, status: StatusCode.DRAFT, name: "default", deleted: false, + priority: 0, // TODO: change to amount of siblings + depth: this.getTask(parentId).depth + 1, }); - this.curTaskId += 1; + this.curTaskId++; return id; } diff --git a/src/TaskmapView.ts b/src/TaskmapView.ts index b581f69..687660e 100644 --- a/src/TaskmapView.ts +++ b/src/TaskmapView.ts @@ -3,6 +3,7 @@ import { mount } from "svelte"; import { DEFAULT_DATA, ProjectData } from "./ProjectData.svelte"; import pixi from "./components/pixi.svelte"; import { UIState } from "./pixi/GlobalState.svelte"; +import { NodePositionsCalculator } from "./NodePositionsCalculator"; export const VIEW_TYPE_EXAMPLE = "example"; @@ -47,7 +48,12 @@ export class TaskmapView extends TextFileView { this.pixiComponent = mount(pixi, { target: this.contentEl, props: { - uiState: new UIState(this.projectData, this.app), + uiState: new UIState( + this, + this.projectData, + this.app, + new NodePositionsCalculator(), + ), projectData: this.projectData, }, }); diff --git a/src/components/AddTaskButton.svelte b/src/components/AddTaskButton.svelte index 514fae0..c1c6a03 100644 --- a/src/components/AddTaskButton.svelte +++ b/src/components/AddTaskButton.svelte @@ -27,7 +27,7 @@ } function addButtonPressed (event: MouseEvent) { console.log('add icon clicked') - uiState.addTask(); + uiState.addTask(taskId); event.stopPropagation(); } @@ -47,30 +47,37 @@ " > + {#if entered} - - - - + + + + {/if} diff --git a/src/components/Task.svelte b/src/components/Task.svelte index 78b6a03..db3fa02 100644 --- a/src/components/Task.svelte +++ b/src/components/Task.svelte @@ -103,6 +103,7 @@ {#if !projectData.isTaskDeleted(taskId)} +{#key uiState.updateOnZoomCounter}
; + taskId: TaskId; + tween: Spring<{ x: number; y: number }> | null; }>; // svg elements are pixelated zooming from scale < 1 to scale > 1, so we force a redraw manually updateOnZoomCounter = $state(0); @@ -22,19 +25,26 @@ export class UIState { private tweenOptions = { duration: 300 }; private springOptions = { stiffness: 0.07, damping: 0.7 }; - constructor(projectData: ProjectData, app: App) { + constructor( + view: TaskmapView, + projectData: ProjectData, + app: App, + nodePositionsCalculator: NodePositionsCalculator, + ) { + this.view = view; + this.nodePositionsCalculator = nodePositionsCalculator; this.app = app; this.projectData = projectData; - const posArray = projectData.tasks + + this.taskPositions = projectData.tasks .filter((t) => !t.deleted) .map((task) => { - const t = new Spring( - this.calcTaskPosition(task.taskId), - this.springOptions, - ); - return { taskId: task.taskId, tween: t }; + return { + taskId: task.taskId, + tween: null, + }; }); - this.taskPositions = posArray; + this.updateTaskPositions(); } public incrementUpdateOnZoomCounter(): void { @@ -42,8 +52,20 @@ export class UIState { } public updateTaskPositions() { + const positions = + this.nodePositionsCalculator.CalculatePositionsInGlobalFrame( + this.projectData.tasks.filter((t) => !t.deleted), + { x: 0, y: 0 }, + ); this.taskPositions.forEach((taskPos) => { - taskPos.tween.target = this.calcTaskPosition(taskPos.taskId); + const newPos = positions.get(taskPos.taskId); + if (newPos === undefined) { + throw new Error(); + } + if (taskPos.tween === null) { + taskPos.tween = new Spring(newPos, this.springOptions); + } + taskPos.tween.target = newPos; }); } @@ -68,26 +90,29 @@ export class UIState { } public getActiveView(): TaskmapView { - const x = TaskmapPlugin.getActiveView(); - if (x) { - return x; - } else { - throw new Error("Unable to get active view"); - } + return this.view; + // const x = TaskmapPlugin.getActiveView(); + // if (x) { + // return x; + // } else { + // throw new Error("Unable to get active view"); + // } } - public addTask() { - const id = this.projectData.addTask(); + public addTask(parentId: TaskId): void { + const id = this.projectData.addTask(parentId); + this.save(); this.taskPositions.push({ taskId: id, - tween: new Spring(this.calcTaskPosition(id), this.springOptions), + tween: null, }); - this.save(); + this.updateTaskPositions(); } public removeTask(id: number) { this.setSelectedTaskId(-1); this.projectData.removeTask(id); + this.taskPositions = this.taskPositions.filter((t) => t.taskId !== id); this.updateTaskPositions(); this.save(); } @@ -106,19 +131,8 @@ export class UIState { // change its status } - private calcTaskPosition(taskId: number) { - const base_position = { x: 200, y: 500 }; - const idx = this.projectData.tasks - .filter((t) => !t.deleted) - .findIndex((t) => t.taskId == taskId); - return { - x: base_position.x + 200 * idx, - y: base_position.y + 200 * idx, - }; - } - public getCurrentTaskPosition(taskId: number) { - const pos = this.taskPositions.find((t) => t.taskId === taskId)!.tween + const pos = this.taskPositions.find((t) => t.taskId === taskId)!.tween! .current; if (taskId == 5) { console.log(JSON.stringify(pos)); diff --git a/src/types.ts b/src/types.ts index 9977d3c..26c677a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,11 +23,20 @@ export enum StatusCode { DONE, } +export interface Vector2 { + x: number; + y: number; +} +export type TaskId = number; + export type TaskData = { name: string; - taskId: number; + taskId: TaskId; status: StatusCode; deleted: boolean; + parentId: TaskId; + depth: number; + priority: number; }; export type Context = {