96 SUCCESS tests complet
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Enhanced Graph View Container V2
|
||||
* Integrates Canvas renderer with filtering, grouping, and settings
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
effect,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { GraphCanvasComponent } from '../../app/graph/graph-canvas.component';
|
||||
import { GraphLegendComponent } from '../../app/graph/graph-legend.component';
|
||||
import { GraphSettingsStore } from '../../app/graph/graph-settings.store';
|
||||
import { GraphSettingsPanelComponent } from '../../app/graph/ui/settings-panel.component';
|
||||
import { GraphSettingsService } from '../../app/graph/graph-settings.service';
|
||||
import { NoteIndexService } from '../../services/note-index.service';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { GraphData, GraphNode as SimpleGraphNode } from '../../types';
|
||||
import { GraphNode, GraphLink, GraphNodeWithVisuals } from '../../app/graph/graph-data.types';
|
||||
import { createFilteredGraphData, createGroupLegend, createFocusedGraphData } from '../../app/graph/graph.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-view-container-v2',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
GraphCanvasComponent,
|
||||
GraphLegendComponent,
|
||||
GraphSettingsPanelComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="flex flex-col h-full w-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Main graph area -->
|
||||
<div class="flex-1 relative">
|
||||
<app-graph-canvas
|
||||
[nodes]="displayData().nodes"
|
||||
[links]="displayData().links"
|
||||
[settings]="store.settings()"
|
||||
[centerNodeId]="centerNoteId()"
|
||||
(nodeClicked)="onNodeClicked($event)"
|
||||
(nodeHovered)="onNodeHovered($event)">
|
||||
</app-graph-canvas>
|
||||
|
||||
<!-- Settings button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleSettings()"
|
||||
class="absolute top-5 right-5 flex items-center justify-center w-11 h-11 rounded-full bg-white dark:bg-gray-800 shadow-lg hover:shadow-xl transition-all hover:rotate-90 z-50 border border-gray-200 dark:border-gray-700"
|
||||
aria-label="Graph settings"
|
||||
title="Graph settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Legend at bottom -->
|
||||
@if (legendItems().length > 0) {
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<app-graph-legend
|
||||
[items]="legendItems()"
|
||||
(itemClicked)="onLegendItemClicked($event)">
|
||||
</app-graph-legend>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Settings panel -->
|
||||
<app-graph-settings-panel
|
||||
[isOpen]="settingsOpen()"
|
||||
(close)="closeSettings()"
|
||||
(animateRequested)="onAnimateRequested()">
|
||||
</app-graph-settings-panel>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphViewContainerV2Component {
|
||||
// Inputs
|
||||
centerNoteId = input<string | null>(null);
|
||||
|
||||
// Outputs
|
||||
nodeSelected = output<string>();
|
||||
|
||||
// Injected services
|
||||
private noteIndexService = inject(NoteIndexService);
|
||||
private vaultService = inject(VaultService);
|
||||
readonly store = inject(GraphSettingsStore);
|
||||
private settingsService = inject(GraphSettingsService);
|
||||
|
||||
// Local state
|
||||
settingsOpen = signal(false);
|
||||
private canvasCmp = viewChild(GraphCanvasComponent);
|
||||
|
||||
// Base graph data from vault (full graph)
|
||||
private rawGraphData = computed<GraphData>(() => this.vaultService.graphData());
|
||||
|
||||
// Convert to enhanced graph data format
|
||||
private enhancedGraphData = computed(() => {
|
||||
const rawData = this.rawGraphData();
|
||||
const allNotes = this.vaultService.allNotes();
|
||||
|
||||
// Build lookup for note metadata
|
||||
const noteMap = new Map(allNotes.map(n => [n.id, n]));
|
||||
|
||||
// Convert nodes to enhanced format
|
||||
const nodes: GraphNode[] = rawData.nodes.map(node => {
|
||||
const note = noteMap.get(node.id);
|
||||
return {
|
||||
id: node.id,
|
||||
title: node.label,
|
||||
path: note?.filePath || note?.originalPath || '',
|
||||
tags: note?.tags || [],
|
||||
hasAttachment: this.isAttachment(node.label),
|
||||
exists: noteMap.has(node.id)
|
||||
};
|
||||
});
|
||||
|
||||
// Convert edges to links
|
||||
const links: GraphLink[] = rawData.edges.map(edge => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
directed: true
|
||||
}));
|
||||
|
||||
return { nodes, links };
|
||||
});
|
||||
|
||||
// Apply all filters using selectors
|
||||
private filteredData = createFilteredGraphData(
|
||||
this.enhancedGraphData,
|
||||
this.store.settings
|
||||
);
|
||||
|
||||
// Create legend
|
||||
legendItems = createGroupLegend(
|
||||
this.filteredData,
|
||||
this.store.settings,
|
||||
this.store.focusedGroupIndex
|
||||
);
|
||||
|
||||
// Apply group focus if active
|
||||
displayData = createFocusedGraphData(
|
||||
this.filteredData,
|
||||
this.store.focusedGroupIndex
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Snapshot refs to avoid TS complaining about possibly-undefined this inside effects
|
||||
const store = this.store;
|
||||
const settingsService = this.settingsService;
|
||||
const settingsOpen = this.settingsOpen;
|
||||
|
||||
// Debounced URL updates to avoid scheduling many timers
|
||||
let urlTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
effect(() => {
|
||||
// Track settings changes
|
||||
store.settings();
|
||||
if (urlTimer) clearTimeout(urlTimer);
|
||||
urlTimer = setTimeout(() => {
|
||||
store.updateURL();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Close settings on Escape only when panel is open
|
||||
effect(() => {
|
||||
if (!settingsOpen()) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.closeSettings();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
// One-way sync: store -> service (persist). Debounced to prevent rapid churn.
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
effect(() => {
|
||||
const cfg = store.settings();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
const svcCfg = settingsService.config();
|
||||
const a = JSON.stringify(cfg);
|
||||
const b = JSON.stringify(svcCfg);
|
||||
if (a !== b) {
|
||||
settingsService.save(cfg);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node label represents an attachment
|
||||
*/
|
||||
private isAttachment(label: string): boolean {
|
||||
const attachmentExtensions = [
|
||||
'pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp',
|
||||
'mp4', 'webm', 'ogv', 'mov', 'mkv',
|
||||
'mp3', 'wav', 'ogg', 'm4a', 'flac'
|
||||
];
|
||||
|
||||
const ext = label.split('.').pop()?.toLowerCase();
|
||||
return ext ? attachmentExtensions.includes(ext) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handlers
|
||||
*/
|
||||
onNodeClicked(node: GraphNodeWithVisuals): void {
|
||||
this.nodeSelected.emit(node.id);
|
||||
}
|
||||
|
||||
onNodeHovered(node: GraphNodeWithVisuals | null): void {
|
||||
// Can emit hover events if needed
|
||||
}
|
||||
|
||||
onLegendItemClicked(groupIndex: number): void {
|
||||
this.store.toggleFocusedGroup(groupIndex);
|
||||
}
|
||||
|
||||
toggleSettings(): void {
|
||||
this.settingsOpen.update(open => !open);
|
||||
}
|
||||
|
||||
closeSettings(): void {
|
||||
this.settingsOpen.set(false);
|
||||
}
|
||||
|
||||
onAnimateRequested(): void {
|
||||
// Trigger animation on canvas
|
||||
const canvas = this.canvasCmp();
|
||||
if (canvas) {
|
||||
canvas.animate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API: Focus on a specific node by path
|
||||
*/
|
||||
focusNodeByPath(path: string): void {
|
||||
const node = this.displayData().nodes.find(n => n.path === path);
|
||||
const canvas = this.canvasCmp();
|
||||
if (node && canvas) {
|
||||
canvas.focusOnNode(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user