96 SUCCESS tests complet

This commit is contained in:
2025-10-03 14:24:16 -04:00
parent 2dfe827759
commit 77ced60d7c
155 changed files with 11107 additions and 207392 deletions
+680
View File
@@ -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();
}
}
}
+63
View File
@@ -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;
}
+51
View File
@@ -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);
});
});
+339
View File
@@ -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;
}
}
}
+209
View File
@@ -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();
});
});
});
+252
View File
@@ -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
};
});
}
+216
View File
@@ -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);
}
};
}
+141
View File
@@ -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();
});
});
});
+98
View File
@@ -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 [];
}
+33
View File
@@ -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 {