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();
|
const count = await legendItems.count();
|
||||||
expect(count).toBeGreaterThan(0);
|
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": {
|
"scripts": {
|
||||||
"dev": "ng serve",
|
"dev": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"build:workers": "ng build",
|
||||||
"preview": "ng serve --configuration=production --port 3000 --host 127.0.0.1",
|
"preview": "ng serve --configuration=production --port 3000 --host 127.0.0.1",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
|
@ -14,13 +14,16 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
afterNextRender,
|
afterNextRender,
|
||||||
OnDestroy
|
OnDestroy,
|
||||||
|
inject
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import * as d3 from 'd3-force';
|
import { GraphNodeWithVisuals, GraphLink, SimulationNode } from './graph-data.types';
|
||||||
import { GraphNodeWithVisuals, GraphLink, SimulationNode, SimulationLink } from './graph-data.types';
|
|
||||||
import { GraphConfig } from './graph-settings.types';
|
import { GraphConfig } from './graph-settings.types';
|
||||||
import { SpatialIndex, drawArrow } from './graph.utils';
|
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 {
|
interface TooltipData {
|
||||||
node: GraphNodeWithVisuals;
|
node: GraphNodeWithVisuals;
|
||||||
@ -69,6 +72,9 @@ interface TooltipData {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
host: {
|
||||||
|
'[attr.aria-busy]': 'ariaBusy() ? "true" : "false"'
|
||||||
|
},
|
||||||
styles: [`
|
styles: [`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@ -95,10 +101,11 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
tooltip = signal<TooltipData | null>(null);
|
tooltip = signal<TooltipData | null>(null);
|
||||||
private transform = signal({ x: 0, y: 0, k: 1 });
|
private transform = signal({ x: 0, y: 0, k: 1 });
|
||||||
private simulationNodes = signal<SimulationNode[]>([]);
|
private simulationNodes = signal<SimulationNode[]>([]);
|
||||||
private simulationLinks = signal<SimulationLink[]>([]);
|
private simulationLinks = signal<GraphLink[]>([]);
|
||||||
|
|
||||||
// Simulation
|
// Simulation
|
||||||
private simulation: d3.Simulation<SimulationNode, SimulationLink> | null = null;
|
private layout = inject(GraphLayoutService);
|
||||||
|
private session: GraphLayoutSession | null = null;
|
||||||
private animationFrameId: number | null = null;
|
private animationFrameId: number | null = null;
|
||||||
private isAnimating = false;
|
private isAnimating = false;
|
||||||
|
|
||||||
@ -115,6 +122,13 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
private width = 800;
|
private width = 800;
|
||||||
private height = 600;
|
private height = 600;
|
||||||
private ctx: CanvasRenderingContext2D | null = null;
|
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
|
// Neighbors cache for highlighting
|
||||||
private neighborMap = computed(() => {
|
private neighborMap = computed(() => {
|
||||||
@ -131,20 +145,26 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
constructor() {
|
constructor() {
|
||||||
afterNextRender(() => {
|
afterNextRender(() => {
|
||||||
this.initCanvas();
|
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(() => {
|
effect(() => {
|
||||||
const nodes = this.nodes();
|
const nodes = this.nodes();
|
||||||
const links = this.links();
|
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
|
// React to settings changes
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const settings = this.settings();
|
const settings = this.settings();
|
||||||
this.updateForces(settings);
|
this.updateWorkerSettings(settings);
|
||||||
this.scheduleRedraw();
|
this.scheduleRedraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -158,12 +178,17 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
if (this.simulation) {
|
if (this.session) {
|
||||||
this.simulation.stop();
|
this.session.terminate();
|
||||||
|
this.session = null;
|
||||||
}
|
}
|
||||||
if (this.animationFrameId) {
|
if (this.animationFrameId) {
|
||||||
cancelAnimationFrame(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();
|
this.resizeCanvas();
|
||||||
|
|
||||||
// Handle window resize
|
// 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
|
* Initialize d3-force simulation
|
||||||
*/
|
*/
|
||||||
private initSimulation(): void {
|
private initWorker(): void {
|
||||||
this.simulation = d3.forceSimulation<SimulationNode, SimulationLink>()
|
// Create a new layout session
|
||||||
.alphaDecay(0.02)
|
this.session = this.layout.createSession();
|
||||||
.velocityDecay(0.3)
|
|
||||||
.on('tick', () => {
|
// Subscribe to worker position updates
|
||||||
this.onSimulationTick();
|
let lastIndexBuild = 0;
|
||||||
})
|
this.session.positions$
|
||||||
.on('end', () => {
|
.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.isAnimating = false;
|
||||||
|
this.ariaBusy.set(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update simulation with new data
|
* Update simulation with new data
|
||||||
*/
|
*/
|
||||||
private updateSimulation(nodes: GraphNodeWithVisuals[], links: GraphLink[]): void {
|
private updateWorkerData(nodes: GraphNodeWithVisuals[], links: GraphLink[]): void {
|
||||||
if (!this.simulation) return;
|
// Skip update if data hasn't substantially changed (same counts)
|
||||||
|
if (nodes.length === this.lastNodeCount && links.length === this.lastLinkCount && this.session) {
|
||||||
// Stop current simulation
|
console.log(`[GraphCanvas] Skipping update - same data size (${nodes.length} nodes, ${links.length} links)`);
|
||||||
this.simulation.stop();
|
return;
|
||||||
|
}
|
||||||
// Create simulation nodes
|
|
||||||
|
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 => ({
|
const simNodes: SimulationNode[] = nodes.map(node => ({
|
||||||
...node,
|
...node,
|
||||||
x: this.width / 2 + (Math.random() - 0.5) * 100,
|
x: this.width / 2 + (Math.random() - 0.5) * 100,
|
||||||
y: this.height / 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.simulationNodes.set(simNodes);
|
||||||
this.simulationLinks.set(simLinks);
|
this.simulationLinks.set(links);
|
||||||
|
|
||||||
// Update spatial index
|
|
||||||
this.spatialIndex = new SpatialIndex(simNodes);
|
this.spatialIndex = new SpatialIndex(simNodes);
|
||||||
|
|
||||||
// Update simulation
|
// Initialize or re-init the worker with new data
|
||||||
this.simulation.nodes(simNodes);
|
if (!this.session) {
|
||||||
|
this.initWorker();
|
||||||
// Apply forces
|
}
|
||||||
this.updateForces(this.settings());
|
if (this.session) {
|
||||||
|
this.session.init(simNodes, links, this.settings(), { width: this.width, height: this.height });
|
||||||
|
}
|
||||||
|
|
||||||
// Restart simulation
|
// Kick an animation frame to draw initial state
|
||||||
this.simulation.alpha(1).restart();
|
|
||||||
this.isAnimating = true;
|
this.isAnimating = true;
|
||||||
|
this.ariaBusy.set(true);
|
||||||
this.startAnimationLoop();
|
this.startAnimationLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update force parameters
|
* Update force parameters
|
||||||
*/
|
*/
|
||||||
private updateForces(settings: GraphConfig): void {
|
private updateWorkerSettings(settings: GraphConfig): void {
|
||||||
if (!this.simulation) return;
|
if (!this.session) return;
|
||||||
|
this.session.updateSettings({
|
||||||
const nodes = this.simulationNodes();
|
centerStrength: settings.centerStrength,
|
||||||
const links = this.simulationLinks();
|
repelStrength: settings.repelStrength,
|
||||||
|
linkStrength: settings.linkStrength,
|
||||||
// Centering forces (use forceX/forceY to control strength)
|
linkDistance: settings.linkDistance,
|
||||||
this.simulation.force('centerX', d3.forceX<SimulationNode>(this.width / 2).strength(settings.centerStrength));
|
nodeSizeMultiplier: settings.nodeSizeMultiplier as any,
|
||||||
this.simulation.force('centerY', d3.forceY<SimulationNode>(this.height / 2).strength(settings.centerStrength));
|
} as any);
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulation tick handler
|
* Simulation tick handler
|
||||||
*/
|
*/
|
||||||
private onSimulationTick(): void {
|
private onSimulationTick(): void {
|
||||||
|
// retained for compatibility; worker drives updates
|
||||||
this.scheduleRedraw();
|
this.scheduleRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,7 +345,8 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
if (this.animationFrameId) return;
|
if (this.animationFrameId) return;
|
||||||
|
|
||||||
const animate = () => {
|
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.draw();
|
||||||
this.animationFrameId = requestAnimationFrame(animate);
|
this.animationFrameId = requestAnimationFrame(animate);
|
||||||
} else {
|
} else {
|
||||||
@ -378,18 +405,14 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Draw links
|
* 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 nodeSize = settings.nodeSizeMultiplier * 5;
|
||||||
const linkThickness = settings.lineSizeMultiplier * 1;
|
const linkThickness = settings.lineSizeMultiplier * 1;
|
||||||
|
|
||||||
links.forEach(link => {
|
links.forEach(link => {
|
||||||
const source = typeof link.source === 'string' ? null : link.source;
|
const source = this.simulationNodes().find(n => n.id === link.source);
|
||||||
const target = typeof link.target === 'string' ? null : link.target;
|
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;
|
||||||
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
|
// Dim link if node is selected and this link is not connected
|
||||||
let opacity = 0.6;
|
let opacity = 0.6;
|
||||||
@ -510,9 +533,9 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
if (clickedNode) {
|
if (clickedNode) {
|
||||||
this.draggedNode = clickedNode;
|
this.draggedNode = clickedNode;
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
if (this.simulation) {
|
if (clickedNode.x !== undefined && clickedNode.y !== undefined) {
|
||||||
clickedNode.fx = clickedNode.x;
|
this.session?.pinNode(clickedNode.id, clickedNode.x, clickedNode.y);
|
||||||
clickedNode.fy = clickedNode.y;
|
this.session?.reheat();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.isPanning = true;
|
this.isPanning = true;
|
||||||
@ -530,15 +553,9 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
const t = this.transform();
|
const t = this.transform();
|
||||||
const graphX = (x - t.x) / t.k;
|
const graphX = (x - t.x) / t.k;
|
||||||
const graphY = (y - t.y) / t.k;
|
const graphY = (y - t.y) / t.k;
|
||||||
|
this.session?.pinNode(this.draggedNode.id, graphX, graphY);
|
||||||
this.draggedNode.fx = graphX;
|
this.isAnimating = true;
|
||||||
this.draggedNode.fy = graphY;
|
this.startAnimationLoop();
|
||||||
|
|
||||||
if (this.simulation) {
|
|
||||||
this.simulation.alpha(0.3).restart();
|
|
||||||
this.isAnimating = true;
|
|
||||||
this.startAnimationLoop();
|
|
||||||
}
|
|
||||||
} else if (this.isPanning) {
|
} else if (this.isPanning) {
|
||||||
// Pan canvas
|
// Pan canvas
|
||||||
const dx = event.clientX - this.dragStart.x;
|
const dx = event.clientX - this.dragStart.x;
|
||||||
@ -583,8 +600,7 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
|
|
||||||
onMouseUp(event: MouseEvent): void {
|
onMouseUp(event: MouseEvent): void {
|
||||||
if (this.isDragging && this.draggedNode) {
|
if (this.isDragging && this.draggedNode) {
|
||||||
this.draggedNode.fx = null;
|
this.session?.unpinNode(this.draggedNode.id);
|
||||||
this.draggedNode.fy = null;
|
|
||||||
this.draggedNode = null;
|
this.draggedNode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -671,10 +687,8 @@ export class GraphCanvasComponent implements OnDestroy {
|
|||||||
* Public API: Restart animation
|
* Public API: Restart animation
|
||||||
*/
|
*/
|
||||||
animate(): void {
|
animate(): void {
|
||||||
if (this.simulation) {
|
this.session?.reheat();
|
||||||
this.simulation.alpha(1).restart();
|
this.isAnimating = true;
|
||||||
this.isAnimating = true;
|
this.startAnimationLoop();
|
||||||
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[] {
|
function isTagNode(node: GraphNode): boolean {
|
||||||
if (!enabled) return nodes;
|
return node.id.startsWith('tag:');
|
||||||
return nodes.filter(node => node.tags.length > 0);
|
}
|
||||||
|
|
||||||
|
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[] {
|
function addTagNodesAndLinks(data: { nodes: GraphNode[]; links: GraphLink[] }): { nodes: GraphNode[]; links: GraphLink[] } {
|
||||||
if (!enabled) return nodes;
|
const nodes = [...data.nodes];
|
||||||
return nodes.filter(node => node.hasAttachment);
|
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(
|
export function createFilteredGraphData(
|
||||||
rawData: Signal<GraphData>,
|
rawData: Signal<GraphData>,
|
||||||
config: Signal<GraphConfig>
|
config: Signal<GraphConfig>,
|
||||||
|
noteContents?: Signal<Map<string, string>>
|
||||||
): Signal<{ nodes: GraphNodeWithVisuals[], links: GraphLink[] }> {
|
): Signal<{ nodes: GraphNodeWithVisuals[], links: GraphLink[] }> {
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
const data = rawData();
|
const data = rawData();
|
||||||
const cfg = config();
|
const cfg = config();
|
||||||
|
const contents = noteContents ? noteContents() : undefined;
|
||||||
|
|
||||||
// Apply filters in sequence
|
// Start from base data
|
||||||
let filteredNodes = data.nodes;
|
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
|
// 2-3. Show/Hide synthetic nodes according to toggles
|
||||||
filteredNodes = filterBySearch(filteredNodes, cfg.search);
|
if (!cfg.showTags) {
|
||||||
|
filteredNodes = filteredNodes.filter(n => !isTagNode(n));
|
||||||
// 2. Tags only
|
}
|
||||||
filteredNodes = filterByTags(filteredNodes, cfg.showTags);
|
if (!cfg.showAttachments) {
|
||||||
|
filteredNodes = filteredNodes.filter(n => !isAttachmentNode(n));
|
||||||
// 3. Attachments only
|
}
|
||||||
filteredNodes = filterByAttachments(filteredNodes, cfg.showAttachments);
|
|
||||||
|
|
||||||
// 4. Existing files only
|
// 4. Existing files only
|
||||||
filteredNodes = filterByExistence(filteredNodes, cfg.hideUnresolved);
|
filteredNodes = filterByExistence(filteredNodes, cfg.hideUnresolved);
|
||||||
|
|
||||||
// Prune broken links
|
// Prune broken links from current working set
|
||||||
let filteredLinks = pruneBrokenLinks(filteredNodes, data.links);
|
let filteredLinks = pruneBrokenLinks(filteredNodes, workingLinks);
|
||||||
|
|
||||||
// 5. Orphans filter
|
// 5. Orphans filter
|
||||||
const afterOrphans = filterByOrphans(filteredNodes, filteredLinks, cfg.showOrphans);
|
const afterOrphans = filterByOrphans(filteredNodes, filteredLinks, cfg.showOrphans);
|
||||||
|
@ -141,10 +141,20 @@ export class GraphViewContainerV2Component {
|
|||||||
return { nodes, links };
|
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
|
// Apply all filters using selectors
|
||||||
private filteredData = createFilteredGraphData(
|
private filteredData = createFilteredGraphData(
|
||||||
this.enhancedGraphData,
|
this.enhancedGraphData,
|
||||||
this.store.settings
|
this.store.settings,
|
||||||
|
this.noteContents
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create legend
|
// Create legend
|
||||||
@ -203,6 +213,26 @@ export class GraphViewContainerV2Component {
|
|||||||
}
|
}
|
||||||
}, 150);
|
}, 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>(() => {
|
graphData = computed<GraphData>(() => {
|
||||||
|
const startTime = performance.now();
|
||||||
const notes = this.allNotes();
|
const notes = this.allNotes();
|
||||||
const nodes = notes.map(note => ({ id: note.id, label: note.title }));
|
const nodes = notes.map(note => ({ id: note.id, label: note.title }));
|
||||||
const edges: { source: string, target: string }[] = [];
|
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) {
|
for (const note of notes) {
|
||||||
const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g;
|
const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g;
|
||||||
let match;
|
let match;
|
||||||
while ((match = linkRegex.exec(note.content)) !== null) {
|
while ((match = linkRegex.exec(note.content)) !== null) {
|
||||||
const linkPath = match[1].toLowerCase().replace(/\s+/g, '-');
|
const rawLink = match[1];
|
||||||
const targetNote = notes.find(n => n.id === linkPath || n.title === match[1] || (n.frontmatter?.aliases as string[])?.includes(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) {
|
if (targetNote && targetNote.id !== note.id) {
|
||||||
edges.push({ source: note.id, target: targetNote.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 };
|
return { nodes, edges };
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -261,6 +289,27 @@ export class VaultService implements OnDestroy {
|
|||||||
(frontmatter.tags as string[]).forEach(tag => tagSet.add(tag));
|
(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 fallbackUpdatedAt = new Date((frontmatter.mtime ?? apiNote.mtime) || Date.now()).toISOString();
|
||||||
const note: Note = {
|
const note: Note = {
|
||||||
id: apiNote.id,
|
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": "",
|
"search": "",
|
||||||
"showTags": false,
|
"showTags": false,
|
||||||
"showAttachments": false,
|
"showAttachments": false,
|
||||||
"hideUnresolved": false,
|
"hideUnresolved": false,
|
||||||
"showOrphans": false,
|
"showOrphans": false,
|
||||||
"collapse-color-groups": true,
|
"collapse-color-groups": false,
|
||||||
"colorGroups": [
|
"colorGroups": [],
|
||||||
{
|
"collapse-display": false,
|
||||||
"query": "tag:test",
|
|
||||||
"color": {
|
|
||||||
"a": 1,
|
|
||||||
"rgb": 11657324
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"collapse-display": true,
|
|
||||||
"showArrow": false,
|
"showArrow": false,
|
||||||
"textFadeMultiplier": 0,
|
"textFadeMultiplier": -3,
|
||||||
"nodeSizeMultiplier": 1,
|
"nodeSizeMultiplier": 0.25,
|
||||||
"lineSizeMultiplier": 1,
|
"lineSizeMultiplier": 1.45,
|
||||||
"collapse-forces": true,
|
"collapse-forces": false,
|
||||||
"centerStrength": 0.5,
|
"centerStrength": 0.58,
|
||||||
"repelStrength": 10,
|
"repelStrength": 4.5,
|
||||||
"linkStrength": 1,
|
"linkStrength": 0.41,
|
||||||
"linkDistance": 250,
|
"linkDistance": 20,
|
||||||
"scale": 1,
|
"scale": 1.4019828977761002,
|
||||||
"close": false
|
"close": false
|
||||||
}
|
}
|
20
vault/.obsidian/graph.json.bak
vendored
20
vault/.obsidian/graph.json.bak
vendored
@ -3,20 +3,20 @@
|
|||||||
"search": "",
|
"search": "",
|
||||||
"showTags": false,
|
"showTags": false,
|
||||||
"showAttachments": false,
|
"showAttachments": false,
|
||||||
"hideUnresolved": true,
|
"hideUnresolved": false,
|
||||||
"showOrphans": true,
|
"showOrphans": false,
|
||||||
"collapse-color-groups": false,
|
"collapse-color-groups": false,
|
||||||
"colorGroups": [],
|
"colorGroups": [],
|
||||||
"collapse-display": false,
|
"collapse-display": false,
|
||||||
"showArrow": false,
|
"showArrow": false,
|
||||||
"textFadeMultiplier": 0,
|
"textFadeMultiplier": -3,
|
||||||
"nodeSizeMultiplier": 1,
|
"nodeSizeMultiplier": 0.25,
|
||||||
"lineSizeMultiplier": 1,
|
"lineSizeMultiplier": 1.45,
|
||||||
"collapse-forces": false,
|
"collapse-forces": false,
|
||||||
"centerStrength": 0.5,
|
"centerStrength": 0.58,
|
||||||
"repelStrength": 10,
|
"repelStrength": 4.5,
|
||||||
"linkStrength": 1,
|
"linkStrength": 0.41,
|
||||||
"linkDistance": 250,
|
"linkDistance": 20,
|
||||||
"scale": 1,
|
"scale": 1.4019828977761002,
|
||||||
"close": false
|
"close": false
|
||||||
}
|
}
|
35
vault/.obsidian/workspace.json
vendored
35
vault/.obsidian/workspace.json
vendored
@ -11,14 +11,10 @@
|
|||||||
"id": "17cca9c5f5a7401d",
|
"id": "17cca9c5f5a7401d",
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"state": {
|
"state": {
|
||||||
"type": "markdown",
|
"type": "graph",
|
||||||
"state": {
|
"state": {},
|
||||||
"file": "HOME.md",
|
"icon": "lucide-git-fork",
|
||||||
"mode": "source",
|
"title": "Graph view"
|
||||||
"source": false
|
|
||||||
},
|
|
||||||
"icon": "lucide-file",
|
|
||||||
"title": "HOME"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -78,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"direction": "horizontal",
|
"direction": "horizontal",
|
||||||
"width": 449.5
|
"width": 205.5
|
||||||
},
|
},
|
||||||
"right": {
|
"right": {
|
||||||
"id": "3932036feebc690d",
|
"id": "3932036feebc690d",
|
||||||
@ -94,7 +90,6 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"type": "backlink",
|
"type": "backlink",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "HOME.md",
|
|
||||||
"collapseAll": false,
|
"collapseAll": false,
|
||||||
"extraContext": false,
|
"extraContext": false,
|
||||||
"sortOrder": "alphabetical",
|
"sortOrder": "alphabetical",
|
||||||
@ -104,7 +99,7 @@
|
|||||||
"unlinkedCollapsed": true
|
"unlinkedCollapsed": true
|
||||||
},
|
},
|
||||||
"icon": "links-coming-in",
|
"icon": "links-coming-in",
|
||||||
"title": "Backlinks for HOME"
|
"title": "Backlinks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -113,12 +108,11 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"type": "outgoing-link",
|
"type": "outgoing-link",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "HOME.md",
|
|
||||||
"linksCollapsed": false,
|
"linksCollapsed": false,
|
||||||
"unlinkedCollapsed": true
|
"unlinkedCollapsed": true
|
||||||
},
|
},
|
||||||
"icon": "links-going-out",
|
"icon": "links-going-out",
|
||||||
"title": "Outgoing links from HOME"
|
"title": "Outgoing links"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -142,13 +136,12 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"type": "outline",
|
"type": "outline",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "HOME.md",
|
|
||||||
"followCursor": false,
|
"followCursor": false,
|
||||||
"showSearch": false,
|
"showSearch": false,
|
||||||
"searchQuery": ""
|
"searchQuery": ""
|
||||||
},
|
},
|
||||||
"icon": "lucide-list",
|
"icon": "lucide-list",
|
||||||
"title": "Outline of HOME"
|
"title": "Outline"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -156,19 +149,17 @@
|
|||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"state": {
|
"state": {
|
||||||
"type": "footnotes",
|
"type": "footnotes",
|
||||||
"state": {
|
"state": {},
|
||||||
"file": "HOME.md"
|
|
||||||
},
|
|
||||||
"icon": "lucide-file-signature",
|
"icon": "lucide-file-signature",
|
||||||
"title": "Footnotes"
|
"title": "Footnotes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"currentTab": 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"direction": "horizontal",
|
"direction": "horizontal",
|
||||||
"width": 300,
|
"width": 234.5
|
||||||
"collapsed": true
|
|
||||||
},
|
},
|
||||||
"left-ribbon": {
|
"left-ribbon": {
|
||||||
"hiddenItems": {
|
"hiddenItems": {
|
||||||
@ -184,6 +175,7 @@
|
|||||||
"active": "17cca9c5f5a7401d",
|
"active": "17cca9c5f5a7401d",
|
||||||
"lastOpenFiles": [
|
"lastOpenFiles": [
|
||||||
"test.md",
|
"test.md",
|
||||||
|
"HOME.md",
|
||||||
"folder1/test2.md",
|
"folder1/test2.md",
|
||||||
"folder2/test2.md",
|
"folder2/test2.md",
|
||||||
"folder2",
|
"folder2",
|
||||||
@ -194,7 +186,6 @@
|
|||||||
"Fichier_not_found.png.md",
|
"Fichier_not_found.png.md",
|
||||||
"welcome.md",
|
"welcome.md",
|
||||||
"tata/briana/test-todo.md",
|
"tata/briana/test-todo.md",
|
||||||
"HOME.md",
|
|
||||||
"titi/tata-coco.md",
|
"titi/tata-coco.md",
|
||||||
"tata/briana/test-table.md",
|
"tata/briana/test-table.md",
|
||||||
"tata/briana/test-note-1.md",
|
"tata/briana/test-note-1.md",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user