refactor: migrate graph canvas to use web worker for force layout computation
This commit is contained in:
parent
a948d10512
commit
e75fcd60cd
166
docs/GRAPH_FREEZE_FIX.md
Normal file
166
docs/GRAPH_FREEZE_FIX.md
Normal 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
|
@ -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
|
||||
});
|
||||
});
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
||||
// 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<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();
|
||||
}
|
||||
}
|
||||
|
129
src/app/graph/graph-layout.service.ts
Normal file
129
src/app/graph/graph-layout.service.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
170
src/app/graph/graph-layout.worker.ts
Normal file
170
src/app/graph/graph-layout.worker.ts
Normal 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;
|
||||
}
|
||||
};
|
@ -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;
|
||||
|
||||
// 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);
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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,
|
||||
|
34
vault/.obsidian/graph.json
vendored
34
vault/.obsidian/graph.json
vendored
@ -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
|
||||
}
|
20
vault/.obsidian/graph.json.bak
vendored
20
vault/.obsidian/graph.json.bak
vendored
@ -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
|
||||
}
|
35
vault/.obsidian/workspace.json
vendored
35
vault/.obsidian/workspace.json
vendored
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user