96 SUCCESS tests complet
This commit is contained in:
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* Canvas-based Graph Renderer
|
||||
* High-performance graph visualization using HTML5 Canvas and d3-force
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import * as d3 from 'd3-force';
|
||||
import { GraphNodeWithVisuals, GraphLink, SimulationNode, SimulationLink } from './graph-data.types';
|
||||
import { GraphConfig } from './graph-settings.types';
|
||||
import { SpatialIndex, drawArrow } from './graph.utils';
|
||||
|
||||
interface TooltipData {
|
||||
node: GraphNodeWithVisuals;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-canvas',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="relative w-full h-full bg-gray-50 dark:bg-gray-900">
|
||||
<canvas
|
||||
#canvas
|
||||
class="w-full h-full cursor-grab active:cursor-grabbing"
|
||||
(mousedown)="onMouseDown($event)"
|
||||
(mousemove)="onMouseMove($event)"
|
||||
(mouseup)="onMouseUp($event)"
|
||||
(mouseleave)="onMouseLeave($event)"
|
||||
(wheel)="onWheel($event)"
|
||||
(click)="onClick($event)">
|
||||
</canvas>
|
||||
|
||||
<!-- Tooltip -->
|
||||
@if (tooltip()) {
|
||||
<div
|
||||
class="absolute pointer-events-none bg-gray-800 dark:bg-gray-700 text-white text-xs px-3 py-2 rounded-lg shadow-lg z-50"
|
||||
[style.left.px]="tooltip()!.x + 10"
|
||||
[style.top.px]="tooltip()!.y - 10">
|
||||
<div class="font-semibold">{{ tooltip()!.node.title }}</div>
|
||||
<div class="text-gray-300 text-xs">{{ tooltip()!.node.path }}</div>
|
||||
@if (tooltip()!.node.tags.length > 0) {
|
||||
<div class="text-gray-400 text-xs mt-1">
|
||||
{{ tooltip()!.node.tags.join(', ') }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Info overlay -->
|
||||
<div class="absolute top-4 left-4 bg-white dark:bg-gray-800 px-3 py-2 rounded-lg shadow-md text-xs text-gray-700 dark:text-gray-300 pointer-events-none">
|
||||
<div><strong>Nodes:</strong> {{ nodes().length }}</div>
|
||||
<div><strong>Links:</strong> {{ links().length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphCanvasComponent implements OnDestroy {
|
||||
// Inputs
|
||||
nodes = input.required<GraphNodeWithVisuals[]>();
|
||||
links = input.required<GraphLink[]>();
|
||||
settings = input.required<GraphConfig>();
|
||||
centerNodeId = input<string | null>(null);
|
||||
|
||||
// Outputs
|
||||
nodeClicked = output<GraphNodeWithVisuals>();
|
||||
nodeHovered = output<GraphNodeWithVisuals | null>();
|
||||
|
||||
// Canvas reference
|
||||
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
||||
|
||||
// State
|
||||
tooltip = signal<TooltipData | null>(null);
|
||||
private transform = signal({ x: 0, y: 0, k: 1 });
|
||||
private simulationNodes = signal<SimulationNode[]>([]);
|
||||
private simulationLinks = signal<SimulationLink[]>([]);
|
||||
|
||||
// Simulation
|
||||
private simulation: d3.Simulation<SimulationNode, SimulationLink> | null = null;
|
||||
private animationFrameId: number | null = null;
|
||||
private isAnimating = false;
|
||||
|
||||
// Interaction state
|
||||
private isDragging = false;
|
||||
private isPanning = false;
|
||||
private dragStart = { x: 0, y: 0 };
|
||||
private draggedNode: SimulationNode | null = null;
|
||||
private hoveredNode: SimulationNode | null = null;
|
||||
private selectedNodeId: string | null = null;
|
||||
private spatialIndex: SpatialIndex | null = null;
|
||||
|
||||
// Canvas dimensions
|
||||
private width = 800;
|
||||
private height = 600;
|
||||
private ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
// Neighbors cache for highlighting
|
||||
private neighborMap = computed(() => {
|
||||
const map = new Map<string, Set<string>>();
|
||||
this.links().forEach(link => {
|
||||
if (!map.has(link.source)) map.set(link.source, new Set());
|
||||
if (!map.has(link.target)) map.set(link.target, new Set());
|
||||
map.get(link.source)!.add(link.target);
|
||||
map.get(link.target)!.add(link.source);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
this.initCanvas();
|
||||
this.initSimulation();
|
||||
});
|
||||
|
||||
// React to data changes
|
||||
effect(() => {
|
||||
const nodes = this.nodes();
|
||||
const links = this.links();
|
||||
this.updateSimulation(nodes, links);
|
||||
});
|
||||
|
||||
// React to settings changes
|
||||
effect(() => {
|
||||
const settings = this.settings();
|
||||
this.updateForces(settings);
|
||||
this.scheduleRedraw();
|
||||
});
|
||||
|
||||
// React to center node changes
|
||||
effect(() => {
|
||||
const centerId = this.centerNodeId();
|
||||
if (centerId) {
|
||||
this.focusOnNode(centerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.simulation) {
|
||||
this.simulation.stop();
|
||||
}
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize canvas and rendering context
|
||||
*/
|
||||
private initCanvas(): void {
|
||||
const canvasEl = this.canvas().nativeElement;
|
||||
this.ctx = canvasEl.getContext('2d');
|
||||
|
||||
if (!this.ctx) {
|
||||
console.error('Failed to get canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to match display size
|
||||
this.resizeCanvas();
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => this.resizeCanvas());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize canvas to match container
|
||||
*/
|
||||
private resizeCanvas(): void {
|
||||
const canvasEl = this.canvas().nativeElement;
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
|
||||
this.width = rect.width;
|
||||
this.height = rect.height;
|
||||
|
||||
// Set actual canvas size (accounting for device pixel ratio)
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvasEl.width = this.width * dpr;
|
||||
canvasEl.height = this.height * dpr;
|
||||
|
||||
if (this.ctx) {
|
||||
this.ctx.scale(dpr, dpr);
|
||||
}
|
||||
|
||||
this.scheduleRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', () => {
|
||||
this.isAnimating = 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
|
||||
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.spatialIndex = new SpatialIndex(simNodes);
|
||||
|
||||
// Update simulation
|
||||
this.simulation.nodes(simNodes);
|
||||
|
||||
// Apply forces
|
||||
this.updateForces(this.settings());
|
||||
|
||||
// Restart simulation
|
||||
this.simulation.alpha(1).restart();
|
||||
this.isAnimating = 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulation tick handler
|
||||
*/
|
||||
private onSimulationTick(): void {
|
||||
this.scheduleRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start animation loop
|
||||
*/
|
||||
private startAnimationLoop(): void {
|
||||
if (this.animationFrameId) return;
|
||||
|
||||
const animate = () => {
|
||||
if (this.isAnimating || this.simulation && this.simulation.alpha() > 0.01) {
|
||||
this.draw();
|
||||
this.animationFrameId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
this.animationFrameId = null;
|
||||
this.draw(); // Final draw
|
||||
}
|
||||
};
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a redraw
|
||||
*/
|
||||
private scheduleRedraw(): void {
|
||||
if (!this.isAnimating && !this.animationFrameId) {
|
||||
requestAnimationFrame(() => this.draw());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main draw function
|
||||
*/
|
||||
private draw(): void {
|
||||
if (!this.ctx) return;
|
||||
|
||||
const ctx = this.ctx;
|
||||
const t = this.transform();
|
||||
const settings = this.settings();
|
||||
const nodes = this.simulationNodes();
|
||||
const links = this.simulationLinks();
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, this.width, this.height);
|
||||
|
||||
// Save context
|
||||
ctx.save();
|
||||
|
||||
// Apply transform
|
||||
ctx.translate(t.x, t.y);
|
||||
ctx.scale(t.k, t.k);
|
||||
|
||||
// Draw links
|
||||
this.drawLinks(ctx, links, settings);
|
||||
|
||||
// Draw nodes
|
||||
this.drawNodes(ctx, nodes, settings);
|
||||
|
||||
// Draw labels
|
||||
this.drawLabels(ctx, nodes, settings);
|
||||
|
||||
// Restore context
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw links
|
||||
*/
|
||||
private drawLinks(ctx: CanvasRenderingContext2D, links: SimulationLink[], 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;
|
||||
}
|
||||
|
||||
// Dim link if node is selected and this link is not connected
|
||||
let opacity = 0.6;
|
||||
if (this.selectedNodeId) {
|
||||
const isConnected = source.id === this.selectedNodeId || target.id === this.selectedNodeId;
|
||||
opacity = isConnected ? 0.8 : 0.2;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = `rgba(156, 163, 175, ${opacity})`;
|
||||
ctx.lineWidth = linkThickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(source.x, source.y);
|
||||
ctx.lineTo(target.x, target.y);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw arrow if enabled
|
||||
if (settings.showArrow && link.directed !== false) {
|
||||
ctx.fillStyle = `rgba(156, 163, 175, ${opacity})`;
|
||||
drawArrow(ctx, source.x, source.y, target.x, target.y, nodeSize, 6);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw nodes
|
||||
*/
|
||||
private drawNodes(ctx: CanvasRenderingContext2D, nodes: SimulationNode[], settings: GraphConfig): void {
|
||||
const nodeSize = settings.nodeSizeMultiplier * 5;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (node.x === undefined || node.y === undefined) return;
|
||||
|
||||
// Calculate opacity based on selection
|
||||
let opacity = 1;
|
||||
if (this.selectedNodeId) {
|
||||
if (node.id === this.selectedNodeId) {
|
||||
opacity = 1;
|
||||
} else {
|
||||
const neighbors = this.neighborMap().get(this.selectedNodeId);
|
||||
opacity = neighbors && neighbors.has(node.id) ? 1 : 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw node circle
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, nodeSize, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// Highlight selected or hovered
|
||||
if (node.id === this.selectedNodeId || node === this.hoveredNode) {
|
||||
ctx.strokeStyle = '#3B82F6';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw labels
|
||||
*/
|
||||
private drawLabels(ctx: CanvasRenderingContext2D, nodes: SimulationNode[], settings: GraphConfig): void {
|
||||
const nodeSize = settings.nodeSizeMultiplier * 5;
|
||||
const threshold = settings.textFadeMultiplier;
|
||||
const zoom = this.transform().k;
|
||||
|
||||
// Calculate if labels should be visible based on zoom and threshold
|
||||
const showLabels = Math.log10(zoom) >= threshold / 3;
|
||||
|
||||
if (!showLabels) return;
|
||||
|
||||
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (node.x === undefined || node.y === undefined) return;
|
||||
|
||||
// Calculate opacity based on selection
|
||||
let opacity = 1;
|
||||
if (this.selectedNodeId) {
|
||||
if (node.id === this.selectedNodeId) {
|
||||
opacity = 1;
|
||||
} else {
|
||||
const neighbors = this.neighborMap().get(this.selectedNodeId);
|
||||
opacity = neighbors && neighbors.has(node.id) ? 1 : 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = `rgba(31, 41, 55, ${opacity})`;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillText(node.title, node.x, node.y - nodeSize - 4);
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse event handlers
|
||||
*/
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
const rect = this.canvas().nativeElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
// Convert to graph coordinates
|
||||
const t = this.transform();
|
||||
const graphX = (x - t.x) / t.k;
|
||||
const graphY = (y - t.y) / t.k;
|
||||
|
||||
// Check if clicking on a node
|
||||
const nodeSize = this.settings().nodeSizeMultiplier * 5;
|
||||
const clickedNode = this.spatialIndex?.findNodeAt(graphX, graphY, nodeSize * 2);
|
||||
|
||||
if (clickedNode) {
|
||||
this.draggedNode = clickedNode;
|
||||
this.isDragging = true;
|
||||
if (this.simulation) {
|
||||
clickedNode.fx = clickedNode.x;
|
||||
clickedNode.fy = clickedNode.y;
|
||||
}
|
||||
} else {
|
||||
this.isPanning = true;
|
||||
this.dragStart = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event: MouseEvent): void {
|
||||
const rect = this.canvas().nativeElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
if (this.isDragging && this.draggedNode) {
|
||||
// Drag node
|
||||
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();
|
||||
}
|
||||
} else if (this.isPanning) {
|
||||
// Pan canvas
|
||||
const dx = event.clientX - this.dragStart.x;
|
||||
const dy = event.clientY - this.dragStart.y;
|
||||
|
||||
const t = this.transform();
|
||||
this.transform.set({
|
||||
...t,
|
||||
x: t.x + dx,
|
||||
y: t.y + dy
|
||||
});
|
||||
|
||||
this.dragStart = { x: event.clientX, y: event.clientY };
|
||||
this.scheduleRedraw();
|
||||
} else {
|
||||
// Check for hover
|
||||
const t = this.transform();
|
||||
const graphX = (x - t.x) / t.k;
|
||||
const graphY = (y - t.y) / t.k;
|
||||
|
||||
const nodeSize = this.settings().nodeSizeMultiplier * 5;
|
||||
const hoveredNode = this.spatialIndex?.findNodeAt(graphX, graphY, nodeSize * 2);
|
||||
|
||||
if (hoveredNode !== this.hoveredNode) {
|
||||
this.hoveredNode = hoveredNode || null;
|
||||
|
||||
if (hoveredNode) {
|
||||
this.tooltip.set({ node: hoveredNode, x, y });
|
||||
this.nodeHovered.emit(hoveredNode);
|
||||
} else {
|
||||
this.tooltip.set(null);
|
||||
this.nodeHovered.emit(null);
|
||||
}
|
||||
|
||||
this.scheduleRedraw();
|
||||
} else if (hoveredNode) {
|
||||
// Update tooltip position
|
||||
this.tooltip.set({ node: hoveredNode, x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(event: MouseEvent): void {
|
||||
if (this.isDragging && this.draggedNode) {
|
||||
this.draggedNode.fx = null;
|
||||
this.draggedNode.fy = null;
|
||||
this.draggedNode = null;
|
||||
}
|
||||
|
||||
this.isDragging = false;
|
||||
this.isPanning = false;
|
||||
}
|
||||
|
||||
onMouseLeave(event: MouseEvent): void {
|
||||
this.onMouseUp(event);
|
||||
this.hoveredNode = null;
|
||||
this.tooltip.set(null);
|
||||
this.nodeHovered.emit(null);
|
||||
this.scheduleRedraw();
|
||||
}
|
||||
|
||||
onClick(event: MouseEvent): void {
|
||||
if (this.isDragging || this.isPanning) return;
|
||||
|
||||
const rect = this.canvas().nativeElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
const t = this.transform();
|
||||
const graphX = (x - t.x) / t.k;
|
||||
const graphY = (y - t.y) / t.k;
|
||||
|
||||
const nodeSize = this.settings().nodeSizeMultiplier * 5;
|
||||
const clickedNode = this.spatialIndex?.findNodeAt(graphX, graphY, nodeSize * 2);
|
||||
|
||||
if (clickedNode) {
|
||||
this.selectedNodeId = this.selectedNodeId === clickedNode.id ? null : clickedNode.id;
|
||||
this.nodeClicked.emit(clickedNode);
|
||||
this.scheduleRedraw();
|
||||
} else {
|
||||
this.selectedNodeId = null;
|
||||
this.scheduleRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
onWheel(event: WheelEvent): void {
|
||||
event.preventDefault();
|
||||
|
||||
const rect = this.canvas().nativeElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
const delta = -event.deltaY / 1000;
|
||||
const t = this.transform();
|
||||
const newK = Math.max(0.2, Math.min(5, t.k * (1 + delta)));
|
||||
|
||||
// Zoom towards cursor
|
||||
const factor = newK / t.k;
|
||||
this.transform.set({
|
||||
x: x - (x - t.x) * factor,
|
||||
y: y - (y - t.y) * factor,
|
||||
k: newK
|
||||
});
|
||||
|
||||
this.scheduleRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API: Focus on a specific node
|
||||
*/
|
||||
focusOnNode(nodeId: string): void {
|
||||
const node = this.simulationNodes().find(n => n.id === nodeId);
|
||||
if (!node || node.x === undefined || node.y === undefined) return;
|
||||
|
||||
// Center on node with smooth animation
|
||||
const targetX = this.width / 2 - node.x * this.transform().k;
|
||||
const targetY = this.height / 2 - node.y * this.transform().k;
|
||||
|
||||
this.transform.set({
|
||||
...this.transform(),
|
||||
x: targetX,
|
||||
y: targetY
|
||||
});
|
||||
|
||||
this.selectedNodeId = nodeId;
|
||||
this.scheduleRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API: Restart animation
|
||||
*/
|
||||
animate(): void {
|
||||
if (this.simulation) {
|
||||
this.simulation.alpha(1).restart();
|
||||
this.isAnimating = true;
|
||||
this.startAnimationLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Enhanced graph data types for ObsiViewer
|
||||
* Supports filtering, grouping, and rich node metadata
|
||||
*/
|
||||
|
||||
export type NodeId = string;
|
||||
|
||||
/** Graph node with metadata for filtering and display */
|
||||
export interface GraphNode {
|
||||
id: NodeId;
|
||||
title: string;
|
||||
path: string;
|
||||
tags: string[];
|
||||
hasAttachment: boolean;
|
||||
exists: boolean; // For "Existing files only" filter
|
||||
}
|
||||
|
||||
/** Graph link between nodes */
|
||||
export interface GraphLink {
|
||||
source: NodeId;
|
||||
target: NodeId;
|
||||
directed?: boolean;
|
||||
}
|
||||
|
||||
/** Complete graph dataset */
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
/** Node with computed visual properties */
|
||||
export interface GraphNodeWithVisuals extends GraphNode {
|
||||
color: string;
|
||||
groupIndex?: number;
|
||||
degree: number; // Number of connections
|
||||
isOrphan: boolean;
|
||||
}
|
||||
|
||||
/** Link with resolved node references for simulation */
|
||||
export interface SimulationLink {
|
||||
source: NodeId | SimulationNode;
|
||||
target: NodeId | SimulationNode;
|
||||
directed?: boolean;
|
||||
}
|
||||
|
||||
/** Node with d3-force simulation properties */
|
||||
export interface SimulationNode extends GraphNodeWithVisuals {
|
||||
x?: number;
|
||||
y?: number;
|
||||
vx?: number;
|
||||
vy?: number;
|
||||
fx?: number | null;
|
||||
fy?: number | null;
|
||||
}
|
||||
|
||||
/** Group legend item for display */
|
||||
export interface GroupLegendItem {
|
||||
groupIndex: number;
|
||||
query: string;
|
||||
color: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Graph Legend Component
|
||||
* Displays color groups with counts and allows filtering by clicking
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { GroupLegendItem } from './graph-data.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-legend',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (items().length > 0) {
|
||||
<div class="flex flex-wrap gap-2 p-3 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
@for (item of items(); track item.groupIndex) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="itemClicked.emit(item.groupIndex)"
|
||||
[class.opacity-50]="!item.active"
|
||||
[class.ring-2]="!item.active"
|
||||
[class.ring-gray-400]="!item.active"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-all cursor-pointer group">
|
||||
|
||||
<!-- Color chip -->
|
||||
<div
|
||||
class="w-3 h-3 rounded-full ring-1 ring-gray-300 dark:ring-gray-600"
|
||||
[style.background-color]="item.color">
|
||||
</div>
|
||||
|
||||
<!-- Query text -->
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">
|
||||
{{ item.query }}
|
||||
</span>
|
||||
|
||||
<!-- Count badge -->
|
||||
<span class="text-xs font-semibold px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300">
|
||||
{{ item.count }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class GraphLegendComponent {
|
||||
items = input.required<GroupLegendItem[]>();
|
||||
itemClicked = output<number>();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Unit tests for GraphRuntimeAdapter config mapping (Jasmine)
|
||||
*/
|
||||
import { GraphRuntimeAdapter } from './graph-runtime-adapter';
|
||||
import { DEFAULT_GRAPH_CONFIG, GraphConfig } from './graph-settings.types';
|
||||
|
||||
function withConfig(patch: Partial<GraphConfig>): GraphConfig {
|
||||
return { ...DEFAULT_GRAPH_CONFIG, ...patch } as GraphConfig;
|
||||
}
|
||||
|
||||
describe('GraphRuntimeAdapter.configToDisplayOptions', () => {
|
||||
it('maps arrows and distances correctly', () => {
|
||||
const cfg = withConfig({ showArrow: true, linkDistance: 250, centerStrength: 0.5, repelStrength: 10, nodeSizeMultiplier: 1, lineSizeMultiplier: 1, textFadeMultiplier: 0 });
|
||||
const opts = GraphRuntimeAdapter.configToDisplayOptions(cfg);
|
||||
expect(opts.showArrows).toBe(true);
|
||||
expect(opts.linkDistance).toBe(250);
|
||||
});
|
||||
|
||||
it('maps repel strength to negative charge', () => {
|
||||
const cfg = withConfig({ repelStrength: 12 });
|
||||
const opts = GraphRuntimeAdapter.configToDisplayOptions(cfg);
|
||||
// convertRepelStrength: -repel*10
|
||||
expect(opts.chargeStrength).toBe(-120);
|
||||
});
|
||||
|
||||
it('maps node and link size multipliers', () => {
|
||||
const cfg = withConfig({ nodeSizeMultiplier: 2, lineSizeMultiplier: 3 });
|
||||
const opts = GraphRuntimeAdapter.configToDisplayOptions(cfg);
|
||||
expect(opts.nodeSize).toBe(10); // base 5 * 2
|
||||
expect(opts.linkThickness).toBe(3); // base 1 * 3
|
||||
});
|
||||
|
||||
it('maps text fade multiplier -3..3 to 0..100 threshold', () => {
|
||||
const cfgMin = withConfig({ textFadeMultiplier: -3 });
|
||||
const cfgMid = withConfig({ textFadeMultiplier: 0 });
|
||||
const cfgMax = withConfig({ textFadeMultiplier: 3 });
|
||||
const min = GraphRuntimeAdapter.configToDisplayOptions(cfgMin).textFadeThreshold;
|
||||
const mid = GraphRuntimeAdapter.configToDisplayOptions(cfgMid).textFadeThreshold;
|
||||
const max = GraphRuntimeAdapter.configToDisplayOptions(cfgMax).textFadeThreshold;
|
||||
expect(min).toBe(0);
|
||||
expect(mid).toBe(50);
|
||||
expect(max).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Graph Settings Store
|
||||
* Manages graph settings state with Angular signals
|
||||
* Handles localStorage and URL query parameter persistence
|
||||
*/
|
||||
|
||||
import { Injectable, signal, computed, effect } from '@angular/core';
|
||||
import { GraphConfig, DEFAULT_GRAPH_CONFIG, validateGraphConfig } from './graph-settings.types';
|
||||
|
||||
const STORAGE_KEY = 'obsi-graph-settings';
|
||||
const URL_PARAM = 'gs';
|
||||
|
||||
/**
|
||||
* Encode settings to base64url for URL sharing
|
||||
*/
|
||||
function encodeSettings(config: GraphConfig): string {
|
||||
try {
|
||||
const json = JSON.stringify(config);
|
||||
const base64 = btoa(json);
|
||||
// Make URL-safe
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
} catch (e) {
|
||||
console.error('Failed to encode settings:', e);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode settings from base64url
|
||||
*/
|
||||
function decodeSettings(encoded: string): Partial<GraphConfig> | null {
|
||||
try {
|
||||
// Restore base64 from URL-safe format
|
||||
let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Add padding
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
const json = atob(base64);
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
console.error('Failed to decode settings:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GraphSettingsStore {
|
||||
// Core settings signal
|
||||
private _settings = signal<GraphConfig>(this.loadInitialSettings());
|
||||
|
||||
// Focused group for legend click filtering
|
||||
private _focusedGroupIndex = signal<number | null>(null);
|
||||
|
||||
// Read-only settings
|
||||
readonly settings = this._settings.asReadonly();
|
||||
readonly focusedGroupIndex = this._focusedGroupIndex.asReadonly();
|
||||
|
||||
// Derived signals
|
||||
readonly filters = computed(() => ({
|
||||
search: this.settings().search,
|
||||
tagsOnly: this.settings().showTags,
|
||||
attachmentsOnly: this.settings().showAttachments,
|
||||
existingOnly: this.settings().hideUnresolved,
|
||||
orphansOnly: !this.settings().showOrphans
|
||||
}));
|
||||
|
||||
readonly groups = computed(() => ({
|
||||
groups: this.settings().colorGroups
|
||||
}));
|
||||
|
||||
readonly display = computed(() => ({
|
||||
arrows: this.settings().showArrow,
|
||||
textFadeThreshold: this.settings().textFadeMultiplier,
|
||||
nodeSize: this.settings().nodeSizeMultiplier,
|
||||
linkThickness: this.settings().lineSizeMultiplier
|
||||
}));
|
||||
|
||||
readonly forces = computed(() => ({
|
||||
centerForce: this.settings().centerStrength,
|
||||
repelForce: this.settings().repelStrength,
|
||||
linkForce: this.settings().linkStrength,
|
||||
linkDistance: this.settings().linkDistance
|
||||
}));
|
||||
|
||||
constructor() {
|
||||
// Persist to localStorage on changes
|
||||
effect(() => {
|
||||
const settings = this._settings();
|
||||
this.saveToLocalStorage(settings);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load initial settings from URL or localStorage
|
||||
*/
|
||||
private loadInitialSettings(): GraphConfig {
|
||||
// Try URL first
|
||||
const urlSettings = this.loadFromURL();
|
||||
if (urlSettings) {
|
||||
return this.validateAndMerge(urlSettings);
|
||||
}
|
||||
|
||||
// Try localStorage
|
||||
const storedSettings = this.loadFromLocalStorage();
|
||||
if (storedSettings) {
|
||||
return this.validateAndMerge(storedSettings);
|
||||
}
|
||||
|
||||
return DEFAULT_GRAPH_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from URL query parameter
|
||||
*/
|
||||
private loadFromURL(): Partial<GraphConfig> | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const encoded = params.get(URL_PARAM);
|
||||
|
||||
if (!encoded) return null;
|
||||
|
||||
return decodeSettings(encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from localStorage
|
||||
*/
|
||||
private loadFromLocalStorage(): Partial<GraphConfig> | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
return JSON.parse(stored);
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings from localStorage:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to localStorage
|
||||
*/
|
||||
private saveToLocalStorage(config: GraphConfig): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update URL with current settings
|
||||
*/
|
||||
updateURL(): void {
|
||||
if (typeof window === 'undefined' || typeof history === 'undefined') return;
|
||||
|
||||
const encoded = encodeSettings(this._settings());
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
if (encoded) {
|
||||
url.searchParams.set(URL_PARAM, encoded);
|
||||
} else {
|
||||
url.searchParams.delete(URL_PARAM);
|
||||
}
|
||||
|
||||
history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and merge partial config with defaults
|
||||
*/
|
||||
private validateAndMerge(partial: Partial<GraphConfig>): GraphConfig {
|
||||
const merged = {
|
||||
...DEFAULT_GRAPH_CONFIG,
|
||||
...partial
|
||||
};
|
||||
|
||||
return validateGraphConfig(merged) as GraphConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings (partial update)
|
||||
*/
|
||||
update(patch: Partial<GraphConfig>): void {
|
||||
const updated = { ...this._settings(), ...patch };
|
||||
const validated = this.validateAndMerge(updated);
|
||||
this._settings.set(validated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual setters for convenience
|
||||
*/
|
||||
setSearch(value: string): void {
|
||||
this.update({ search: value });
|
||||
}
|
||||
|
||||
setShowTags(value: boolean): void {
|
||||
this.update({ showTags: value });
|
||||
}
|
||||
|
||||
setShowAttachments(value: boolean): void {
|
||||
this.update({ showAttachments: value });
|
||||
}
|
||||
|
||||
setHideUnresolved(value: boolean): void {
|
||||
this.update({ hideUnresolved: value });
|
||||
}
|
||||
|
||||
setShowOrphans(value: boolean): void {
|
||||
this.update({ showOrphans: value });
|
||||
}
|
||||
|
||||
setColorGroups(groups: any[]): void {
|
||||
this.update({ colorGroups: groups });
|
||||
}
|
||||
|
||||
setShowArrow(value: boolean): void {
|
||||
this.update({ showArrow: value });
|
||||
}
|
||||
|
||||
setTextFadeMultiplier(value: number): void {
|
||||
this.update({ textFadeMultiplier: value });
|
||||
}
|
||||
|
||||
setNodeSizeMultiplier(value: number): void {
|
||||
this.update({ nodeSizeMultiplier: value });
|
||||
}
|
||||
|
||||
setLineSizeMultiplier(value: number): void {
|
||||
this.update({ lineSizeMultiplier: value });
|
||||
}
|
||||
|
||||
setCenterStrength(value: number): void {
|
||||
this.update({ centerStrength: value });
|
||||
}
|
||||
|
||||
setRepelStrength(value: number): void {
|
||||
this.update({ repelStrength: value });
|
||||
}
|
||||
|
||||
setLinkStrength(value: number): void {
|
||||
this.update({ linkStrength: value });
|
||||
}
|
||||
|
||||
setLinkDistance(value: number): void {
|
||||
this.update({ linkDistance: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle collapse states
|
||||
*/
|
||||
toggleCollapseFilter(): void {
|
||||
this.update({ 'collapse-filter': !this._settings()['collapse-filter'] });
|
||||
}
|
||||
|
||||
toggleCollapseColorGroups(): void {
|
||||
this.update({ 'collapse-color-groups': !this._settings()['collapse-color-groups'] });
|
||||
}
|
||||
|
||||
toggleCollapseDisplay(): void {
|
||||
this.update({ 'collapse-display': !this._settings()['collapse-display'] });
|
||||
}
|
||||
|
||||
toggleCollapseForces(): void {
|
||||
this.update({ 'collapse-forces': !this._settings()['collapse-forces'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on a specific group (for legend interaction)
|
||||
*/
|
||||
setFocusedGroup(index: number | null): void {
|
||||
this._focusedGroupIndex.set(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle focused group
|
||||
*/
|
||||
toggleFocusedGroup(index: number): void {
|
||||
const current = this._focusedGroupIndex();
|
||||
this._focusedGroupIndex.set(current === index ? null : index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to defaults
|
||||
*/
|
||||
reset(): void {
|
||||
this._settings.set(DEFAULT_GRAPH_CONFIG);
|
||||
this._focusedGroupIndex.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset specific section
|
||||
*/
|
||||
resetSection(section: 'filters' | 'groups' | 'display' | 'forces'): void {
|
||||
const defaults = DEFAULT_GRAPH_CONFIG;
|
||||
|
||||
switch (section) {
|
||||
case 'filters':
|
||||
this.update({
|
||||
search: defaults.search,
|
||||
showTags: defaults.showTags,
|
||||
showAttachments: defaults.showAttachments,
|
||||
hideUnresolved: defaults.hideUnresolved,
|
||||
showOrphans: defaults.showOrphans
|
||||
});
|
||||
break;
|
||||
|
||||
case 'groups':
|
||||
this.update({ colorGroups: defaults.colorGroups });
|
||||
break;
|
||||
|
||||
case 'display':
|
||||
this.update({
|
||||
showArrow: defaults.showArrow,
|
||||
textFadeMultiplier: defaults.textFadeMultiplier,
|
||||
nodeSizeMultiplier: defaults.nodeSizeMultiplier,
|
||||
lineSizeMultiplier: defaults.lineSizeMultiplier
|
||||
});
|
||||
break;
|
||||
|
||||
case 'forces':
|
||||
this.update({
|
||||
centerStrength: defaults.centerStrength,
|
||||
repelStrength: defaults.repelStrength,
|
||||
linkStrength: defaults.linkStrength,
|
||||
linkDistance: defaults.linkDistance
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Unit tests for graph selectors (Jasmine)
|
||||
*/
|
||||
|
||||
import { signal } from '@angular/core';
|
||||
import { createFilteredGraphData, createGroupLegend } from './graph.selectors';
|
||||
import { GraphData, GraphNode } from './graph-data.types';
|
||||
import { GraphConfig, DEFAULT_GRAPH_CONFIG, createGraphColor } from './graph-settings.types';
|
||||
|
||||
describe('GraphSelectors', () => {
|
||||
const createTestNode = (id: string, overrides?: Partial<GraphNode>): GraphNode => ({
|
||||
id,
|
||||
title: `Note ${id}`,
|
||||
path: `folder/${id}.md`,
|
||||
tags: [],
|
||||
hasAttachment: false,
|
||||
exists: true,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const createTestData = (): GraphData => ({
|
||||
nodes: [
|
||||
createTestNode('1', { tags: ['markdown'], exists: true }),
|
||||
createTestNode('2', { tags: ['test'], exists: true }),
|
||||
createTestNode('3', { hasAttachment: true, exists: true }),
|
||||
createTestNode('4', { exists: false }),
|
||||
createTestNode('5', { tags: ['markdown', 'test'], exists: true })
|
||||
],
|
||||
links: [
|
||||
{ source: '1', target: '2' },
|
||||
{ source: '2', target: '3' },
|
||||
{ source: '1', target: '5' }
|
||||
]
|
||||
});
|
||||
|
||||
describe('createFilteredGraphData', () => {
|
||||
it('should return all data with default config', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal(DEFAULT_GRAPH_CONFIG);
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
expect(result.nodes.length).toBe(5);
|
||||
expect(result.links.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should filter by search text', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({ ...DEFAULT_GRAPH_CONFIG, search: 'Note 1' });
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
expect(result.nodes.length).toBe(1);
|
||||
expect(result.nodes[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should filter by tags only', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({ ...DEFAULT_GRAPH_CONFIG, showTags: true });
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
expect(result.nodes.length).toBe(3); // nodes 1, 2, 5 have tags
|
||||
expect(result.nodes.every(n => n.tags.length > 0)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should filter by attachments', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({ ...DEFAULT_GRAPH_CONFIG, showAttachments: true });
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
expect(result.nodes.length).toBe(1);
|
||||
expect(result.nodes[0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('should filter by existing files only', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({ ...DEFAULT_GRAPH_CONFIG, hideUnresolved: true });
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
expect(result.nodes.length).toBe(4); // node 4 has exists: false
|
||||
expect(result.nodes.every(n => n.exists)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should filter orphans', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({ ...DEFAULT_GRAPH_CONFIG, showOrphans: false });
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
// Node 4 is orphan (no links)
|
||||
expect(result.nodes.every(n => n.id !== '4')).toBeTrue();
|
||||
});
|
||||
|
||||
it('should prune broken links after filtering', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({ ...DEFAULT_GRAPH_CONFIG, search: 'Note 1' });
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
expect(result.links.length).toBe(0); // All links removed as endpoints filtered
|
||||
});
|
||||
|
||||
it('should apply color groups', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({
|
||||
...DEFAULT_GRAPH_CONFIG,
|
||||
colorGroups: [
|
||||
{ query: 'tag:markdown', color: createGraphColor('#FF0000', 1) },
|
||||
{ query: 'tag:test', color: createGraphColor('#00FF00', 1) }
|
||||
]
|
||||
});
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
const node1 = result.nodes.find(n => n.id === '1');
|
||||
const node2 = result.nodes.find(n => n.id === '2');
|
||||
|
||||
expect(node1?.color.includes('255, 0, 0')).toBeTrue(); // Red for markdown
|
||||
expect(node2?.color.includes('0, 255, 0')).toBeTrue(); // Green for test
|
||||
});
|
||||
|
||||
it('should mark orphan status correctly', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal(DEFAULT_GRAPH_CONFIG);
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
const node4 = result.nodes.find(n => n.id === '4');
|
||||
expect(node4?.isOrphan).toBeTrue();
|
||||
expect(node4?.degree).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate node degrees', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal(DEFAULT_GRAPH_CONFIG);
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
|
||||
const result = filtered();
|
||||
const node1 = result.nodes.find(n => n.id === '1');
|
||||
const node2 = result.nodes.find(n => n.id === '2');
|
||||
|
||||
expect(node1?.degree).toBe(2); // connected to 2 and 5
|
||||
expect(node2?.degree).toBe(2); // connected to 1 and 3
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGroupLegend', () => {
|
||||
it('should create legend items for each group', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({
|
||||
...DEFAULT_GRAPH_CONFIG,
|
||||
colorGroups: [
|
||||
{ query: 'tag:markdown', color: createGraphColor('#FF0000', 1) },
|
||||
{ query: 'tag:test', color: createGraphColor('#00FF00', 1) }
|
||||
]
|
||||
});
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
const focusedGroup = signal<number | null>(null);
|
||||
|
||||
const legend = createGroupLegend(filtered, config, focusedGroup);
|
||||
const items = legend();
|
||||
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].query).toBe('tag:markdown');
|
||||
expect(items[1].query).toBe('tag:test');
|
||||
});
|
||||
|
||||
it('should count nodes per group', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({
|
||||
...DEFAULT_GRAPH_CONFIG,
|
||||
colorGroups: [
|
||||
{ query: 'tag:markdown', color: createGraphColor('#FF0000', 1) },
|
||||
{ query: 'tag:test', color: createGraphColor('#00FF00', 1) }
|
||||
]
|
||||
});
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
const focusedGroup = signal<number | null>(null);
|
||||
|
||||
const legend = createGroupLegend(filtered, config, focusedGroup);
|
||||
const items = legend();
|
||||
|
||||
expect(items[0].count).toBe(2); // nodes 1 and 5 have markdown tag
|
||||
expect(items[1].count).toBe(1); // node 2 has test tag (5 already matched by markdown)
|
||||
});
|
||||
|
||||
it('should mark active state based on focus', () => {
|
||||
const rawData = signal(createTestData());
|
||||
const config = signal({
|
||||
...DEFAULT_GRAPH_CONFIG,
|
||||
colorGroups: [
|
||||
{ query: 'tag:markdown', color: createGraphColor('#FF0000', 1) },
|
||||
{ query: 'tag:test', color: createGraphColor('#00FF00', 1) }
|
||||
]
|
||||
});
|
||||
const filtered = createFilteredGraphData(rawData, config);
|
||||
const focusedGroup = signal<number | null>(0);
|
||||
|
||||
const legend = createGroupLegend(filtered, config, focusedGroup);
|
||||
const items = legend();
|
||||
|
||||
expect(items[0].active).toBeTrue();
|
||||
expect(items[1].active).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Graph selectors - filtering and data transformation logic
|
||||
* Uses computed signals for reactive filtering pipeline
|
||||
*/
|
||||
|
||||
import { computed, Signal } from '@angular/core';
|
||||
import { GraphData, GraphNode, GraphLink, GraphNodeWithVisuals, GroupLegendItem } from './graph-data.types';
|
||||
import { GraphConfig, GraphColorGroup, colorToCss } from './graph-settings.types';
|
||||
import { parseGroupQuery, nodeMatchesQuery } from './group-query.parser';
|
||||
import { DEFAULT_NODE_COLOR } from './graph.utils';
|
||||
|
||||
/**
|
||||
* Apply text search filter to nodes
|
||||
*/
|
||||
function filterBySearch(nodes: GraphNode[], search: string): GraphNode[] {
|
||||
if (!search.trim()) return nodes;
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
return nodes.filter(node =>
|
||||
node.title.toLowerCase().includes(searchLower) ||
|
||||
node.path.toLowerCase().includes(searchLower) ||
|
||||
node.tags.some(tag => tag.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter nodes by tags only
|
||||
*/
|
||||
function filterByTags(nodes: GraphNode[], enabled: boolean): GraphNode[] {
|
||||
if (!enabled) return nodes;
|
||||
return nodes.filter(node => node.tags.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter nodes by attachments
|
||||
*/
|
||||
function filterByAttachments(nodes: GraphNode[], enabled: boolean): GraphNode[] {
|
||||
if (!enabled) return nodes;
|
||||
return nodes.filter(node => node.hasAttachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter nodes by existence
|
||||
*/
|
||||
function filterByExistence(nodes: GraphNode[], hideUnresolved: boolean): GraphNode[] {
|
||||
if (!hideUnresolved) return nodes;
|
||||
return nodes.filter(node => node.exists);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate node degrees (number of connections)
|
||||
*/
|
||||
function calculateDegrees(nodes: GraphNode[], links: GraphLink[]): Map<string, number> {
|
||||
const degrees = new Map<string, number>();
|
||||
|
||||
// Initialize all nodes with 0
|
||||
nodes.forEach(node => degrees.set(node.id, 0));
|
||||
|
||||
// Count connections
|
||||
links.forEach(link => {
|
||||
degrees.set(link.source, (degrees.get(link.source) || 0) + 1);
|
||||
degrees.set(link.target, (degrees.get(link.target) || 0) + 1);
|
||||
});
|
||||
|
||||
return degrees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter orphan nodes (nodes with no connections)
|
||||
*/
|
||||
function filterByOrphans(
|
||||
nodes: GraphNode[],
|
||||
links: GraphLink[],
|
||||
showOrphans: boolean
|
||||
): { nodes: GraphNode[], links: GraphLink[] } {
|
||||
if (showOrphans) {
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
const degrees = calculateDegrees(nodes, links);
|
||||
const filteredNodes = nodes.filter(node => (degrees.get(node.id) || 0) > 0);
|
||||
|
||||
return { nodes: filteredNodes, links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply color groups to nodes
|
||||
*/
|
||||
function applyColorGroups(
|
||||
nodes: GraphNode[],
|
||||
colorGroups: GraphColorGroup[]
|
||||
): GraphNodeWithVisuals[] {
|
||||
const degrees = new Map<string, number>();
|
||||
nodes.forEach(node => degrees.set(node.id, 0));
|
||||
|
||||
return nodes.map(node => {
|
||||
let color = DEFAULT_NODE_COLOR;
|
||||
let groupIndex: number | undefined = undefined;
|
||||
|
||||
// Find first matching group (first match wins)
|
||||
for (let i = 0; i < colorGroups.length; i++) {
|
||||
const group = colorGroups[i];
|
||||
const parsed = parseGroupQuery(group.query);
|
||||
|
||||
if (parsed && nodeMatchesQuery(node, parsed)) {
|
||||
color = colorToCss(group.color);
|
||||
groupIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
color,
|
||||
groupIndex,
|
||||
degree: degrees.get(node.id) || 0,
|
||||
isOrphan: false // Will be updated later
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update orphan status after filtering
|
||||
*/
|
||||
function updateOrphanStatus(
|
||||
nodes: GraphNodeWithVisuals[],
|
||||
links: GraphLink[]
|
||||
): GraphNodeWithVisuals[] {
|
||||
const degrees = calculateDegrees(nodes, links);
|
||||
|
||||
return nodes.map(node => ({
|
||||
...node,
|
||||
degree: degrees.get(node.id) || 0,
|
||||
isOrphan: (degrees.get(node.id) || 0) === 0
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune links whose endpoints are filtered out
|
||||
*/
|
||||
function pruneBrokenLinks(nodes: GraphNode[], links: GraphLink[]): GraphLink[] {
|
||||
const nodeIds = new Set(nodes.map(n => n.id));
|
||||
return links.filter(link =>
|
||||
nodeIds.has(link.source) && nodeIds.has(link.target)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filtered graph data computed signal
|
||||
*/
|
||||
export function createFilteredGraphData(
|
||||
rawData: Signal<GraphData>,
|
||||
config: Signal<GraphConfig>
|
||||
): Signal<{ nodes: GraphNodeWithVisuals[], links: GraphLink[] }> {
|
||||
return computed(() => {
|
||||
const data = rawData();
|
||||
const cfg = config();
|
||||
|
||||
// Apply filters in sequence
|
||||
let filteredNodes = data.nodes;
|
||||
|
||||
// 1. Text search
|
||||
filteredNodes = filterBySearch(filteredNodes, cfg.search);
|
||||
|
||||
// 2. Tags only
|
||||
filteredNodes = filterByTags(filteredNodes, cfg.showTags);
|
||||
|
||||
// 3. Attachments only
|
||||
filteredNodes = filterByAttachments(filteredNodes, cfg.showAttachments);
|
||||
|
||||
// 4. Existing files only
|
||||
filteredNodes = filterByExistence(filteredNodes, cfg.hideUnresolved);
|
||||
|
||||
// Prune broken links
|
||||
let filteredLinks = pruneBrokenLinks(filteredNodes, data.links);
|
||||
|
||||
// 5. Orphans filter
|
||||
const afterOrphans = filterByOrphans(filteredNodes, filteredLinks, cfg.showOrphans);
|
||||
filteredNodes = afterOrphans.nodes;
|
||||
filteredLinks = afterOrphans.links;
|
||||
|
||||
// Apply color groups
|
||||
let nodesWithVisuals = applyColorGroups(filteredNodes, cfg.colorGroups);
|
||||
|
||||
// Update orphan status based on final link set
|
||||
nodesWithVisuals = updateOrphanStatus(nodesWithVisuals, filteredLinks);
|
||||
|
||||
return {
|
||||
nodes: nodesWithVisuals,
|
||||
links: filteredLinks
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create group legend items
|
||||
*/
|
||||
export function createGroupLegend(
|
||||
filteredData: Signal<{ nodes: GraphNodeWithVisuals[], links: GraphLink[] }>,
|
||||
config: Signal<GraphConfig>,
|
||||
focusedGroupIndex: Signal<number | null>
|
||||
): Signal<GroupLegendItem[]> {
|
||||
return computed(() => {
|
||||
const data = filteredData();
|
||||
const cfg = config();
|
||||
const focused = focusedGroupIndex();
|
||||
|
||||
// Count nodes per group
|
||||
const groupCounts = new Map<number, number>();
|
||||
data.nodes.forEach(node => {
|
||||
if (node.groupIndex !== undefined) {
|
||||
groupCounts.set(node.groupIndex, (groupCounts.get(node.groupIndex) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create legend items
|
||||
return cfg.colorGroups.map((group, index) => ({
|
||||
groupIndex: index,
|
||||
query: group.query,
|
||||
color: colorToCss(group.color),
|
||||
count: groupCounts.get(index) || 0,
|
||||
active: focused === null || focused === index
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter data by focused group
|
||||
*/
|
||||
export function createFocusedGraphData(
|
||||
filteredData: Signal<{ nodes: GraphNodeWithVisuals[], links: GraphLink[] }>,
|
||||
focusedGroupIndex: Signal<number | null>
|
||||
): Signal<{ nodes: GraphNodeWithVisuals[], links: GraphLink[] }> {
|
||||
return computed(() => {
|
||||
const data = filteredData();
|
||||
const focused = focusedGroupIndex();
|
||||
|
||||
// If no focus, return all data
|
||||
if (focused === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Filter nodes by group
|
||||
const focusedNodes = data.nodes.filter(node => node.groupIndex === focused);
|
||||
const focusedLinks = pruneBrokenLinks(focusedNodes, data.links);
|
||||
|
||||
return {
|
||||
nodes: focusedNodes,
|
||||
links: focusedLinks
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Graph utility functions
|
||||
* Colors, hit-testing, rendering helpers
|
||||
*/
|
||||
|
||||
import { SimulationNode } from './graph-data.types';
|
||||
|
||||
/**
|
||||
* Default pastel colors for groups
|
||||
*/
|
||||
export const DEFAULT_GROUP_COLORS = [
|
||||
'#E06C75', // red
|
||||
'#98C379', // green
|
||||
'#61AFEF', // blue
|
||||
'#C678DD', // purple
|
||||
'#E5C07B', // yellow
|
||||
'#56B6C2', // cyan
|
||||
'#D19A66', // orange
|
||||
'#E88388', // pink
|
||||
'#89B8C2', // teal
|
||||
'#A0C980', // lime
|
||||
];
|
||||
|
||||
/**
|
||||
* Default node color (theme-aware will be handled in component)
|
||||
*/
|
||||
export const DEFAULT_NODE_COLOR = '#3B82F6'; // blue-500
|
||||
|
||||
/**
|
||||
* Get a pastel color by index (cycles through palette)
|
||||
*/
|
||||
export function getGroupColor(index: number): string {
|
||||
return DEFAULT_GROUP_COLORS[index % DEFAULT_GROUP_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random pastel color
|
||||
*/
|
||||
export function randomPastelColor(): string {
|
||||
const hue = Math.floor(Math.random() * 360);
|
||||
return `hsl(${hue}, 65%, 65%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple quadtree-like spatial index for hit testing
|
||||
*/
|
||||
export class SpatialIndex {
|
||||
private nodes: Map<string, SimulationNode> = new Map();
|
||||
|
||||
constructor(nodes: SimulationNode[]) {
|
||||
nodes.forEach(node => {
|
||||
this.nodes.set(node.id, node);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find node at given canvas coordinates within radius
|
||||
*/
|
||||
findNodeAt(x: number, y: number, radius: number): SimulationNode | null {
|
||||
let closest: SimulationNode | null = null;
|
||||
let minDist = radius;
|
||||
|
||||
for (const node of this.nodes.values()) {
|
||||
if (node.x === undefined || node.y === undefined) continue;
|
||||
|
||||
const dx = node.x - x;
|
||||
const dy = node.y - y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closest = node;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all nodes within a rectangular region
|
||||
*/
|
||||
findNodesInRect(x1: number, y1: number, x2: number, y2: number): SimulationNode[] {
|
||||
const minX = Math.min(x1, x2);
|
||||
const maxX = Math.max(x1, x2);
|
||||
const minY = Math.min(y1, y2);
|
||||
const maxY = Math.max(y1, y2);
|
||||
|
||||
const result: SimulationNode[] = [];
|
||||
|
||||
for (const node of this.nodes.values()) {
|
||||
if (node.x === undefined || node.y === undefined) continue;
|
||||
|
||||
if (node.x >= minX && node.x <= maxX &&
|
||||
node.y >= minY && node.y <= maxY) {
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points
|
||||
*/
|
||||
export function distance(x1: number, y1: number, x2: number, y2: number): number {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert screen coordinates to graph coordinates
|
||||
*/
|
||||
export function screenToGraph(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
transform: { x: number; y: number; k: number }
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (screenX - transform.x) / transform.k,
|
||||
y: (screenY - transform.y) / transform.k
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert graph coordinates to screen coordinates
|
||||
*/
|
||||
export function graphToScreen(
|
||||
graphX: number,
|
||||
graphY: number,
|
||||
transform: { x: number; y: number; k: number }
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: graphX * transform.k + transform.x,
|
||||
y: graphY * transform.k + transform.y
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an arrow on canvas at the end of a line
|
||||
*/
|
||||
export function drawArrow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
toX: number,
|
||||
toY: number,
|
||||
nodeRadius: number,
|
||||
arrowSize: number = 8
|
||||
): void {
|
||||
// Calculate angle
|
||||
const angle = Math.atan2(toY - fromY, toX - fromX);
|
||||
|
||||
// Calculate arrow position (at edge of target node)
|
||||
const arrowX = toX - Math.cos(angle) * nodeRadius;
|
||||
const arrowY = toY - Math.sin(angle) * nodeRadius;
|
||||
|
||||
// Draw arrow triangle
|
||||
ctx.save();
|
||||
ctx.translate(arrowX, arrowY);
|
||||
ctx.rotate(angle);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(-arrowSize, -arrowSize / 2);
|
||||
ctx.lineTo(-arrowSize, arrowSize / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate text width with given font
|
||||
*/
|
||||
export function measureText(ctx: CanvasRenderingContext2D, text: string, font: string): number {
|
||||
ctx.save();
|
||||
ctx.font = font;
|
||||
const width = ctx.measureText(text).width;
|
||||
ctx.restore();
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return function(...args: Parameters<T>) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle: boolean = false;
|
||||
|
||||
return function(...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Unit tests for group query parser (Jasmine)
|
||||
*/
|
||||
|
||||
import { parseGroupQuery, nodeMatchesQuery, isValidQuery } from './group-query.parser';
|
||||
import { GraphNode } from './graph-data.types';
|
||||
|
||||
describe('GroupQueryParser', () => {
|
||||
describe('parseGroupQuery', () => {
|
||||
it('should parse tag query', () => {
|
||||
const result = parseGroupQuery('tag:#markdown');
|
||||
expect(result).toEqual({ type: 'tag', value: '#markdown', raw: 'tag:#markdown' });
|
||||
});
|
||||
|
||||
it('should parse tag query without hash', () => {
|
||||
const result = parseGroupQuery('tag:test');
|
||||
expect(result).toEqual({ type: 'tag', value: 'test', raw: 'tag:test' });
|
||||
});
|
||||
|
||||
it('should parse file query', () => {
|
||||
const result = parseGroupQuery('file:test');
|
||||
expect(result).toEqual({ type: 'file', value: 'test', raw: 'file:test' });
|
||||
});
|
||||
|
||||
it('should parse path query', () => {
|
||||
const result = parseGroupQuery('path:folder/subfolder');
|
||||
expect(result).toEqual({ type: 'path', value: 'folder/subfolder', raw: 'path:folder/subfolder' });
|
||||
});
|
||||
|
||||
it('should handle case insensitive type', () => {
|
||||
const result = parseGroupQuery('TAG:#test');
|
||||
expect(result?.type).toBe('tag');
|
||||
});
|
||||
|
||||
it('should return null for empty/invalid queries', () => {
|
||||
expect(parseGroupQuery('')).toBeNull();
|
||||
expect(parseGroupQuery(' ')).toBeNull();
|
||||
expect(parseGroupQuery('invalid')).toBeNull();
|
||||
expect(parseGroupQuery('tag:')).toBeNull();
|
||||
expect(parseGroupQuery(':value')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeMatchesQuery', () => {
|
||||
const createNode = (overrides?: Partial<GraphNode>): GraphNode => ({
|
||||
id: 'test',
|
||||
title: 'Test Note',
|
||||
path: 'folder/test.md',
|
||||
tags: ['markdown', 'test'],
|
||||
hasAttachment: false,
|
||||
exists: true,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('tag queries', () => {
|
||||
it('should match node with tag', () => {
|
||||
const node = createNode({ tags: ['markdown', 'note'] });
|
||||
const query = parseGroupQuery('tag:#markdown')!;
|
||||
expect(nodeMatchesQuery(node, query)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should match tag case-insensitively', () => {
|
||||
const node = createNode({ tags: ['Markdown', 'Test'] });
|
||||
const query = parseGroupQuery('tag:#markdown')!;
|
||||
expect(nodeMatchesQuery(node, query)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should handle tags with or without hash', () => {
|
||||
const node = createNode({ tags: ['#markdown'] });
|
||||
const query1 = parseGroupQuery('tag:#markdown')!;
|
||||
const query2 = parseGroupQuery('tag:markdown')!;
|
||||
expect(nodeMatchesQuery(node, query1)).toBeTrue();
|
||||
expect(nodeMatchesQuery(node, query2)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should not match node without tag', () => {
|
||||
const node = createNode({ tags: ['other'] });
|
||||
const query = parseGroupQuery('tag:#markdown')!;
|
||||
expect(nodeMatchesQuery(node, query)).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file queries', () => {
|
||||
it('should match by title and path', () => {
|
||||
const node1 = createNode({ title: 'My Test Note' });
|
||||
const node2 = createNode({ path: 'testing/note.md' });
|
||||
const query = parseGroupQuery('file:test')!;
|
||||
expect(nodeMatchesQuery(node1, query)).toBeTrue();
|
||||
expect(nodeMatchesQuery(node2, query)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should match case-insensitively', () => {
|
||||
const node = createNode({ title: 'TEST' });
|
||||
const query = parseGroupQuery('file:test')!;
|
||||
expect(nodeMatchesQuery(node, query)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should not match when text not found', () => {
|
||||
const node = createNode({ title: 'Note', path: 'folder/note.md' });
|
||||
const query = parseGroupQuery('file:xyz')!;
|
||||
expect(nodeMatchesQuery(node, query)).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('path queries', () => {
|
||||
it('should match folder path and exact path', () => {
|
||||
const node1 = createNode({ path: 'folder/subfolder/note.md' });
|
||||
const node2 = createNode({ path: 'folder/subfolder/note.md' });
|
||||
const q1 = parseGroupQuery('path:folder')!;
|
||||
const q2 = parseGroupQuery('path:folder/subfolder')!;
|
||||
expect(nodeMatchesQuery(node1, q1)).toBeTrue();
|
||||
expect(nodeMatchesQuery(node2, q2)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should match with trailing slash', () => {
|
||||
const node = createNode({ path: 'folder/note.md' });
|
||||
const query = parseGroupQuery('path:folder/')!;
|
||||
expect(nodeMatchesQuery(node, query)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should respect case-insensitivity and not partially match', () => {
|
||||
const node1 = createNode({ path: 'Folder/note.md' });
|
||||
const node2 = createNode({ path: 'foldertest/note.md' });
|
||||
const q = parseGroupQuery('path:folder')!;
|
||||
expect(nodeMatchesQuery(node1, q)).toBeTrue();
|
||||
expect(nodeMatchesQuery(node2, q)).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidQuery', () => {
|
||||
it('should validate and reject queries correctly', () => {
|
||||
expect(isValidQuery('tag:#test')).toBeTrue();
|
||||
expect(isValidQuery('file:test')).toBeTrue();
|
||||
expect(isValidQuery('path:folder')).toBeTrue();
|
||||
expect(isValidQuery('')).toBeFalse();
|
||||
expect(isValidQuery('invalid')).toBeFalse();
|
||||
expect(isValidQuery('tag:')).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Parser for graph group queries
|
||||
* Supports: tag:#foo, file:test, path:folder
|
||||
*/
|
||||
|
||||
import { GraphNode } from './graph-data.types';
|
||||
|
||||
export type QueryType = 'tag' | 'file' | 'path';
|
||||
|
||||
export interface ParsedQuery {
|
||||
type: QueryType;
|
||||
value: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a group query string
|
||||
* Examples:
|
||||
* "tag:#markdown" -> { type: 'tag', value: '#markdown' }
|
||||
* "file:test" -> { type: 'file', value: 'test' }
|
||||
* "path:folder" -> { type: 'path', value: 'folder' }
|
||||
*/
|
||||
export function parseGroupQuery(query: string): ParsedQuery | null {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Match pattern: type:value
|
||||
const match = trimmed.match(/^(tag|file|path):(.+)$/i);
|
||||
if (!match) return null;
|
||||
|
||||
const type = match[1].toLowerCase() as QueryType;
|
||||
const value = match[2].trim();
|
||||
|
||||
if (!value) return null;
|
||||
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
raw: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node matches a parsed query
|
||||
*/
|
||||
export function nodeMatchesQuery(node: GraphNode, query: ParsedQuery): boolean {
|
||||
switch (query.type) {
|
||||
case 'tag':
|
||||
// Match if node has the specified tag
|
||||
const tagToMatch = query.value.startsWith('#')
|
||||
? query.value.substring(1)
|
||||
: query.value;
|
||||
return node.tags.some(tag => {
|
||||
const nodeTag = tag.startsWith('#') ? tag.substring(1) : tag;
|
||||
return nodeTag.toLowerCase() === tagToMatch.toLowerCase();
|
||||
});
|
||||
|
||||
case 'file':
|
||||
// Match if title or path contains the value (case-insensitive)
|
||||
const searchLower = query.value.toLowerCase();
|
||||
return node.title.toLowerCase().includes(searchLower) ||
|
||||
node.path.toLowerCase().includes(searchLower);
|
||||
|
||||
case 'path':
|
||||
// Match if path starts with the specified folder
|
||||
const folderPath = query.value.endsWith('/')
|
||||
? query.value
|
||||
: query.value + '/';
|
||||
return node.path.toLowerCase().startsWith(folderPath.toLowerCase()) ||
|
||||
node.path.toLowerCase() === query.value.toLowerCase();
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a query string
|
||||
*/
|
||||
export function isValidQuery(query: string): boolean {
|
||||
return parseGroupQuery(query) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions for query completion
|
||||
*/
|
||||
export function getQuerySuggestions(partial: string): string[] {
|
||||
const lower = partial.toLowerCase();
|
||||
|
||||
if (!partial.includes(':')) {
|
||||
// Suggest query types
|
||||
const types = ['tag:', 'file:', 'path:'];
|
||||
return types.filter(t => t.startsWith(lower));
|
||||
}
|
||||
|
||||
// Return empty for now - could be enhanced with tag/path suggestions
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Graph Module - Public API
|
||||
* Complete graph visualization with Canvas rendering and settings
|
||||
*/
|
||||
|
||||
// Core types
|
||||
export * from './graph-data.types';
|
||||
export * from './graph-settings.types';
|
||||
|
||||
// Services and stores
|
||||
export { GraphSettingsService } from './graph-settings.service';
|
||||
export { GraphSettingsStore } from './graph-settings.store';
|
||||
|
||||
// Components
|
||||
export { GraphCanvasComponent } from './graph-canvas.component';
|
||||
export { GraphLegendComponent } from './graph-legend.component';
|
||||
|
||||
// Utilities
|
||||
export * from './graph.utils';
|
||||
export * from './group-query.parser';
|
||||
|
||||
// Selectors
|
||||
export * from './graph.selectors';
|
||||
|
||||
// Runtime adapter (for backward compatibility)
|
||||
export { GraphRuntimeAdapter } from './graph-runtime-adapter';
|
||||
|
||||
// UI Components (re-exported for convenience)
|
||||
export { GraphSettingsPanelComponent } from './ui/settings-panel.component';
|
||||
export { GraphFiltersSectionComponent } from './ui/sections/filters-section.component';
|
||||
export { GraphGroupsSectionComponent } from './ui/sections/groups-section.component';
|
||||
export { GraphDisplaySectionComponent } from './ui/sections/display-section.component';
|
||||
export { GraphForcesSectionComponent } from './ui/sections/forces-section.component';
|
||||
@@ -68,9 +68,15 @@ import { GraphConfig } from '../../graph-settings.types';
|
||||
export class GraphFiltersSectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
private searchDebounceId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onSearchChange(value: string): void {
|
||||
this.configChange.emit({ search: value });
|
||||
if (this.searchDebounceId) {
|
||||
clearTimeout(this.searchDebounceId);
|
||||
}
|
||||
this.searchDebounceId = setTimeout(() => {
|
||||
this.configChange.emit({ search: value });
|
||||
}, 250);
|
||||
}
|
||||
|
||||
onToggleChange(key: keyof GraphConfig, event: Event): void {
|
||||
|
||||
@@ -116,10 +116,16 @@ export class GraphForcesSectionComponent {
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
|
||||
bounds = GRAPH_CONFIG_BOUNDS;
|
||||
private sliderDebounceId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onSliderChange(key: keyof GraphConfig, event: Event): void {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||
this.configChange.emit({ [key]: value } as Partial<GraphConfig>);
|
||||
if (this.sliderDebounceId) {
|
||||
clearTimeout(this.sliderDebounceId);
|
||||
}
|
||||
this.sliderDebounceId = setTimeout(() => {
|
||||
this.configChange.emit({ [key]: value } as Partial<GraphConfig>);
|
||||
}, 75);
|
||||
}
|
||||
|
||||
formatNumber(value: number, decimals: number): string {
|
||||
|
||||
Reference in New Issue
Block a user