/** * 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, DEFAULT_TAG_COLOR, DEFAULT_ATTACHMENT_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)) ); } /** * Helpers to detect synthetic node kinds we generate here */ function isTagNode(node: GraphNode): boolean { return node.id.startsWith('tag:'); } function isAttachmentNode(node: GraphNode): boolean { return node.id.startsWith('att:'); } /** * Augment base graph with tag nodes and links (note -> tag) */ function addTagNodesAndLinks(data: { nodes: GraphNode[]; links: GraphLink[] }): { nodes: GraphNode[]; links: GraphLink[] } { const nodes = [...data.nodes]; const links = [...data.links]; const seenTags = new Set(); for (const n of data.nodes) { for (const rawTag of n.tags || []) { const tag = rawTag.startsWith('#') ? rawTag : `#${rawTag}`; const tagId = `tag:${tag.toLowerCase()}`; if (!seenTags.has(tagId)) { seenTags.add(tagId); nodes.push({ id: tagId, title: tag, path: '', tags: [], hasAttachment: false, exists: true }); } links.push({ source: n.id, target: tagId, directed: false }); } } return { nodes, links }; } /** * Extract attachment references from note titles/paths in wikilinks and add nodes (note -> attachment) * We look for wikilinks containing a filename with a known attachment extension. */ function addAttachmentNodesAndLinks( data: { nodes: GraphNode[]; links: GraphLink[] }, allNotesContent?: Map ): { nodes: GraphNode[]; links: GraphLink[] } { // We rely on note titles/content being available through allNotesContent when provided by the caller. // If not provided, we still attempt a best-effort by parsing node.path/title (no-ops in most cases). const nodes = [...data.nodes]; const links = [...data.links]; const attachmentExt = /\.(pdf|png|jpe?g|gif|svg|webp|bmp|mp4|webm|ogv|mov|mkv|mp3|wav|ogg|m4a|flac|docx?|xlsx?|pptx?)$/i; const attId = (name: string) => `att:${name.toLowerCase()}`; const haveNode = new Set(nodes.map(n => n.id)); const addAttachment = (note: GraphNode, name: string) => { if (!attachmentExt.test(name)) return; const id = attId(name); if (!haveNode.has(id)) { haveNode.add(id); nodes.push({ id, title: name, path: '', tags: [], hasAttachment: true, exists: true }); } links.push({ source: note.id, target: id, directed: false }); }; // Parse per note for (const note of data.nodes) { // Only real notes (exclude synthetic ones) if (isTagNode(note) || isAttachmentNode(note)) continue; const content = allNotesContent?.get(note.id) || ''; if (!content) continue; // Match Obsidian wikilinks with optional embed prefix: ![[file.ext]] or [[file.ext]] const wikilink = /!?\[\[([^\]|\n]+)(?:\|[^\]]+)?\]\]/g; let m: RegExpExecArray | null; while ((m = wikilink.exec(content)) !== null) { const target = m[1].trim(); if (attachmentExt.test(target)) { addAttachment(note, target); } } } return { nodes, links }; } /** * 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 { const degrees = new Map(); // 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(); nodes.forEach(node => degrees.set(node.id, 0)); return nodes.map(node => { // Set default color based on node type let color = DEFAULT_NODE_COLOR; if (isTagNode(node)) { color = DEFAULT_TAG_COLOR; } else if (isAttachmentNode(node)) { color = DEFAULT_ATTACHMENT_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, config: Signal, noteContents?: Signal> ): Signal<{ nodes: GraphNodeWithVisuals[], links: GraphLink[] }> { return computed(() => { const data = rawData(); const cfg = config(); const contents = noteContents ? noteContents() : undefined; // Start from base data let workingNodes: GraphNode[] = data.nodes; let workingLinks: GraphLink[] = data.links; // If Tags are enabled, augment the dataset with tag nodes and links if (cfg.showTags) { const augmented = addTagNodesAndLinks({ nodes: workingNodes, links: workingLinks }); workingNodes = augmented.nodes; workingLinks = augmented.links; } // If Attachments are enabled, attempt to add attachment nodes/links by parsing note contents if (cfg.showAttachments) { // We do not have direct access to note contents here; however, base graph edges are built elsewhere from contents. // To keep selectors pure, we skip when content is unavailable. The container can provide contents later if needed. const augmented = addAttachmentNodesAndLinks({ nodes: workingNodes, links: workingLinks }, contents); workingNodes = augmented.nodes; workingLinks = augmented.links; } // 1. Text search on titles/paths/tags (applies to all nodes but tag/attachment nodes usually unaffected) let filteredNodes = filterBySearch(workingNodes, cfg.search); // 2-3. Show/Hide synthetic nodes according to toggles if (!cfg.showTags) { filteredNodes = filteredNodes.filter(n => !isTagNode(n)); } if (!cfg.showAttachments) { filteredNodes = filteredNodes.filter(n => !isAttachmentNode(n)); } // 4. Existing files only filteredNodes = filterByExistence(filteredNodes, cfg.hideUnresolved); // Prune broken links from current working set let filteredLinks = pruneBrokenLinks(filteredNodes, workingLinks); // 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, focusedGroupIndex: Signal ): Signal { return computed(() => { const data = filteredData(); const cfg = config(); const focused = focusedGroupIndex(); // Count nodes per group const groupCounts = new Map(); 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 ): 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 }; }); }