364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
/**
|
|
* 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<string>();
|
|
for (const n of data.nodes) {
|
|
for (const rawTag of n.tags || []) {
|
|
const tag = rawTag.startsWith('#') ? rawTag : `#${rawTag}`;
|
|
const tagId = `tag:${tag.toLowerCase()}`;
|
|
|
|
if (!seenTags.has(tagId)) {
|
|
seenTags.add(tagId);
|
|
nodes.push({
|
|
id: tagId,
|
|
title: tag,
|
|
path: '',
|
|
tags: [],
|
|
hasAttachment: false,
|
|
exists: true
|
|
});
|
|
}
|
|
|
|
links.push({ source: n.id, target: tagId, directed: false });
|
|
}
|
|
}
|
|
|
|
return { nodes, links };
|
|
}
|
|
|
|
/**
|
|
* Extract attachment references from note titles/paths in wikilinks and add nodes (note -> attachment)
|
|
* We look for wikilinks containing a filename with a known attachment extension.
|
|
*/
|
|
function addAttachmentNodesAndLinks(
|
|
data: { nodes: GraphNode[]; links: GraphLink[] },
|
|
allNotesContent?: Map<string, string>
|
|
): { nodes: GraphNode[]; links: GraphLink[] } {
|
|
// We rely on note titles/content being available through allNotesContent when provided by the caller.
|
|
// If not provided, we still attempt a best-effort by parsing node.path/title (no-ops in most cases).
|
|
const nodes = [...data.nodes];
|
|
const links = [...data.links];
|
|
|
|
const attachmentExt = /\.(pdf|png|jpe?g|gif|svg|webp|bmp|mp4|webm|ogv|mov|mkv|mp3|wav|ogg|m4a|flac|docx?|xlsx?|pptx?)$/i;
|
|
const attId = (name: string) => `att:${name.toLowerCase()}`;
|
|
const haveNode = new Set(nodes.map(n => n.id));
|
|
|
|
const addAttachment = (note: GraphNode, name: string) => {
|
|
if (!attachmentExt.test(name)) return;
|
|
const id = attId(name);
|
|
if (!haveNode.has(id)) {
|
|
haveNode.add(id);
|
|
nodes.push({
|
|
id,
|
|
title: name,
|
|
path: '',
|
|
tags: [],
|
|
hasAttachment: true,
|
|
exists: true
|
|
});
|
|
}
|
|
links.push({ source: note.id, target: id, directed: false });
|
|
};
|
|
|
|
// Parse per note
|
|
for (const note of data.nodes) {
|
|
// Only real notes (exclude synthetic ones)
|
|
if (isTagNode(note) || isAttachmentNode(note)) continue;
|
|
|
|
const content = allNotesContent?.get(note.id) || '';
|
|
if (!content) continue;
|
|
|
|
// Match Obsidian wikilinks with optional embed prefix: ![[file.ext]] or [[file.ext]]
|
|
const wikilink = /!?\[\[([^\]|\n]+)(?:\|[^\]]+)?\]\]/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = wikilink.exec(content)) !== null) {
|
|
const target = m[1].trim();
|
|
if (attachmentExt.test(target)) {
|
|
addAttachment(note, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { nodes, links };
|
|
}
|
|
|
|
/**
|
|
* 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 => {
|
|
// 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<GraphData>,
|
|
config: Signal<GraphConfig>,
|
|
noteContents?: Signal<Map<string, string>>
|
|
): 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<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
|
|
};
|
|
});
|
|
}
|