From e75fcd60cd9990cba3f4c122969686a50c24bc18 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 4 Oct 2025 08:56:49 -0400 Subject: [PATCH] refactor: migrate graph canvas to use web worker for force layout computation --- docs/GRAPH_FREEZE_FIX.md | 166 +++++++++++++ e2e/graph.spec.ts | 37 +++ package.json | 1 + src/app/graph/graph-canvas.component.ts | 232 ++++++++++-------- src/app/graph/graph-layout.service.ts | 129 ++++++++++ src/app/graph/graph-layout.worker.ts | 170 +++++++++++++ src/app/graph/graph.selectors.ts | 146 +++++++++-- .../graph-view-container-v2.component.ts | 32 ++- src/services/vault.service.ts | 53 +++- vault/.obsidian/graph.json | 34 +-- vault/.obsidian/graph.json.bak | 20 +- vault/.obsidian/workspace.json | 35 +-- 12 files changed, 869 insertions(+), 186 deletions(-) create mode 100644 docs/GRAPH_FREEZE_FIX.md create mode 100644 src/app/graph/graph-layout.service.ts create mode 100644 src/app/graph/graph-layout.worker.ts diff --git a/docs/GRAPH_FREEZE_FIX.md b/docs/GRAPH_FREEZE_FIX.md new file mode 100644 index 0000000..fdb63d2 --- /dev/null +++ b/docs/GRAPH_FREEZE_FIX.md @@ -0,0 +1,166 @@ +# Correction du gel de l'application lors de l'ouverture du Graph View + +## Problème identifié + +L'application gelait complètement pendant plusieurs secondes lors du passage à la vue graphe, empêchant même la prise de captures d'écran. + +### Cause racine + +**Complexité algorithmique O(N²) dans `VaultService.graphData()`** + +L'ancien code effectuait une recherche linéaire (`.find()`) pour CHAQUE lien trouvé dans CHAQUE note : + +```typescript +// AVANT (O(N × M × N)) +for (const note of notes) { // Boucle 1: N notes + while ((match = linkRegex.exec(note.content)) !== null) { // Boucle 2: M liens + const targetNote = notes.find(n => ...) // Boucle 3: N notes PAR LIEN! + } +} +``` + +**Résultat** : Avec 100 notes et 10 liens/note → **100,000+ opérations synchrones** bloquant le thread principal. + +### Effets en cascade + +1. `VaultService.graphData()` se recalcule (O(N²)) +2. `GraphViewContainerV2Component` déclenche multiples recalculs de computed signals +3. `GraphCanvasComponent` recrée le worker, les nodes, et le spatial index +4. **Tout cela de manière SYNCHRONE** → gel total + +## Solutions implémentées + +### 1. Optimisation O(N²) → O(N×M) dans VaultService + +**Fichier**: `src/services/vault.service.ts` + +Remplacement des `.find()` par des `Map.get()` (O(1)) : + +```typescript +// Build fast lookup maps +const noteById = new Map(); +const noteByTitle = new Map(); +const notesByAlias = new Map(); + +// Index all notes once +for (const note of notes) { + noteById.set(note.id, note); + noteByTitle.set(note.title, note); + if (Array.isArray(note.frontmatter?.aliases)) { + for (const alias of note.frontmatter.aliases) { + notesByAlias.set(alias, note); + } + } +} + +// Fast lookup during link extraction +const targetNote = noteById.get(linkPath) + || noteByTitle.get(rawLink) + || notesByAlias.get(rawLink); +``` + +**Gain** : Complexité réduite de O(N×M×N) à O(N×M) + +### 2. Debounce des mises à jour dans GraphCanvasComponent + +**Fichier**: `src/app/graph/graph-canvas.component.ts` + +Ajout d'un délai de 100ms pour éviter les cascades de recalculs : + +```typescript +// React to data changes with debounce +let updateTimer: ReturnType | null = null; +effect(() => { + const nodes = this.nodes(); + const links = this.links(); + + if (updateTimer) clearTimeout(updateTimer); + updateTimer = setTimeout(() => { + this.updateWorkerData(nodes, links); + }, 100); +}); +``` + +### 3. Throttle de reconstruction du Spatial Index + +**Fichier**: `src/app/graph/graph-canvas.component.ts` + +Augmentation de l'intervalle de reconstruction de 200ms → 500ms : + +```typescript +// Rebuild spatial index at most every 500ms to reduce main thread load +const now = performance.now(); +if (now - lastIndexBuild > 500) { + this.spatialIndex = new SpatialIndex(this.simulationNodes()); + lastIndexBuild = now; +} +``` + +### 4. Éviter les réinitialisations inutiles du worker + +**Fichier**: `src/app/graph/graph-canvas.component.ts` + +Vérification du nombre de nodes/links avant de réinitialiser : + +```typescript +private updateWorkerData(nodes: GraphNodeWithVisuals[], links: GraphLink[]): void { + // Skip update if data hasn't substantially changed + if (nodes.length === this.lastNodeCount && links.length === this.lastLinkCount && this.session) { + console.log(`[GraphCanvas] Skipping update - same data size`); + return; + } + + this.lastNodeCount = nodes.length; + this.lastLinkCount = links.length; + // ... reste de l'initialisation +} +``` + +### 5. Logs de performance pour diagnostic + +Ajout de traces pour mesurer les performances : + +```typescript +// Dans VaultService +console.log(`[GraphData] Computed in ${duration.toFixed(2)}ms: ${nodes.length} nodes, ${edges.length} edges`); + +// Dans GraphCanvasComponent +console.log(`[GraphCanvas] Updating worker: ${nodes.length} nodes, ${links.length} links`); +``` + +## Résultats attendus + +- ✅ **Réduction drastique du temps de calcul** : O(N²) → O(N×M) +- ✅ **Évite les recalculs en cascade** grâce au debounce +- ✅ **Réduit la charge du thread principal** avec throttle spatial index +- ✅ **Skip les updates inutiles** quand les données n'ont pas changé +- ✅ **Visibilité sur les performances** via les console logs + +## Validation + +Pour tester les améliorations : + +1. Ouvrir DevTools Console +2. Passer à la vue Graph +3. Observer les logs : + ``` + [GraphData] Computed in XXms: YY nodes, ZZ edges + [GraphCanvas] Updating worker: YY nodes, ZZ links + ``` +4. Vérifier que l'application ne gèle plus +5. Essayer de prendre une capture d'écran pendant le chargement → devrait fonctionner + +## Optimisations futures possibles + +Si des problèmes de performance persistent : + +1. **Lazy loading** : Charger le graph view uniquement quand l'utilisateur clique dessus +2. **Virtual scrolling** pour les graphes très larges (>1000 nodes) +3. **Web Worker pour le parsing des liens** : Déplacer l'extraction des liens dans un worker séparé +4. **Cache du graphData** : Mémoriser le résultat et n'invalider que si les notes changent +5. **Pagination du graph** : Afficher seulement les N nodes les plus connectés par défaut + +## Fichiers modifiés + +- `src/services/vault.service.ts` - Optimisation O(N²) → O(N×M) +- `src/app/graph/graph-canvas.component.ts` - Debounce, throttle, logs diff --git a/e2e/graph.spec.ts b/e2e/graph.spec.ts index 1bba459..105a3a7 100644 --- a/e2e/graph.spec.ts +++ b/e2e/graph.spec.ts @@ -62,4 +62,41 @@ test.describe('Graph Canvas', () => { const count = await legendItems.count(); expect(count).toBeGreaterThan(0); }); + + test('open/close Graph 10x remains responsive (no freeze)', async ({ page }) => { + // Capture console errors during the whole test + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + // Buttons in header/sidebar + const graphButtons = [ + page.getByRole('button', { name: /graph|graphe/i }).first(), + ]; + const filesButtons = [ + page.getByRole('button', { name: /files|fichiers/i }).first(), + ]; + + // Perform 10 rapid toggles + for (let i = 0; i < 10; i++) { + await graphButtons[0].click({ timeout: 2000 }); + // Canvas should be present quickly + await page.waitForSelector('app-graph-canvas canvas', { timeout: 3000 }); + + // Evaluate a trivial script to ensure main thread is not locked + const val = await page.evaluate(() => 21 + 21); + expect(val).toBe(42); + + // Switch back to files + await filesButtons[0].click({ timeout: 2000 }); + + // Quick evaluation again + const val2 = await page.evaluate(() => 1 + 1); + expect(val2).toBe(2); + } + + // Ensure no console errors during toggles + expect(errors.length).toBeLessThan(2); // allow transient warnings but not repeated errors + }); }); diff --git a/package.json b/package.json index af6b953..d079155 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "ng serve", "build": "ng build", + "build:workers": "ng build", "preview": "ng serve --configuration=production --port 3000 --host 127.0.0.1", "test": "ng test", "test:e2e": "playwright test" diff --git a/src/app/graph/graph-canvas.component.ts b/src/app/graph/graph-canvas.component.ts index e2bdf9e..43f468a 100644 --- a/src/app/graph/graph-canvas.component.ts +++ b/src/app/graph/graph-canvas.component.ts @@ -14,13 +14,16 @@ import { viewChild, ElementRef, afterNextRender, - OnDestroy + OnDestroy, + inject } from '@angular/core'; import { CommonModule } from '@angular/common'; -import * as d3 from 'd3-force'; -import { GraphNodeWithVisuals, GraphLink, SimulationNode, SimulationLink } from './graph-data.types'; +import { GraphNodeWithVisuals, GraphLink, SimulationNode } from './graph-data.types'; import { GraphConfig } from './graph-settings.types'; import { SpatialIndex, drawArrow } from './graph.utils'; +import { GraphLayoutService, type GraphLayoutSession } from './graph-layout.service'; +import { DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; interface TooltipData { node: GraphNodeWithVisuals; @@ -69,6 +72,9 @@ interface TooltipData { `, + host: { + '[attr.aria-busy]': 'ariaBusy() ? "true" : "false"' + }, styles: [` :host { display: block; @@ -95,10 +101,11 @@ export class GraphCanvasComponent implements OnDestroy { tooltip = signal(null); private transform = signal({ x: 0, y: 0, k: 1 }); private simulationNodes = signal([]); - private simulationLinks = signal([]); + private simulationLinks = signal([]); // Simulation - private simulation: d3.Simulation | null = null; + private layout = inject(GraphLayoutService); + private session: GraphLayoutSession | null = null; private animationFrameId: number | null = null; private isAnimating = false; @@ -115,6 +122,13 @@ export class GraphCanvasComponent implements OnDestroy { private width = 800; private height = 600; private ctx: CanvasRenderingContext2D | null = null; + private readonly destroyRef = inject(DestroyRef); + private boundResizeHandler: (() => void) | null = null; + private ariaBusy = signal(false); + + // Track last update to avoid unnecessary reinitializations + private lastNodeCount = 0; + private lastLinkCount = 0; // Neighbors cache for highlighting private neighborMap = computed(() => { @@ -131,20 +145,26 @@ export class GraphCanvasComponent implements OnDestroy { constructor() { afterNextRender(() => { this.initCanvas(); - this.initSimulation(); + this.initWorker(); }); - // React to data changes + // React to data changes with debounce to prevent cascade updates + let updateTimer: ReturnType | null = null; effect(() => { const nodes = this.nodes(); const links = this.links(); - this.updateSimulation(nodes, links); + + // Debounce updates to avoid freezing on rapid changes + if (updateTimer) clearTimeout(updateTimer); + updateTimer = setTimeout(() => { + this.updateWorkerData(nodes, links); + }, 100); }); // React to settings changes effect(() => { const settings = this.settings(); - this.updateForces(settings); + this.updateWorkerSettings(settings); this.scheduleRedraw(); }); @@ -158,12 +178,17 @@ export class GraphCanvasComponent implements OnDestroy { } ngOnDestroy(): void { - if (this.simulation) { - this.simulation.stop(); + if (this.session) { + this.session.terminate(); + this.session = null; } if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); } + if (this.boundResizeHandler) { + window.removeEventListener('resize', this.boundResizeHandler); + this.boundResizeHandler = null; + } } /** @@ -182,7 +207,13 @@ export class GraphCanvasComponent implements OnDestroy { this.resizeCanvas(); // Handle window resize - window.addEventListener('resize', () => this.resizeCanvas()); + this.boundResizeHandler = () => { + this.resizeCanvas(); + if (this.session) { + this.session.updateSettings({ width: this.width, height: this.height }); + } + }; + window.addEventListener('resize', this.boundResizeHandler); } /** @@ -210,105 +241,100 @@ export class GraphCanvasComponent implements OnDestroy { /** * Initialize d3-force simulation */ - private initSimulation(): void { - this.simulation = d3.forceSimulation() - .alphaDecay(0.02) - .velocityDecay(0.3) - .on('tick', () => { - this.onSimulationTick(); - }) - .on('end', () => { + private initWorker(): void { + // Create a new layout session + this.session = this.layout.createSession(); + + // Subscribe to worker position updates + let lastIndexBuild = 0; + this.session.positions$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(msg => { + const map = new Map(this.simulationNodes().map(n => [n.id, n] as const)); + for (const p of msg.nodes) { + const node = map.get(p.id); + if (node) { + node.x = p.x; + node.y = p.y; + } + } + // Trigger redraw + this.scheduleRedraw(); + // Rebuild spatial index at most every 500ms to reduce main thread load + const now = performance.now(); + if (now - lastIndexBuild > 500) { + this.spatialIndex = new SpatialIndex(this.simulationNodes()); + lastIndexBuild = now; + } + }); + + // Stop animation when simulation ends + this.session.end$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.isAnimating = false; + this.ariaBusy.set(false); }); } /** * Update simulation with new data */ - private updateSimulation(nodes: GraphNodeWithVisuals[], links: GraphLink[]): void { - if (!this.simulation) return; - - // Stop current simulation - this.simulation.stop(); - - // Create simulation nodes + private updateWorkerData(nodes: GraphNodeWithVisuals[], links: GraphLink[]): void { + // Skip update if data hasn't substantially changed (same counts) + if (nodes.length === this.lastNodeCount && links.length === this.lastLinkCount && this.session) { + console.log(`[GraphCanvas] Skipping update - same data size (${nodes.length} nodes, ${links.length} links)`); + return; + } + + console.log(`[GraphCanvas] Updating worker: ${nodes.length} nodes, ${links.length} links (prev: ${this.lastNodeCount}/${this.lastLinkCount})`); + this.lastNodeCount = nodes.length; + this.lastLinkCount = links.length; + + // Create local nodes with initial jitter for better convergence visuals const simNodes: SimulationNode[] = nodes.map(node => ({ ...node, x: this.width / 2 + (Math.random() - 0.5) * 100, y: this.height / 2 + (Math.random() - 0.5) * 100 })); - // Create simulation links - const simLinks: SimulationLink[] = links.map(link => ({ - source: link.source, - target: link.target, - directed: link.directed - })); - - // Update signals this.simulationNodes.set(simNodes); - this.simulationLinks.set(simLinks); - - // Update spatial index + this.simulationLinks.set(links); this.spatialIndex = new SpatialIndex(simNodes); - // Update simulation - this.simulation.nodes(simNodes); - - // Apply forces - this.updateForces(this.settings()); + // Initialize or re-init the worker with new data + if (!this.session) { + this.initWorker(); + } + if (this.session) { + this.session.init(simNodes, links, this.settings(), { width: this.width, height: this.height }); + } - // Restart simulation - this.simulation.alpha(1).restart(); + // Kick an animation frame to draw initial state this.isAnimating = true; + this.ariaBusy.set(true); this.startAnimationLoop(); } /** * Update force parameters */ - private updateForces(settings: GraphConfig): void { - if (!this.simulation) return; - - const nodes = this.simulationNodes(); - const links = this.simulationLinks(); - - // Centering forces (use forceX/forceY to control strength) - this.simulation.force('centerX', d3.forceX(this.width / 2).strength(settings.centerStrength)); - this.simulation.force('centerY', d3.forceY(this.height / 2).strength(settings.centerStrength)); - - // Repel force (charge) - this.simulation.force('charge', - d3.forceManyBody() - .strength(-50 * settings.repelStrength) - ); - - // Link force - this.simulation.force('link', - d3.forceLink(links) - .id(d => d.id) - .strength(settings.linkStrength) - .distance(settings.linkDistance) - ); - - // Collision force - this.simulation.force('collision', - d3.forceCollide() - .radius(settings.nodeSizeMultiplier * 10) - ); - - // Reheat simulation if not already running - if (this.simulation.alpha() < 0.1) { - this.simulation.alpha(0.3).restart(); - this.isAnimating = true; - this.startAnimationLoop(); - } + private updateWorkerSettings(settings: GraphConfig): void { + if (!this.session) return; + this.session.updateSettings({ + centerStrength: settings.centerStrength, + repelStrength: settings.repelStrength, + linkStrength: settings.linkStrength, + linkDistance: settings.linkDistance, + nodeSizeMultiplier: settings.nodeSizeMultiplier as any, + } as any); } /** * Simulation tick handler */ private onSimulationTick(): void { + // retained for compatibility; worker drives updates this.scheduleRedraw(); } @@ -319,7 +345,8 @@ export class GraphCanvasComponent implements OnDestroy { if (this.animationFrameId) return; const animate = () => { - if (this.isAnimating || this.simulation && this.simulation.alpha() > 0.01) { + // Draw at least one frame; subsequent frames depend on isAnimating flag + if (this.isAnimating) { this.draw(); this.animationFrameId = requestAnimationFrame(animate); } else { @@ -378,18 +405,14 @@ export class GraphCanvasComponent implements OnDestroy { /** * Draw links */ - private drawLinks(ctx: CanvasRenderingContext2D, links: SimulationLink[], settings: GraphConfig): void { + private drawLinks(ctx: CanvasRenderingContext2D, links: GraphLink[], settings: GraphConfig): void { const nodeSize = settings.nodeSizeMultiplier * 5; const linkThickness = settings.lineSizeMultiplier * 1; links.forEach(link => { - const source = typeof link.source === 'string' ? null : link.source; - const target = typeof link.target === 'string' ? null : link.target; - - if (!source || !target || source.x === undefined || source.y === undefined || - target.x === undefined || target.y === undefined) { - return; - } + const source = this.simulationNodes().find(n => n.id === link.source); + const target = this.simulationNodes().find(n => n.id === link.target); + if (!source || !target || source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return; // Dim link if node is selected and this link is not connected let opacity = 0.6; @@ -510,9 +533,9 @@ export class GraphCanvasComponent implements OnDestroy { if (clickedNode) { this.draggedNode = clickedNode; this.isDragging = true; - if (this.simulation) { - clickedNode.fx = clickedNode.x; - clickedNode.fy = clickedNode.y; + if (clickedNode.x !== undefined && clickedNode.y !== undefined) { + this.session?.pinNode(clickedNode.id, clickedNode.x, clickedNode.y); + this.session?.reheat(); } } else { this.isPanning = true; @@ -530,15 +553,9 @@ export class GraphCanvasComponent implements OnDestroy { const t = this.transform(); const graphX = (x - t.x) / t.k; const graphY = (y - t.y) / t.k; - - this.draggedNode.fx = graphX; - this.draggedNode.fy = graphY; - - if (this.simulation) { - this.simulation.alpha(0.3).restart(); - this.isAnimating = true; - this.startAnimationLoop(); - } + this.session?.pinNode(this.draggedNode.id, graphX, graphY); + this.isAnimating = true; + this.startAnimationLoop(); } else if (this.isPanning) { // Pan canvas const dx = event.clientX - this.dragStart.x; @@ -583,8 +600,7 @@ export class GraphCanvasComponent implements OnDestroy { onMouseUp(event: MouseEvent): void { if (this.isDragging && this.draggedNode) { - this.draggedNode.fx = null; - this.draggedNode.fy = null; + this.session?.unpinNode(this.draggedNode.id); this.draggedNode = null; } @@ -671,10 +687,8 @@ export class GraphCanvasComponent implements OnDestroy { * Public API: Restart animation */ animate(): void { - if (this.simulation) { - this.simulation.alpha(1).restart(); - this.isAnimating = true; - this.startAnimationLoop(); - } + this.session?.reheat(); + this.isAnimating = true; + this.startAnimationLoop(); } } diff --git a/src/app/graph/graph-layout.service.ts b/src/app/graph/graph-layout.service.ts new file mode 100644 index 0000000..7c6b029 --- /dev/null +++ b/src/app/graph/graph-layout.service.ts @@ -0,0 +1,129 @@ +import { Injectable, NgZone } from '@angular/core'; +import { Observable, Subject, shareReplay } from 'rxjs'; +import { GraphLink, GraphNodeWithVisuals } from './graph-data.types'; +import { GraphConfig } from './graph-settings.types'; + +// Types mirrored to worker +export interface WorkerNode { + id: string; + x?: number; + y?: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; +} + +export interface WorkerLink { + source: string; + target: string; +} + +export interface WorkerSettings { + width: number; + height: number; + centerStrength: number; + repelStrength: number; // charge scale: -50 * repelStrength + linkStrength: number; + linkDistance: number; + nodeSize: number; +} + +export type WorkerInMsg = + | { type: 'init'; nodes: WorkerNode[]; links: WorkerLink[]; settings: WorkerSettings } + | { type: 'updateSettings'; settings: Partial } + | { type: 'pinNode'; id: string; x: number; y: number } + | { type: 'unpinNode'; id: string } + | { type: 'reheat' } + | { type: 'stop' }; + +export type WorkerPositionsMessage = { type: 'positions'; nodes: Array<{ id: string; x: number; y: number }>; alpha: number }; +export type WorkerEndMessage = { type: 'end'; reason: 'converged' | 'stopped' | 'timeout' }; +export type WorkerOutMsg = WorkerPositionsMessage | WorkerEndMessage; + +export interface GraphLayoutSession { + positions$: Observable; + end$: Observable; + init(nodes: GraphNodeWithVisuals[], links: GraphLink[], settings: GraphConfig, size: { width: number; height: number }): void; + updateSettings(settings: Partial & { width?: number; height?: number }): void; + pinNode(id: string, x: number, y: number): void; + unpinNode(id: string): void; + reheat(): void; + terminate(): void; +} + +@Injectable({ providedIn: 'root' }) +export class GraphLayoutService { + constructor(private zone: NgZone) {} + + createSession(): GraphLayoutSession { + // Create worker using bundler-friendly URL + const worker = new Worker(new URL('./graph-layout.worker.ts', import.meta.url), { type: 'module' }); + + const positionsSubj = new Subject(); + const endSubj = new Subject(); + + // Route messages outside Angular, re-enter only when emitting + this.zone.runOutsideAngular(() => { + worker.onmessage = (ev: MessageEvent) => { + const msg = ev.data; + if (!msg) return; + if (msg.type === 'positions') { + // Avoid triggering full change detection: emit in zone only to observers + this.zone.run(() => positionsSubj.next(msg)); + } else if (msg.type === 'end') { + this.zone.run(() => endSubj.next(msg)); + } + }; + }); + + const positions$ = positionsSubj.asObservable().pipe(shareReplay({ bufferSize: 1, refCount: true })); + const end$ = endSubj.asObservable().pipe(shareReplay({ bufferSize: 1, refCount: true })); + + const send = (payload: WorkerInMsg) => worker.postMessage(payload); + + const toWorkerSettings = (cfg: GraphConfig, size: { width: number; height: number }): WorkerSettings => ({ + width: size.width, + height: size.height, + centerStrength: cfg.centerStrength, + repelStrength: cfg.repelStrength, + linkStrength: cfg.linkStrength, + linkDistance: cfg.linkDistance, + nodeSize: cfg.nodeSizeMultiplier * 5, + }); + + return { + positions$, + end$, + init: (nodes, links, settings, size) => { + const wNodes: WorkerNode[] = nodes.map((n) => ({ id: n.id })); + const wLinks: WorkerLink[] = links.map((l) => ({ source: l.source, target: l.target })); + const ws = toWorkerSettings(settings, size); + send({ type: 'init', nodes: wNodes, links: wLinks, settings: ws }); + }, + updateSettings: (patch) => { + const partial: Partial = {}; + if (patch.centerStrength != null) partial.centerStrength = patch.centerStrength; + if (patch.repelStrength != null) partial.repelStrength = patch.repelStrength; + if (patch.linkStrength != null) partial.linkStrength = patch.linkStrength; + if (patch.linkDistance != null) partial.linkDistance = patch.linkDistance; + if (patch.width != null) partial.width = patch.width; + if (patch.height != null) partial.height = patch.height; + if ((patch as any).nodeSizeMultiplier != null) partial.nodeSize = (patch as any).nodeSizeMultiplier * 5; + send({ type: 'updateSettings', settings: partial }); + }, + pinNode: (id, x, y) => send({ type: 'pinNode', id, x, y }), + unpinNode: (id) => send({ type: 'unpinNode', id }), + reheat: () => send({ type: 'reheat' }), + terminate: () => { + try { + send({ type: 'stop' }); + } finally { + worker.terminate(); + positionsSubj.complete(); + endSubj.complete(); + } + }, + }; + } +} diff --git a/src/app/graph/graph-layout.worker.ts b/src/app/graph/graph-layout.worker.ts new file mode 100644 index 0000000..e487ae5 --- /dev/null +++ b/src/app/graph/graph-layout.worker.ts @@ -0,0 +1,170 @@ +/* + Web Worker for d3-force simulation + - Receives nodes, links, settings + - Runs simulation off the main thread + - Posts throttled position updates (~30Hz) + - Times out after 10s and falls back to circular layout +*/ + +/// + +import * as d3 from 'd3-force'; + +export interface WorkerNode { + id: string; + x?: number; + y?: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; +} + +export interface WorkerLink { + source: string; + target: string; +} + +export interface WorkerSettings { + width: number; + height: number; + centerStrength: number; // 0..1 + repelStrength: number; // maps to -50 * repelStrength + linkStrength: number; // 0..1 + linkDistance: number; // px + nodeSize: number; // visual radius-ish used for collision +} + +type InMsg = + | { type: 'init'; nodes: WorkerNode[]; links: WorkerLink[]; settings: WorkerSettings } + | { type: 'updateSettings'; settings: Partial } + | { type: 'pinNode'; id: string; x: number; y: number } + | { type: 'unpinNode'; id: string } + | { type: 'reheat' } + | { type: 'stop' }; + +type OutMsg = + | { type: 'positions'; nodes: Array<{ id: string; x: number; y: number }>; alpha: number } + | { type: 'end'; reason: 'converged' | 'stopped' | 'timeout' }; + +let sim: d3.Simulation | null = null; +let simNodes: WorkerNode[] = []; +let simLinks: WorkerLink[] = []; +let cfg: WorkerSettings | null = null; +let lastPost = 0; +let timeoutHandle: any = null; + +function setupSimulation(settings: WorkerSettings) { + cfg = settings; + + if (sim) sim.stop(); + + sim = d3 + .forceSimulation(simNodes) + .alpha(1) + .alphaDecay(0.02) + .velocityDecay(0.3) + .force('centerX', d3.forceX(settings.width / 2).strength(settings.centerStrength)) + .force('centerY', d3.forceY(settings.height / 2).strength(settings.centerStrength)) + .force('charge', d3.forceManyBody().strength(-50 * settings.repelStrength)) + .force( + 'link', + d3 + .forceLink(simLinks) + .id((d) => d.id) + .strength(settings.linkStrength) + .distance(settings.linkDistance) + ) + .force('collision', d3.forceCollide().radius(settings.nodeSize * 2)) + .on('tick', onTick) + .on('end', () => postMessage({ type: 'end', reason: 'converged' } as OutMsg)); + + // Safety timeout: stop physics after 10s with fallback positions if needed + if (timeoutHandle) clearTimeout(timeoutHandle); + timeoutHandle = setTimeout(() => { + if (!sim) return; + // Fallback: place nodes on a circle + const r = Math.min(settings.width, settings.height) * 0.4; + const n = simNodes.length || 1; + simNodes.forEach((node, i) => { + const a = (i / n) * Math.PI * 2; + node.x = settings.width / 2 + Math.cos(a) * r; + node.y = settings.height / 2 + Math.sin(a) * r; + node.vx = 0; + node.vy = 0; + }); + postPositions(0); + sim.stop(); + postMessage({ type: 'end', reason: 'timeout' } as OutMsg); + }, 10_000); +} + +function onTick() { + // Throttle to ~30Hz + const now = performance.now(); + if (now - lastPost < 33) return; + lastPost = now; + if (!sim) return; + postPositions(sim.alpha()); +} + +function postPositions(alpha: number) { + if (!simNodes.length) return; + const payload = simNodes.map((n) => ({ id: n.id, x: n.x ?? 0, y: n.y ?? 0 })); + const msg: OutMsg = { type: 'positions', nodes: payload, alpha }; + postMessage(msg); +} + +function reheat() { + if (sim && sim.alpha() < 0.9) { + sim.alpha(1).restart(); + } +} + +function stopSim(reason: 'stopped' | 'timeout' = 'stopped') { + if (sim) sim.stop(); + if (timeoutHandle) clearTimeout(timeoutHandle); + postMessage({ type: 'end', reason } as OutMsg); +} + +self.onmessage = (ev: MessageEvent) => { + const data = ev.data; + switch (data.type) { + case 'init': { + simNodes = data.nodes.map((n) => ({ ...n })); + simLinks = data.links.map((l) => ({ ...l })); + setupSimulation(data.settings); + break; + } + case 'updateSettings': { + if (!cfg) return; + cfg = { ...cfg, ...data.settings } as WorkerSettings; + setupSimulation(cfg); + break; + } + case 'pinNode': { + const node = simNodes.find((n) => n.id === data.id); + if (node) { + node.fx = data.x; + node.fy = data.y; + reheat(); + } + break; + } + case 'unpinNode': { + const node = simNodes.find((n) => n.id === data.id); + if (node) { + node.fx = null; + node.fy = null; + reheat(); + } + break; + } + case 'reheat': + reheat(); + break; + case 'stop': + stopSim('stopped'); + break; + } +}; diff --git a/src/app/graph/graph.selectors.ts b/src/app/graph/graph.selectors.ts index 363e371..02f079a 100644 --- a/src/app/graph/graph.selectors.ts +++ b/src/app/graph/graph.selectors.ts @@ -24,19 +24,102 @@ function filterBySearch(nodes: GraphNode[], search: string): GraphNode[] { } /** - * Filter nodes by tags only + * Helpers to detect synthetic node kinds we generate here */ -function filterByTags(nodes: GraphNode[], enabled: boolean): GraphNode[] { - if (!enabled) return nodes; - return nodes.filter(node => node.tags.length > 0); +function isTagNode(node: GraphNode): boolean { + return node.id.startsWith('tag:'); +} + +function isAttachmentNode(node: GraphNode): boolean { + return node.id.startsWith('att:'); } /** - * Filter nodes by attachments + * Augment base graph with tag nodes and links (note -> tag) */ -function filterByAttachments(nodes: GraphNode[], enabled: boolean): GraphNode[] { - if (!enabled) return nodes; - return nodes.filter(node => node.hasAttachment); +function addTagNodesAndLinks(data: { nodes: GraphNode[]; links: GraphLink[] }): { nodes: GraphNode[]; links: GraphLink[] } { + const nodes = [...data.nodes]; + const links = [...data.links]; + + const seenTags = new Set(); + for (const n of data.nodes) { + for (const rawTag of n.tags || []) { + const tag = rawTag.startsWith('#') ? rawTag : `#${rawTag}`; + const tagId = `tag:${tag.toLowerCase()}`; + + if (!seenTags.has(tagId)) { + seenTags.add(tagId); + nodes.push({ + id: tagId, + title: tag, + path: '', + tags: [], + hasAttachment: false, + exists: true + }); + } + + links.push({ source: n.id, target: tagId, directed: false }); + } + } + + return { nodes, links }; +} + +/** + * Extract attachment references from note titles/paths in wikilinks and add nodes (note -> attachment) + * We look for wikilinks containing a filename with a known attachment extension. + */ +function addAttachmentNodesAndLinks( + data: { nodes: GraphNode[]; links: GraphLink[] }, + allNotesContent?: Map +): { nodes: GraphNode[]; links: GraphLink[] } { + // We rely on note titles/content being available through allNotesContent when provided by the caller. + // If not provided, we still attempt a best-effort by parsing node.path/title (no-ops in most cases). + const nodes = [...data.nodes]; + const links = [...data.links]; + + const attachmentExt = /\.(pdf|png|jpe?g|gif|svg|webp|bmp|mp4|webm|ogv|mov|mkv|mp3|wav|ogg|m4a|flac|docx?|xlsx?|pptx?)$/i; + const attId = (name: string) => `att:${name.toLowerCase()}`; + const haveNode = new Set(nodes.map(n => n.id)); + + const addAttachment = (note: GraphNode, name: string) => { + if (!attachmentExt.test(name)) return; + const id = attId(name); + if (!haveNode.has(id)) { + haveNode.add(id); + nodes.push({ + id, + title: name, + path: '', + tags: [], + hasAttachment: true, + exists: true + }); + } + links.push({ source: note.id, target: id, directed: false }); + }; + + // Parse per note + for (const note of data.nodes) { + // Only real notes (exclude synthetic ones) + if (isTagNode(note) || isAttachmentNode(note)) continue; + + const content = allNotesContent?.get(note.id) || ''; + if (!content) continue; + + // Match Obsidian wikilinks with optional embed prefix: ![[file.ext]] or [[file.ext]] + const wikilink = /!?\[\[([^\]|\n]+)(?:\|[^\]]+)?\]\]/g; + let m: RegExpExecArray | null; + while ((m = wikilink.exec(content)) !== null) { + const target = m[1].trim(); + if (attachmentExt.test(target)) { + addAttachment(note, target); + } + } + } + + return { nodes, links }; } /** @@ -150,29 +233,50 @@ function pruneBrokenLinks(nodes: GraphNode[], links: GraphLink[]): GraphLink[] { */ export function createFilteredGraphData( rawData: Signal, - config: Signal + config: Signal, + noteContents?: Signal> ): Signal<{ nodes: GraphNodeWithVisuals[], links: GraphLink[] }> { return computed(() => { const data = rawData(); const cfg = config(); + const contents = noteContents ? noteContents() : undefined; - // Apply filters in sequence - let filteredNodes = data.nodes; + // Start from base data + let workingNodes: GraphNode[] = data.nodes; + let workingLinks: GraphLink[] = data.links; + + // If Tags are enabled, augment the dataset with tag nodes and links + if (cfg.showTags) { + const augmented = addTagNodesAndLinks({ nodes: workingNodes, links: workingLinks }); + workingNodes = augmented.nodes; + workingLinks = augmented.links; + } + + // If Attachments are enabled, attempt to add attachment nodes/links by parsing note contents + if (cfg.showAttachments) { + // We do not have direct access to note contents here; however, base graph edges are built elsewhere from contents. + // To keep selectors pure, we skip when content is unavailable. The container can provide contents later if needed. + const augmented = addAttachmentNodesAndLinks({ nodes: workingNodes, links: workingLinks }, contents); + workingNodes = augmented.nodes; + workingLinks = augmented.links; + } + + // 1. Text search on titles/paths/tags (applies to all nodes but tag/attachment nodes usually unaffected) + let filteredNodes = filterBySearch(workingNodes, cfg.search); - // 1. Text search - filteredNodes = filterBySearch(filteredNodes, cfg.search); - - // 2. Tags only - filteredNodes = filterByTags(filteredNodes, cfg.showTags); - - // 3. Attachments only - filteredNodes = filterByAttachments(filteredNodes, cfg.showAttachments); + // 2-3. Show/Hide synthetic nodes according to toggles + if (!cfg.showTags) { + filteredNodes = filteredNodes.filter(n => !isTagNode(n)); + } + if (!cfg.showAttachments) { + filteredNodes = filteredNodes.filter(n => !isAttachmentNode(n)); + } // 4. Existing files only filteredNodes = filterByExistence(filteredNodes, cfg.hideUnresolved); - // Prune broken links - let filteredLinks = pruneBrokenLinks(filteredNodes, data.links); + // Prune broken links from current working set + let filteredLinks = pruneBrokenLinks(filteredNodes, workingLinks); // 5. Orphans filter const afterOrphans = filterByOrphans(filteredNodes, filteredLinks, cfg.showOrphans); diff --git a/src/components/graph-view-container-v2/graph-view-container-v2.component.ts b/src/components/graph-view-container-v2/graph-view-container-v2.component.ts index db70334..7aab401 100644 --- a/src/components/graph-view-container-v2/graph-view-container-v2.component.ts +++ b/src/components/graph-view-container-v2/graph-view-container-v2.component.ts @@ -141,10 +141,20 @@ export class GraphViewContainerV2Component { return { nodes, links }; }); + // Map note id -> raw content (for attachment extraction) + private noteContents = computed(() => { + const map = new Map(); + for (const n of this.vaultService.allNotes()) { + map.set(n.id, n.content || ''); + } + return map; + }); + // Apply all filters using selectors private filteredData = createFilteredGraphData( this.enhancedGraphData, - this.store.settings + this.store.settings, + this.noteContents ); // Create legend @@ -203,6 +213,26 @@ export class GraphViewContainerV2Component { } }, 150); }); + + // New: Sync in the other direction service -> store so UI panel updates reflect in graph immediately + let applyTimer: ReturnType | null = null; + effect(() => { + const svcCfg = settingsService.config(); + if (applyTimer) clearTimeout(applyTimer); + applyTimer = setTimeout(() => { + const cur = store.settings(); + const a = JSON.stringify(cur); + const b = JSON.stringify(svcCfg); + if (a !== b) { + store.update(svcCfg); + // Kick a redraw/animation as settings changed (e.g., toggles adding nodes) + const canvas = this.canvasCmp(); + if (canvas) { + canvas.animate(); + } + } + }, 100); + }); } /** diff --git a/src/services/vault.service.ts b/src/services/vault.service.ts index 9509a0e..4188934 100644 --- a/src/services/vault.service.ts +++ b/src/services/vault.service.ts @@ -94,22 +94,50 @@ export class VaultService implements OnDestroy { }); graphData = computed(() => { + const startTime = performance.now(); const notes = this.allNotes(); const nodes = notes.map(note => ({ id: note.id, label: note.title })); const edges: { source: string, target: string }[] = []; + // Build fast lookup maps to avoid O(N²) complexity + const noteById = new Map(); + const noteByTitle = new Map(); + const notesByAlias = new Map(); + + for (const note of notes) { + noteById.set(note.id, note); + noteByTitle.set(note.title, note); + + // Index aliases + if (Array.isArray(note.frontmatter?.aliases)) { + for (const alias of note.frontmatter.aliases as string[]) { + notesByAlias.set(alias, note); + } + } + } + + // Extract links with O(N×M) instead of O(N×M×N) for (const note of notes) { const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g; let match; while ((match = linkRegex.exec(note.content)) !== null) { - const linkPath = match[1].toLowerCase().replace(/\s+/g, '-'); - const targetNote = notes.find(n => n.id === linkPath || n.title === match[1] || (n.frontmatter?.aliases as string[])?.includes(match[1])); + const rawLink = match[1]; + const linkPath = rawLink.toLowerCase().replace(/\s+/g, '-'); + + // Fast lookup instead of .find() + const targetNote = noteById.get(linkPath) + || noteByTitle.get(rawLink) + || notesByAlias.get(rawLink); + if (targetNote && targetNote.id !== note.id) { edges.push({ source: note.id, target: targetNote.id }); } } } + const duration = performance.now() - startTime; + console.log(`[GraphData] Computed in ${duration.toFixed(2)}ms: ${nodes.length} nodes, ${edges.length} edges`); + return { nodes, edges }; }); @@ -261,6 +289,27 @@ export class VaultService implements OnDestroy { (frontmatter.tags as string[]).forEach(tag => tagSet.add(tag)); } + // Extract inline hashtags from body (e.g., "#tag", "#tag/sub") + // Avoid matching inside code blocks by a simple heuristic (skip lines fenced by ```) + try { + const inlineTags = new Set(); + const lines = (body || '').split('\n'); + let inFence = false; + for (const line of lines) { + const trimmed = line.trim(); + if (/^```/.test(trimmed)) { inFence = !inFence; continue; } + if (inFence) continue; + const re = /(^|\s)#([A-Za-z0-9_\-\/]+)\b/g; + let m: RegExpExecArray | null; + while ((m = re.exec(line)) !== null) { + inlineTags.add(m[2]); + } + } + inlineTags.forEach(t => tagSet.add(t)); + } catch { + // noop + } + const fallbackUpdatedAt = new Date((frontmatter.mtime ?? apiNote.mtime) || Date.now()).toISOString(); const note: Note = { id: apiNote.id, diff --git a/vault/.obsidian/graph.json b/vault/.obsidian/graph.json index 01be3a3..8a33809 100644 --- a/vault/.obsidian/graph.json +++ b/vault/.obsidian/graph.json @@ -1,30 +1,22 @@ { - "collapse-filter": true, + "collapse-filter": false, "search": "", "showTags": false, "showAttachments": false, "hideUnresolved": false, "showOrphans": false, - "collapse-color-groups": true, - "colorGroups": [ - { - "query": "tag:test", - "color": { - "a": 1, - "rgb": 11657324 - } - } - ], - "collapse-display": true, + "collapse-color-groups": false, + "colorGroups": [], + "collapse-display": false, "showArrow": false, - "textFadeMultiplier": 0, - "nodeSizeMultiplier": 1, - "lineSizeMultiplier": 1, - "collapse-forces": true, - "centerStrength": 0.5, - "repelStrength": 10, - "linkStrength": 1, - "linkDistance": 250, - "scale": 1, + "textFadeMultiplier": -3, + "nodeSizeMultiplier": 0.25, + "lineSizeMultiplier": 1.45, + "collapse-forces": false, + "centerStrength": 0.58, + "repelStrength": 4.5, + "linkStrength": 0.41, + "linkDistance": 20, + "scale": 1.4019828977761002, "close": false } \ No newline at end of file diff --git a/vault/.obsidian/graph.json.bak b/vault/.obsidian/graph.json.bak index 81dd15a..8a33809 100644 --- a/vault/.obsidian/graph.json.bak +++ b/vault/.obsidian/graph.json.bak @@ -3,20 +3,20 @@ "search": "", "showTags": false, "showAttachments": false, - "hideUnresolved": true, - "showOrphans": true, + "hideUnresolved": false, + "showOrphans": false, "collapse-color-groups": false, "colorGroups": [], "collapse-display": false, "showArrow": false, - "textFadeMultiplier": 0, - "nodeSizeMultiplier": 1, - "lineSizeMultiplier": 1, + "textFadeMultiplier": -3, + "nodeSizeMultiplier": 0.25, + "lineSizeMultiplier": 1.45, "collapse-forces": false, - "centerStrength": 0.5, - "repelStrength": 10, - "linkStrength": 1, - "linkDistance": 250, - "scale": 1, + "centerStrength": 0.58, + "repelStrength": 4.5, + "linkStrength": 0.41, + "linkDistance": 20, + "scale": 1.4019828977761002, "close": false } \ No newline at end of file diff --git a/vault/.obsidian/workspace.json b/vault/.obsidian/workspace.json index d2b30a5..cfe1e46 100644 --- a/vault/.obsidian/workspace.json +++ b/vault/.obsidian/workspace.json @@ -11,14 +11,10 @@ "id": "17cca9c5f5a7401d", "type": "leaf", "state": { - "type": "markdown", - "state": { - "file": "HOME.md", - "mode": "source", - "source": false - }, - "icon": "lucide-file", - "title": "HOME" + "type": "graph", + "state": {}, + "icon": "lucide-git-fork", + "title": "Graph view" } } ] @@ -78,7 +74,7 @@ } ], "direction": "horizontal", - "width": 449.5 + "width": 205.5 }, "right": { "id": "3932036feebc690d", @@ -94,7 +90,6 @@ "state": { "type": "backlink", "state": { - "file": "HOME.md", "collapseAll": false, "extraContext": false, "sortOrder": "alphabetical", @@ -104,7 +99,7 @@ "unlinkedCollapsed": true }, "icon": "links-coming-in", - "title": "Backlinks for HOME" + "title": "Backlinks" } }, { @@ -113,12 +108,11 @@ "state": { "type": "outgoing-link", "state": { - "file": "HOME.md", "linksCollapsed": false, "unlinkedCollapsed": true }, "icon": "links-going-out", - "title": "Outgoing links from HOME" + "title": "Outgoing links" } }, { @@ -142,13 +136,12 @@ "state": { "type": "outline", "state": { - "file": "HOME.md", "followCursor": false, "showSearch": false, "searchQuery": "" }, "icon": "lucide-list", - "title": "Outline of HOME" + "title": "Outline" } }, { @@ -156,19 +149,17 @@ "type": "leaf", "state": { "type": "footnotes", - "state": { - "file": "HOME.md" - }, + "state": {}, "icon": "lucide-file-signature", "title": "Footnotes" } } - ] + ], + "currentTab": 2 } ], "direction": "horizontal", - "width": 300, - "collapsed": true + "width": 234.5 }, "left-ribbon": { "hiddenItems": { @@ -184,6 +175,7 @@ "active": "17cca9c5f5a7401d", "lastOpenFiles": [ "test.md", + "HOME.md", "folder1/test2.md", "folder2/test2.md", "folder2", @@ -194,7 +186,6 @@ "Fichier_not_found.png.md", "welcome.md", "tata/briana/test-todo.md", - "HOME.md", "titi/tata-coco.md", "tata/briana/test-table.md", "tata/briana/test-note-1.md",