From af34f98d2cf209b2a7189dce5d519bec0066a2e9 Mon Sep 17 00:00:00 2001 From: poanse <50020771+poanse@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:57:28 +0300 Subject: [PATCH] optimized for large number of tasks --- package-lock.json | 23 +++- package.json | 4 +- src/Context.svelte.ts | 17 ++- src/FileWatcherWithCache.ts | 4 +- src/NodePositionsCalculator.ts | 15 ++- src/SaveManager.ts | 4 +- src/TaskmapView.ts | 4 +- src/components/TaskmapContainer.svelte | 34 +++--- src/data/Action.ts | 9 +- src/data/HistoryManager.svelte.ts | 3 + src/data/ProjectData.svelte.ts | 150 ++++++++++++++++++------- src/data/VersionedData.ts | 16 ++- src/main.ts | 2 +- 13 files changed, 203 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index aad001a..7bb0d81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "obsidian-taskmap", - "version": "0.0.8", + "version": "0.1.3", "license": "MIT", "dependencies": { "@napolab/alpha-blend": "^2.0.0", @@ -39,6 +39,7 @@ "svelte-check": "^4.3.4", "svelte-preprocess": "^6.0.3", "tslib": "latest", + "tsx": "^4.21.0", "typescript": "latest", "typescript-eslint": "^8.54.0" } @@ -5990,6 +5991,26 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index bac7a88..0dfbe6b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "version": "node version-bump.mjs && git add manifest.json versions.json", - "svelte-check": "svelte-check --tsconfig tsconfig.json" + "svelte-check": "svelte-check --tsconfig tsconfig.json", + "perf:tasks": "tsx scripts/perf-tasks.ts" }, "keywords": [], "author": "", @@ -33,6 +34,7 @@ "svelte-check": "^4.3.4", "svelte-preprocess": "^6.0.3", "tslib": "latest", + "tsx": "^4.21.0", "typescript": "latest", "typescript-eslint": "^8.54.0" }, diff --git a/src/Context.svelte.ts b/src/Context.svelte.ts index a8ea28a..655186c 100644 --- a/src/Context.svelte.ts +++ b/src/Context.svelte.ts @@ -102,7 +102,7 @@ export class Context { this.pressedButtonCode = -1; const sel = this.versionedData.getTaskOption(this.selectedTaskId); - if (sel == null || sel.deleted) { + if (sel === undefined || sel.deleted) { this.selectedTaskId = NoTaskId; this.toolbarStatus = DEFAULT_TOOLBAR_STATUS; } else { @@ -110,12 +110,12 @@ export class Context { } const focused = this.versionedData.getTaskOption(this.focusedTaskId); - if (focused == null || focused.deleted) { + if (focused === undefined || focused.deleted) { this.focusedTaskId = RootTaskId; } const editing = this.versionedData.getTaskOption(this.editingTaskId); - if (editing == null || editing.deleted) { + if (editing === undefined || editing.deleted) { this.editingTaskId = NoTaskId; } @@ -274,10 +274,7 @@ export class Context { } public isAncestorOfHidden(taskId: TaskId): boolean { - return this.versionedData - .getAncestors(this.focusedTaskId) - .map((t) => t.taskId) - .includes(taskId); + return this.versionedData.getAncestorIds(this.focusedTaskId).includes(taskId); } public updateTaskPositions(draggingOnly = false) { @@ -471,9 +468,9 @@ export class Context { const task = this.versionedData.getTask(taskId); const ancestorTasks = this.versionedData.getAncestors(taskId); - const focusedAncestorIds = this.versionedData - .getAncestors(this.focusedTaskId) - .map((t) => t.taskId); + const focusedAncestorIds = this.versionedData.getAncestorIds( + this.focusedTaskId, + ); const focusedDescendantIds = this.versionedData.getDescendantIds( this.focusedTaskId, ); diff --git a/src/FileWatcherWithCache.ts b/src/FileWatcherWithCache.ts index f2b92d0..c1d2e1a 100644 --- a/src/FileWatcherWithCache.ts +++ b/src/FileWatcherWithCache.ts @@ -113,7 +113,7 @@ export class FileWatcherWithCache { if (!projectData) { continue; } - const affectedTasks = projectData.tasks.filter( + const affectedTasks = projectData.getTasks().filter( (t) => t.path && deletedFile.path === t.path, ); for (const t of affectedTasks) { @@ -153,7 +153,7 @@ export class FileWatcherWithCache { if (!projectData) { continue; } - const affectedTasks = projectData.tasks.filter( + const affectedTasks = projectData.getTasks().filter( (t) => t.path && t.path === oldPath, ); for (const t of affectedTasks) { diff --git a/src/NodePositionsCalculator.ts b/src/NodePositionsCalculator.ts index 0f43c2f..64d0f64 100644 --- a/src/NodePositionsCalculator.ts +++ b/src/NodePositionsCalculator.ts @@ -204,6 +204,11 @@ export class NodePositionsCalculator { Vector2 >(); parentIds.forEach((parentId) => { + if (!subtreeSizeByNodeId.has(parentId)) { + throw new Error( + `Failed to calculate layout: taskId [${parentId}] not in subtreeSizeByNodeId`, + ); + } parentAlignmentShift.set( parentId, V2.mult( @@ -267,7 +272,10 @@ export class NodePositionsCalculator { depthOneTasksByRow.get(idx)!.push(t); }); - const yShiftByRowId: Map = new Map(); + const yShiftByRowId: Map = new Map< + number, + number + >(); [...depthOneTasksByRow.keys()].forEach((key) => { const rowIdx = Number(key); const elements = depthOneTasksByRow.get(rowIdx)!; @@ -302,7 +310,10 @@ export class NodePositionsCalculator { // Sizes of subtrees by x. If 2 tasks are above each other, the larger one is used. this.subtreeWidthByHalfPriority = new Map(); - const rootChildrenByPriority: Map = new Map(); + const rootChildrenByPriority: Map = new Map< + number, + TaskId + >(); const rootChildren = childrenIdsByParentId.get(RootTaskId) ?? []; rootChildren.forEach((id, idx) => { diff --git a/src/SaveManager.ts b/src/SaveManager.ts index db91547..439373f 100644 --- a/src/SaveManager.ts +++ b/src/SaveManager.ts @@ -12,7 +12,7 @@ export function serializeProjectData(projectData: ProjectData) { return JSON.stringify( { schemaVersion: TASKMAP_FILE_SCHEMA_VERSION, - tasks: projectData.tasks, + tasks: [...projectData.tasks.values()], blockerPairs: projectData.blockerPairs, folderPath: projectData.folderPath, curTaskId: projectData.curTaskId, @@ -56,3 +56,5 @@ export function deserializeProjectData(data: string) { const input = parseProjectFileJson(parsed); return new ProjectData(input); } + +export const DEFAULT_DATA = serializeProjectData(ProjectData.getDefault()); diff --git a/src/TaskmapView.ts b/src/TaskmapView.ts index 2971d91..6f6598e 100644 --- a/src/TaskmapView.ts +++ b/src/TaskmapView.ts @@ -1,10 +1,10 @@ import { debounce, TextFileView, TFile, WorkspaceLeaf } from "obsidian"; import { mount, unmount } from "svelte"; -import { DEFAULT_DATA, ProjectData } from "./data/ProjectData.svelte"; +import { ProjectData } from "./data/ProjectData.svelte"; import { Context } from "./Context.svelte.js"; import { NodePositionsCalculator } from "./NodePositionsCalculator"; import TaskmapContainer from "./components/TaskmapContainer.svelte"; -import { deserializeProjectData, updateFile } from "./SaveManager"; +import { DEFAULT_DATA, deserializeProjectData, updateFile } from "./SaveManager"; import type TaskmapPlugin from "./main"; import { VersionedData } from "./data/VersionedData"; import { HistoryManager } from "./data/HistoryManager.svelte"; diff --git a/src/components/TaskmapContainer.svelte b/src/components/TaskmapContainer.svelte index e90f3a0..334c1f2 100644 --- a/src/components/TaskmapContainer.svelte +++ b/src/components/TaskmapContainer.svelte @@ -170,18 +170,20 @@ - {#each (context.versionedData.getTasks() - .filter(t => !context.isTaskHidden(t.taskId)) - .filter(t => t.taskId !== RootTaskId) - .filter(t => !context.versionedData.isBranchHidden(t.taskId)) - ) as task (task.taskId)} - - {/each} + {#key context.versionedData.getTasksVersion()} + {#each (context.versionedData.getTasks() + .filter(t => !context.isTaskHidden(t.taskId)) + .filter(t => t.taskId !== RootTaskId) + .filter(t => !context.versionedData.isBranchHidden(t.taskId)) + ) as task (task.taskId)} + + {/each} + {/key} @@ -189,9 +191,11 @@ class="task-layer" role="presentation" > - {#each context.versionedData.getTasks().filter(t => !context.isTaskHidden(t.taskId)) as task (task.taskId)} - - {/each} + {#key context.versionedData.getTasksVersion()} + {#each context.versionedData.getTasks().filter(t => !context.isTaskHidden(t.taskId)) as task (task.taskId)} + + {/each} + {/key} diff --git a/src/data/Action.ts b/src/data/Action.ts index 3497b0a..01329f9 100644 --- a/src/data/Action.ts +++ b/src/data/Action.ts @@ -17,7 +17,7 @@ export class AddTaskAction implements Action { do(data: ProjectData): void { const childrenCount = data.getChildren(this.parentId).length; this.addedTaskId = data.curTaskId; - data.tasks.push({ + const task = { taskId: this.addedTaskId, parentId: this.parentId, status: StatusCode.READY, @@ -26,8 +26,8 @@ export class AddTaskAction implements Action { hidden: false, priority: childrenCount, depth: data.getTask(this.parentId).depth + 1, - }); - data.curTaskId++; + }; + data.addTask(task); data.recalcStatusRecursive(this.parentId); } @@ -38,8 +38,7 @@ export class AddTaskAction implements Action { if (data.curTaskId != this.addedTaskId + 1) { throw new Error(); } - data.tasks = data.tasks.filter((t) => t.taskId !== this.addedTaskId); - data.curTaskId--; + data.removeTask(this.addedTaskId); this.addedTaskId = undefined; } } diff --git a/src/data/HistoryManager.svelte.ts b/src/data/HistoryManager.svelte.ts index 91b4652..98ecb13 100644 --- a/src/data/HistoryManager.svelte.ts +++ b/src/data/HistoryManager.svelte.ts @@ -7,6 +7,7 @@ export class HistoryManager { execute(action: Action, data: ProjectData): void { action.do(data); + data.markTasksUpdated(); this.undoStack.push(action); this.redoStack = []; // Clear redo stack on new action } @@ -16,6 +17,7 @@ export class HistoryManager { if (!action) return false; action.undo(data); + data.markTasksUpdated(); this.redoStack.push(action); return true; } @@ -25,6 +27,7 @@ export class HistoryManager { if (!action) return false; action.do(data); + data.markTasksUpdated(); this.undoStack.push(action); return true; } diff --git a/src/data/ProjectData.svelte.ts b/src/data/ProjectData.svelte.ts index f5c19b4..3142680 100644 --- a/src/data/ProjectData.svelte.ts +++ b/src/data/ProjectData.svelte.ts @@ -4,12 +4,16 @@ type TaskData, type TaskId, } from "../types"; +import { SvelteMap } from "svelte/reactivity"; import { NoTaskId, RootTaskId } from "../NodePositionsCalculator"; -import { serializeProjectData } from "../SaveManager"; import type { ProjectFileParsed } from "./ProjectDataSchema"; export class ProjectData { - tasks = $state(new Array()); + tasks = new SvelteMap(); + childrenCache = new SvelteMap(); + ancestorsCache = new SvelteMap(); + descendantsCache = new SvelteMap(); + tasksVersion = $state(0); blockerPairs = $state(new Array()); folderPath: string | undefined; curTaskId = RootTaskId; @@ -24,17 +28,96 @@ export class ProjectData { } constructor(obj: ProjectFileParsed) { - this.tasks = obj.tasks; + this.tasks = new SvelteMap( + obj.tasks.map((task) => [task.taskId, task]), + ); + if (this.tasks.size == 0) { + this.addRootTask(); + } + this.rebuildCaches(); this.blockerPairs = obj.blockerPairs ?? []; this.folderPath = obj.folderPath; this.curTaskId = obj.curTaskId; - if (this.tasks.length == 0) { - this.addRootTask(); + } + + public markTasksUpdated() { + this.tasksVersion += 1; + } + + private rebuildCaches() { + // Order matters: descendants rely on ancestors, and ancestors rely on children. + this.rebuildChildrenCache(); + this.rebuildAncestorsCache(); + this.rebuildDescendantsCache(); + } + + private rebuildChildrenCache() { + this.childrenCache = new SvelteMap(); + for (const task of this.getTasks()) { + const children = this.childrenCache.get(task.parentId) ?? []; + children.push(task.taskId); + this.childrenCache.set(task.parentId, children); } } + // Rebuild ancestors by traversing childrenCache top-down from root. + private rebuildAncestorsCache() { + this.ancestorsCache = new SvelteMap(); + const queue: TaskId[] = [RootTaskId]; + + while (queue.length > 0) { + const taskId = queue.shift(); + if (taskId === undefined) { + continue; + } + const task = this.getTask(taskId); + if (task.depth === 0) { + this.ancestorsCache.set(taskId, []); + } else { + const parentAncestors = + this.ancestorsCache.get(task.parentId) ?? []; + this.ancestorsCache.set(taskId, [ + task.parentId, + ...parentAncestors, + ]); + } + queue.push(...(this.childrenCache.get(taskId) ?? [])); + } + } + + private rebuildDescendantsCache() { + this.descendantsCache = new SvelteMap(); + + for (const task of this.getTasks()) { + this.descendantsCache.set(task.taskId, [task.taskId]); + } + + for (const taskId of this.tasks.keys()) { + for (const ancestorId of this.getAncestorIds(taskId)) { + const descendants = this.descendantsCache.get(ancestorId); + if (descendants !== undefined) { + descendants.push(taskId); + } + } + } + } + + public addTask(task: TaskData) { + this.tasks.set(task.taskId, task); + this.rebuildCaches(); + this.markTasksUpdated(); + this.curTaskId++; + } + + public removeTask(taskId: TaskId) { + this.tasks.delete(taskId); + this.rebuildCaches(); + this.markTasksUpdated(); + this.curTaskId--; + } + public addRootTask() { - this.tasks.push({ + const task = { taskId: this.curTaskId, parentId: NoTaskId, status: StatusCode.IN_PROGRESS, @@ -43,38 +126,24 @@ export class ProjectData { depth: 0, deleted: false, hidden: false, - }); - this.curTaskId++; + }; + this.addTask(task); } - public getDescendantIds(taskId: number, includeDeleted: boolean = false) { - const tasks = [taskId]; - const result: TaskId[] = []; - while (tasks.length > 0) { - const task = tasks.pop(); - if (task === undefined) { - break; - } - result.push(task); - tasks.push(...this.getChildren(task, includeDeleted)); - } - return result; + public getDescendantIds(taskId: number) { + return this.descendantsCache.get(taskId) ?? [taskId]; } public getAncestors(taskId: number) { - const res: TaskData[] = []; - let task = this.getTask(taskId); - while (task.depth != 0) { - task = this.getTask(task.parentId); - res.push(task); - } - return res; + return this.getAncestorIds(taskId).map((id) => this.getTask(id)); + } + + public getAncestorIds(taskId: number) { + return [...(this.ancestorsCache.get(taskId) ?? [])]; } public isAncestorOf(taskId: TaskId, candidate: TaskId) { - return this.getAncestors(taskId) - .map((t) => t.taskId) - .includes(candidate); + return this.getAncestorIds(taskId).includes(candidate); } public isDescendantOf(taskId: TaskId, candidate: TaskId) { @@ -82,15 +151,15 @@ export class ProjectData { } public getChildren(taskId: number, includeDeleted: boolean = false) { - let res = this.tasks.filter((t) => t.parentId === taskId); - if (!includeDeleted) { - res = res.filter((t) => !t.deleted); + const childIds = this.childrenCache.get(taskId) ?? []; + if (includeDeleted) { + return [...childIds]; } - return res.map((t) => t.taskId); + return childIds.filter((id) => !this.getTask(id).deleted); } public getTask(taskId: number) { - const res = this.tasks.find((t) => t.taskId == taskId); + const res = this.tasks.get(taskId); if (res) { return res; } else { @@ -98,6 +167,12 @@ export class ProjectData { } } + public getTasks(includeDeleted: boolean = false) { + return [...this.tasks.values()].filter( + (t) => includeDeleted || !t.deleted, + ); + } + public isTaskDeleted(taskId: number) { return this.getTask(taskId).deleted; } @@ -106,9 +181,10 @@ export class ProjectData { const taskData = this.getTask(taskId); const oldParentId = taskData.parentId; taskData.parentId = newParentId; + this.rebuildCaches(); this.recalcStatusRecursive(oldParentId); this.recalcStatusRecursive(newParentId); - [taskId, ...this.getDescendantIds(taskId)].forEach((taskId) => { + this.getDescendantIds(taskId).forEach((taskId) => { const task = this.getTask(taskId); task.depth = this.getTask(task.parentId).depth + 1; }); @@ -212,5 +288,3 @@ export class ProjectData { this.folderPath = path === "" ? undefined : path; }; } - -export const DEFAULT_DATA = serializeProjectData(ProjectData.getDefault()); diff --git a/src/data/VersionedData.ts b/src/data/VersionedData.ts index c38cbdb..ac338d1 100644 --- a/src/data/VersionedData.ts +++ b/src/data/VersionedData.ts @@ -133,7 +133,11 @@ export class VersionedData { }; public getTasks = (includeDeleted = false) => { - return this.data.tasks.filter((t) => includeDeleted || !t.deleted); + return this.data.getTasks(includeDeleted); + }; + + public getTasksVersion = () => { + return this.data.tasksVersion; }; public getTask = (taskId: TaskId) => { @@ -141,7 +145,7 @@ export class VersionedData { }; public getTaskOption = (taskId: TaskId) => { - return this.data.tasks.find((t) => t.taskId == taskId) ?? null; + return this.data.tasks.get(taskId); }; public getChildren = (taskId: TaskId, includeDeleted?: boolean) => { @@ -152,8 +156,12 @@ export class VersionedData { return this.data.getAncestors(taskId); }; - public getDescendantIds = (taskId: TaskId, includeDeleted?: boolean) => { - return this.data.getDescendantIds(taskId, includeDeleted); + public getAncestorIds = (taskId: TaskId) => { + return this.data.getAncestorIds(taskId); + }; + + public getDescendantIds = (taskId: TaskId) => { + return this.data.getDescendantIds(taskId); }; public isAncestorOf = (taskId: TaskId, candidate: TaskId) => { diff --git a/src/main.ts b/src/main.ts index 9d5ae0a..3335bb4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,9 +2,9 @@ import { addIcon, Plugin } from "obsidian"; import { TASKMAP_VIEW_TYPE, TaskmapView } from "./TaskmapView"; import { DEFAULT_SETTINGS, type TaskmapSettings } from "./TaskmapSettings"; import { TaskmapSettingTab } from "./TaskmapSettingTab"; -import { DEFAULT_DATA } from "./data/ProjectData.svelte"; import { FileWatcherWithCache } from "./FileWatcherWithCache"; import { LOGO_CONTENT, LOGO_NAME } from "./Constants"; +import { DEFAULT_DATA } from "./SaveManager"; export const FILE_EXTENSION = "taskmap";