added position calculations

This commit is contained in:
poanse 2025-12-17 11:37:44 +03:00
parent d90f45b637
commit ce0821ebb6
7 changed files with 477 additions and 68 deletions

View file

@ -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<number, number> = new Map<
number,
number
>();
public xshift = 0.25;
public RootFramePositions: Map<TaskId, Vector2> = new Map<
TaskId,
Vector2
>();
public CalculatePositionsInGlobalFrame(
tasks: TaskData[],
rootPosition: Vector2,
): Map<TaskId, Vector2> {
const rootFrame = this.CalculatePositionsInRootFrame(tasks);
const result: Map<TaskId, Vector2> = new Map<TaskId, Vector2>();
for (const [key, value] of rootFrame) {
result.set(key, V2.add(value, rootPosition));
}
return result;
}
/**
* Позиции относительно рута
*/
private CalculatePositionsInRootFrame(
tasks: TaskData[],
): Map<TaskId, Vector2> {
const parentFramePositions =
this.CalculatePositionsInParentFrame(tasks);
const positions: Map<TaskId, Vector2> = new Map<TaskId, Vector2>();
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<TaskId, Vector2> {
// Расположение ноды родителя относительно высоты поддерева. 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<TaskId, TaskData>();
tasks.forEach((t) => taskById.set(t.taskId, t));
const childrenIdsByParentId: Map<TaskId, TaskId[]> = 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<TaskId, Vector2> = 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<TaskId, Vector2> = 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<TaskId, Vector2> = new Map<
TaskId,
Vector2
>();
parentIds.forEach((parentId) => {
parentAlignmentShift.set(
parentId,
V2.mult(
V2.sub(subtreeSizeByNodeId.get(parentId)!, parentDelta),
alignmentRatio,
),
);
});
// Шифт дочерних нод относительно родителя и уже в нормальных координатах
const finalChildShifts: Map<TaskId, Vector2> = 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<number, TaskData[]> = 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<number, number> = {} 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<number, number>;
const rootChildrenByPriority: Map<number, TaskId> = {} 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");
}
}
}

View file

@ -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<TaskData>());
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;
}

View file

@ -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,
},
});

View file

@ -27,7 +27,7 @@
}
function addButtonPressed (event: MouseEvent) {
console.log('add icon clicked')
uiState.addTask();
uiState.addTask(taskId);
event.stopPropagation();
}
@ -47,30 +47,37 @@
"
>
</div>
{#if entered}
<svg
class="button-add"
class:draft={taskData.status === StatusCode.DRAFT}
class:ready={taskData.status === StatusCode.READY}
class:in-progress={taskData.status === StatusCode.IN_PROGRESS}
class:done={taskData.status === StatusCode.DONE}
onmouseenter={onEnter}
onmouseleave={onLeave}
onclick={addButtonPressed}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"
style="
position: absolute;
left: {TASK_SIZE.width - 41/2 - 1}px;
top: {TASK_SIZE.height/2 - 41/2}px;
width: 41;
height: 41;
"
>
<circle
cx="12" cy="12" r="10"
/>
<path d="M5 12h14"/><path d="M12 5v14"/>
</svg>
<svg
class="button-add"
class:draft={taskData.status === StatusCode.DRAFT}
class:ready={taskData.status === StatusCode.READY}
class:in-progress={taskData.status === StatusCode.IN_PROGRESS}
class:done={taskData.status === StatusCode.DONE}
onmouseenter={onEnter}
onmouseleave={onLeave}
onclick={addButtonPressed}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"
style="
position: absolute;
left: {TASK_SIZE.width - 41/2 - 1}px;
top: {TASK_SIZE.height/2 - 41/2}px;
width: 41;
height: 41;
"
>
<circle
cx="12" cy="12" r="10"
/>
<path
stroke-width="1.5"
d="M5 12h14"
/><path
stroke-width="1.5"
d="M12 5v14"
/>
</svg>
{/if}

View file

@ -103,6 +103,7 @@
{#if !projectData.isTaskDeleted(taskId)}
{#key uiState.updateOnZoomCounter}
<div
class="task-container"
style="
@ -159,6 +160,7 @@
</div>
<AddTaskButton {uiState} {projectData} {taskId} />
</div>
{/key}
{/if}
<!--top: {coords.y}px;-->

View file

@ -1,19 +1,22 @@
import TaskmapPlugin from "../main";
import type { ProjectData } from "../ProjectData.svelte";
import { StatusCode } from "../types";
import { StatusCode, type TaskId } from "../types";
import { Spring } from "svelte/motion";
import type { App } from "obsidian";
import type { TaskmapView } from "../TaskmapView";
import type { NodePositionsCalculator } from "../NodePositionsCalculator";
export class UIState {
app: App;
view: TaskmapView;
nodePositionsCalculator: NodePositionsCalculator;
pressedButtonIndex = $state(-1);
selectedTaskId = $state(-1);
toolbarStatus = $state(StatusCode.DRAFT);
projectData: ProjectData;
taskPositions: Array<{
taskId: number;
tween: Spring<{ x: number; y: number }>;
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));

View file

@ -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 = {