import type { GraphNode, GraphLink } from "./types"; // ============================================================================ // Timeline event types // ============================================================================ export type TimelineEventType = "node-created" | "node-closed"; export interface TimelineEvent { time: number; // unix ms type: TimelineEventType; id: string; // node ID or "source->target" } export interface TimelineRange { events: TimelineEvent[]; minTime: number; // earliest event (unix ms) maxTime: number; // latest event (unix ms) } // ============================================================================ // Event extraction // ============================================================================ /** * Extract all temporal events from nodes and links, sorted chronologically. * * Events: * - node-created: from node.createdAt * - node-closed: from node.closedAt (if present) * - link-created: from link.createdAt (if present) * * Nodes/links missing timestamps are skipped. */ export function buildTimelineEvents( nodes: GraphNode[], links: GraphLink[] ): TimelineRange { const events: TimelineEvent[] = []; for (const node of nodes) { const createdMs = new Date(node.createdAt).getTime(); if (!isNaN(createdMs)) { events.push({ time: createdMs, type: "node-created", id: node.id }); } if (node.closedAt) { const closedMs = new Date(node.closedAt).getTime(); if (!isNaN(closedMs)) { events.push({ time: closedMs, type: "node-closed", id: node.id }); } } } events.sort((a, b) => a.time - b.time); const minTime = events.length > 0 ? events[0].time : Date.now(); const maxTime = events.length > 0 ? events[events.length - 1].time : Date.now(); return { events, minTime, maxTime }; } // ============================================================================ // Time filtering // ============================================================================ /** * Filter nodes and links to only include items visible at `currentTime`. * * Node visibility: createdAt <= currentTime. * Node status override: if closedAt <= currentTime, force status to "closed". * Link visibility: both endpoints visible AND link.createdAt <= currentTime. * If link has no createdAt, it appears when both endpoints are visible. * * Returns shallow copies when status is overridden, original objects otherwise * (preserves x/y positions from force simulation). */ export function filterDataAtTime( allNodes: GraphNode[], allLinks: GraphLink[], currentTime: number ): { nodes: GraphNode[]; links: GraphLink[] } { const visibleNodeIds = new Set(); const nodes: GraphNode[] = []; for (const node of allNodes) { const createdMs = new Date(node.createdAt).getTime(); if (isNaN(createdMs) || createdMs > currentTime) continue; visibleNodeIds.add(node.id); // Determine correct status at this point in time let status = node.status; if (node.closedAt) { const closedMs = new Date(node.closedAt).getTime(); if (!isNaN(closedMs) && closedMs <= currentTime) { status = "closed"; } else if (node.status === "closed") { // Node is closed in current data but we're before closedAt — show as open status = "open"; } } if (status !== node.status) { nodes.push({ ...node, status } as GraphNode); } else { nodes.push(node); } } // Filter visible links const links: GraphLink[] = []; for (const link of allLinks) { const src = typeof link.source === "object" ? (link.source as { id: string }).id : link.source; const tgt = typeof link.target === "object" ? (link.target as { id: string }).id : link.target; // Both endpoints must be visible — link appears when both nodes are on canvas if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue; // Normalize source/target to string IDs — d3-force mutates link objects // in-place replacing strings with object refs to the main graph's nodes. // We must return fresh objects with string IDs so ForceGraph2D resolves // them against the timeline's node array, not the main graph's. links.push({ ...link, source: src, target: tgt, }); } return { nodes, links }; }