refactor: migrate graph canvas to use web worker for force layout computation

This commit is contained in:
Bruno Charest 2025-10-04 08:56:49 -04:00
parent a948d10512
commit e75fcd60cd
12 changed files with 869 additions and 186 deletions

166
docs/GRAPH_FREEZE_FIX.md Normal file
View File

@ -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<string, Note>();
const noteByTitle = new Map<string, Note>();
const notesByAlias = new Map<string, Note>();
// 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<typeof setTimeout> | 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

View File

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

View File

@ -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"

View File

@ -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 {
</div>
</div>
`,
host: {
'[attr.aria-busy]': 'ariaBusy() ? "true" : "false"'
},
styles: [`
:host {
display: block;
@ -95,10 +101,11 @@ export class GraphCanvasComponent implements OnDestroy {
tooltip = signal<TooltipData | null>(null);
private transform = signal({ x: 0, y: 0, k: 1 });
private simulationNodes = signal<SimulationNode[]>([]);
private simulationLinks = signal<SimulationLink[]>([]);
private simulationLinks = signal<GraphLink[]>([]);
// Simulation
private simulation: d3.Simulation<SimulationNode, SimulationLink> | 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<boolean>(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<typeof setTimeout> | 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<SimulationNode, SimulationLink>()
.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;
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;
}
// Stop current simulation
this.simulation.stop();
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 simulation nodes
// 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);
// 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 });
}
// Apply forces
this.updateForces(this.settings());
// 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<SimulationNode>(this.width / 2).strength(settings.centerStrength));
this.simulation.force('centerY', d3.forceY<SimulationNode>(this.height / 2).strength(settings.centerStrength));
// Repel force (charge)
this.simulation.force('charge',
d3.forceManyBody<SimulationNode>()
.strength(-50 * settings.repelStrength)
);
// Link force
this.simulation.force('link',
d3.forceLink<SimulationNode, SimulationLink>(links)
.id(d => d.id)
.strength(settings.linkStrength)
.distance(settings.linkDistance)
);
// Collision force
this.simulation.force('collision',
d3.forceCollide<SimulationNode>()
.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();
}
}

View File

@ -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<WorkerSettings> }
| { 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<WorkerPositionsMessage>;
end$: Observable<WorkerEndMessage>;
init(nodes: GraphNodeWithVisuals[], links: GraphLink[], settings: GraphConfig, size: { width: number; height: number }): void;
updateSettings(settings: Partial<GraphConfig> & { 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<WorkerPositionsMessage>();
const endSubj = new Subject<WorkerEndMessage>();
// Route messages outside Angular, re-enter only when emitting
this.zone.runOutsideAngular(() => {
worker.onmessage = (ev: MessageEvent<WorkerOutMsg>) => {
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<WorkerSettings> = {};
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();
}
},
};
}
}

View File

@ -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
*/
/// <reference lib="webworker" />
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<WorkerSettings> }
| { 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<WorkerNode, WorkerLink> | 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<WorkerNode, WorkerLink>(simNodes)
.alpha(1)
.alphaDecay(0.02)
.velocityDecay(0.3)
.force('centerX', d3.forceX<WorkerNode>(settings.width / 2).strength(settings.centerStrength))
.force('centerY', d3.forceY<WorkerNode>(settings.height / 2).strength(settings.centerStrength))
.force('charge', d3.forceManyBody<WorkerNode>().strength(-50 * settings.repelStrength))
.force(
'link',
d3
.forceLink<WorkerNode, WorkerLink>(simLinks)
.id((d) => d.id)
.strength(settings.linkStrength)
.distance(settings.linkDistance)
)
.force('collision', d3.forceCollide<WorkerNode>().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<InMsg>) => {
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;
}
};

View File

@ -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<string>();
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<string, string>
): { 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<GraphData>,
config: Signal<GraphConfig>
config: Signal<GraphConfig>,
noteContents?: Signal<Map<string, string>>
): 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;
// 1. Text search
filteredNodes = filterBySearch(filteredNodes, cfg.search);
// 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;
}
// 2. Tags only
filteredNodes = filterByTags(filteredNodes, cfg.showTags);
// 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;
}
// 3. Attachments only
filteredNodes = filterByAttachments(filteredNodes, cfg.showAttachments);
// 1. Text search on titles/paths/tags (applies to all nodes but tag/attachment nodes usually unaffected)
let filteredNodes = filterBySearch(workingNodes, cfg.search);
// 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);

View File

@ -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<string, string>();
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<typeof setTimeout> | 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);
});
}
/**

View File

@ -94,22 +94,50 @@ export class VaultService implements OnDestroy {
});
graphData = computed<GraphData>(() => {
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<string, Note>();
const noteByTitle = new Map<string, Note>();
const notesByAlias = new Map<string, Note>();
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<string>();
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,

View File

@ -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
}

View File

@ -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
}

View File

@ -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",