ObsiViewer/src/app/graph/graph.selectors.ts

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
};
});
}