diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 279f30c8..16c6f18f 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -4262,23 +4262,27 @@ class MemoryEngine(MemoryEngineInterface): source_memory_ids.extend(unit["source_memory_ids"]) source_memory_ids = list(set(source_memory_ids)) # Deduplicate - # Fetch links involving both visible units AND source memories + # Fetch links where BOTH endpoints are in the visible set (or source memories) + # Cap at 10k edges — the UI can't usefully render more, and uncapped queries + # on highly-connected graphs (e.g. 1000 nodes with 500k+ edges) are too slow. + max_edges = 10000 all_relevant_ids = unit_ids + source_memory_ids if all_relevant_ids: links = await conn.fetch( f""" - SELECT DISTINCT ON (LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) - ml.from_unit_id, - ml.to_unit_id, - ml.link_type, - ml.weight, - e.canonical_name as entity_name + SELECT ml.from_unit_id, + ml.to_unit_id, + ml.link_type, + ml.weight, + e.canonical_name as entity_name FROM {fq_table("memory_links")} ml LEFT JOIN {fq_table("entities")} e ON ml.entity_id = e.id - WHERE ml.from_unit_id = ANY($1::uuid[]) OR ml.to_unit_id = ANY($1::uuid[]) - ORDER BY LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid), ml.weight DESC + WHERE ml.from_unit_id = ANY($1::uuid[]) AND ml.to_unit_id = ANY($1::uuid[]) + ORDER BY ml.weight DESC NULLS LAST + LIMIT $2 """, all_relevant_ids, + max_edges, ) else: links = [] @@ -4339,13 +4343,17 @@ class MemoryEngine(MemoryEngineInterface): link for link in links if link["from_unit_id"] in unit_id_set and link["to_unit_id"] in unit_id_set ] - # Get entity information - unit_entities = await conn.fetch(f""" - SELECT ue.unit_id, e.canonical_name - FROM {fq_table("unit_entities")} ue - JOIN {fq_table("entities")} e ON ue.entity_id = e.id - ORDER BY ue.unit_id - """) + # Get entity information — only for visible units + if unit_ids: + unit_entities = await conn.fetch(f""" + SELECT ue.unit_id, e.canonical_name + FROM {fq_table("unit_entities")} ue + JOIN {fq_table("entities")} e ON ue.entity_id = e.id + WHERE ue.unit_id = ANY($1::uuid[]) + ORDER BY ue.unit_id + """, unit_ids) + else: + unit_entities = [] # Build entity mapping entity_map = {} diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json index c193d7e4..2b4af482 100644 --- a/hindsight-control-plane/package.json +++ b/hindsight-control-plane/package.json @@ -27,6 +27,7 @@ "author": "Hindsight Team", "license": "ISC", "dependencies": { + "@chenglou/pretext": "^0.0.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", diff --git a/hindsight-control-plane/src/components/constellation.tsx b/hindsight-control-plane/src/components/constellation.tsx new file mode 100644 index 00000000..81226779 --- /dev/null +++ b/hindsight-control-plane/src/components/constellation.tsx @@ -0,0 +1,958 @@ +"use client"; + +import { useRef, useEffect, useCallback, useMemo, useState } from "react"; +import { prepare, layout, prepareWithSegments, layoutWithLines } from "@chenglou/pretext"; +import type { GraphData, GraphNode, GraphLink } from "./graph-2d"; + +// ============================================================================ +// Types +// ============================================================================ + +interface PreparedNode { + node: GraphNode; + /** screen x (world coords, before pan/zoom) */ + wx: number; + wy: number; + prepared: ReturnType; + preparedHeight: ReturnType; + color: string; + /** Color derived from link count (heat gradient) */ + heatColor: string; + linkCount: number; +} + +interface ScreenNode { + idx: number; + sx: number; + sy: number; +} + +// ============================================================================ +// Props +// ============================================================================ + +export interface ConstellationProps { + data: GraphData; + height?: number; + onNodeClick?: (node: GraphNode) => void; + nodeColorFn?: (node: GraphNode) => string; + linkColorFn?: (link: GraphLink) => string; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function easeOutCubic(t: number): number { + return 1 - Math.pow(1 - t, 3); +} + +function hexToRgba(hex: string, alpha: number): string { + // Handle non-hex formats + if (!hex.startsWith("#")) return hex; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r},${g},${b},${alpha})`; +} + +/** + * Map a 0..1 value to a Hindsight brand gradient. + * 0 = few links (teal #009296), 1 = hub (blue #0074d9). + * Passes through a brighter midpoint for contrast. + */ +function heatColor(t: number): string { + const v = Math.max(0, Math.min(1, t)); + + // Brighter, more luminous stops — like stars in a night sky + // teal glow → bright cyan → white-blue + const stops = [ + [80, 200, 205], // bright teal + [100, 210, 255], // bright cyan + [140, 180, 255], // light blue-white + ]; + const seg = v * (stops.length - 1); + const i = Math.min(Math.floor(seg), stops.length - 2); + const frac = seg - i; + const a = stops[i]; + const b = stops[i + 1]; + const r = Math.round(a[0] + (b[0] - a[0]) * frac); + const g = Math.round(a[1] + (b[1] - a[1]) * frac); + const bl = Math.round(a[2] + (b[2] - a[2]) * frac); + return `rgb(${r},${g},${bl})`; +} + +function hashStr(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; + return h; +} + +// Dark mode hook +function useIsDarkMode() { + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const check = () => setIsDark(document.documentElement.classList.contains("dark")); + check(); + const obs = new MutationObserver(check); + obs.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => obs.disconnect(); + }, []); + + return isDark; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const FONT = '12px Inter, -apple-system, "Segoe UI", sans-serif'; +const FONT_SMALL = '11px Inter, -apple-system, "Segoe UI", sans-serif'; +const FONT_BOLD = '600 10px Inter, -apple-system, "Segoe UI", sans-serif'; +const MONO = '11px "SF Mono", "Fira Code", Consolas, monospace'; + +const LINK_TYPE_COLORS: Record = { + semantic: "#0074d9", + temporal: "#009296", + entity: "#f59e0b", + causal: "#8b5cf6", +}; + +const DEFAULT_NODE_COLOR = "#0074d9"; + +// ============================================================================ +// Component +// ============================================================================ + +export function Constellation({ + data, + height = 700, + onNodeClick, + nodeColorFn, + linkColorFn, +}: ConstellationProps) { + const wrapperRef = useRef(null); + const canvasRef = useRef(null); + const tooltipRef = useRef(null); + const isDark = useIsDarkMode(); + const animRef = useRef(0); + const [isFullscreen, setIsFullscreen] = useState(false); + + // Interaction state stored in ref for perf (avoid re-renders on every frame) + const stateRef = useRef({ + panX: 0, + panY: 0, + zoom: 0.5, + targetPanX: 0, + targetPanY: 0, + targetZoom: 0.5, + mouseX: -1, + mouseY: -1, + isDragging: false, + dragStartX: 0, + dragStartY: 0, + panStartX: 0, + panStartY: 0, + hoverIndex: -1, + prevHoverIndex: -2, // track changes to avoid DOM thrashing + W: 0, + H: 0, + dpr: 1, + }); + + // ----- Prepare data with Pretext ----- + const { preparedNodes, linksByNode, linksWithIndices } = useMemo(() => { + if (!data.nodes.length) + return { preparedNodes: [], linksByNode: new Map(), linksWithIndices: [] }; + + const nodeIndexMap = new Map(); + data.nodes.forEach((n, i) => nodeIndexMap.set(n.id, i)); + + // Count links per node + const linkCounts = new Map(); + for (const link of data.links) { + linkCounts.set(link.source, (linkCounts.get(link.source) || 0) + 1); + linkCounts.set(link.target, (linkCounts.get(link.target) || 0) + 1); + } + + // Find max link count for heat gradient normalization + let maxLinkCount = 1; + for (const lc of linkCounts.values()) { + if (lc > maxLinkCount) maxLinkCount = lc; + } + + // Prepare nodes: assign world positions + pretext-prepare text + const nodes: PreparedNode[] = data.nodes.map((node, i) => { + const text = node.label || node.id.substring(0, 12); + const color = nodeColorFn?.(node) || node.color || DEFAULT_NODE_COLOR; + const lc = linkCounts.get(node.id) || 0; + // Use sqrt for a less aggressive curve — avoids everything being red + const heat = heatColor(Math.sqrt(lc / maxLinkCount)); + + // Position: use hash of id for deterministic placement, spread in a ring + const seed = hashStr(node.id); + const count = data.nodes.length; + const angle = (i / count) * Math.PI * 2 + ((seed % 100) / 100) * 0.5; + const baseRadius = Math.sqrt(count) * 30; + const radius = baseRadius * 0.3 + ((Math.abs(seed) % 1000) / 1000) * baseRadius * 0.7; + const wx = Math.cos(angle) * radius + ((seed % 200) - 100) * 0.5; + const wy = Math.sin(angle) * radius + (((seed >> 8) % 200) - 100) * 0.5; + + return { + node, + wx, + wy, + prepared: prepareWithSegments(text, FONT_SMALL), + preparedHeight: prepare(text, FONT_SMALL), + color, + heatColor: heat, + linkCount: lc, + }; + }); + + // Build link structures + const linksIdx: Array<{ + a: number; + b: number; + color: string; + type: string; + }> = []; + const byNode = new Map(); + + for (let li = 0; li < data.links.length; li++) { + const link = data.links[li]; + const ai = nodeIndexMap.get(link.source); + const bi = nodeIndexMap.get(link.target); + if (ai === undefined || bi === undefined) continue; + + const color = + linkColorFn?.(link) || + link.color || + LINK_TYPE_COLORS[link.type || "semantic"] || + DEFAULT_NODE_COLOR; + + const idx = linksIdx.length; + linksIdx.push({ a: ai, b: bi, color, type: link.type || "semantic" }); + + if (!byNode.has(ai)) byNode.set(ai, []); + if (!byNode.has(bi)) byNode.set(bi, []); + byNode.get(ai)!.push(idx); + byNode.get(bi)!.push(idx); + } + + return { + preparedNodes: nodes, + linksByNode: byNode, + linksWithIndices: linksIdx, + }; + }, [data, nodeColorFn, linkColorFn]); + + // ----- Animation loop ----- + const animate = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const s = stateRef.current; + + // Smooth interpolation + s.panX = lerp(s.panX, s.targetPanX, 0.12); + s.panY = lerp(s.panY, s.targetPanY, 0.12); + s.zoom = lerp(s.zoom, s.targetZoom, 0.12); + + const { W, H, dpr, zoom, panX, panY, mouseX, mouseY, hoverIndex } = s; + const cx = W / 2 + panX; + const cy = H / 2 + panY; + const margin = 60; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.scale(dpr, dpr); + + const bg = isDark ? "#09090b" : "#ffffff"; + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + + // Screen positions + const screenX = new Float32Array(preparedNodes.length); + const screenY = new Float32Array(preparedNodes.length); + const visible = new Uint8Array(preparedNodes.length); + + for (let i = 0; i < preparedNodes.length; i++) { + const n = preparedNodes[i]; + const sx = cx + n.wx * zoom; + const sy = cy + n.wy * zoom; + screenX[i] = sx; + screenY[i] = sy; + visible[i] = sx > -margin && sx < W + margin && sy > -margin && sy < H + margin ? 1 : 0; + } + + // Hit test for hover + if (mouseX >= 0 && !s.isDragging) { + let bestDist = zoom > 1.5 ? 80 : 30; + let bestIdx = -1; + for (let i = 0; i < preparedNodes.length; i++) { + if (!visible[i]) continue; + const dx = mouseX - screenX[i]; + const dy = mouseY - screenY[i]; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < bestDist) { + bestDist = dist; + bestIdx = i; + } + } + s.hoverIndex = bestIdx; + } + + const hoveredLinks = new Set(); + if (hoverIndex >= 0) { + const myLinks = linksByNode.get(hoverIndex) || []; + for (const li of myLinks) hoveredLinks.add(li); + } + + // --- Draw links --- + const maxLinks = 6000; + let linksDrawn = 0; + + if (hoverIndex >= 0) { + // Only draw hovered node's links + for (const li of hoveredLinks) { + const link = linksWithIndices[li]; + const ax = screenX[link.a]; + const ay = screenY[link.a]; + const bx = screenX[link.b]; + const by = screenY[link.b]; + + ctx.strokeStyle = link.color; + ctx.globalAlpha = 0.5; + ctx.lineWidth = 1.5; + + const midX = (ax + bx) / 2 + (by - ay) * 0.08; + const midY = (ay + by) / 2 - (bx - ax) * 0.08; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.quadraticCurveTo(midX, midY, bx, by); + ctx.stroke(); + linksDrawn++; + } + ctx.globalAlpha = 1; + } else { + const baseAlpha = 0.06 + Math.min(zoom * 0.04, 0.1); + ctx.lineWidth = 0.4; + + for (const link of linksWithIndices) { + if (linksDrawn >= maxLinks) break; + const ax = screenX[link.a]; + const ay = screenY[link.a]; + const bx = screenX[link.b]; + const by = screenY[link.b]; + + if ( + (ax < -margin && bx < -margin) || + (ax > W + margin && bx > W + margin) || + (ay < -margin && by < -margin) || + (ay > H + margin && by > H + margin) + ) + continue; + + ctx.strokeStyle = link.color; + ctx.globalAlpha = baseAlpha; + + const midX = (ax + bx) / 2 + (by - ay) * 0.08; + const midY = (ay + by) / 2 - (bx - ax) * 0.08; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.quadraticCurveTo(midX, midY, bx, by); + ctx.stroke(); + linksDrawn++; + } + ctx.globalAlpha = 1; + } + + // --- Draw nodes --- + // Label deconfliction grid + const GRID_CELL = 90; + const labelGrid = new Set(); + + function canPlace(lx: number, ly: number, lw: number, lh: number) { + const c0 = Math.floor(lx / GRID_CELL); + const c1 = Math.floor((lx + lw) / GRID_CELL); + const r0 = Math.floor(ly / GRID_CELL); + const r1 = Math.floor((ly + lh) / GRID_CELL); + for (let c = c0; c <= c1; c++) + for (let r = r0; r <= r1; r++) if (labelGrid.has(`${c},${r}`)) return false; + return true; + } + + function claim(lx: number, ly: number, lw: number, lh: number) { + const c0 = Math.floor(lx / GRID_CELL); + const c1 = Math.floor((lx + lw) / GRID_CELL); + const r0 = Math.floor(ly / GRID_CELL); + const r1 = Math.floor((ly + lh) / GRID_CELL); + for (let c = c0; c <= c1; c++) for (let r = r0; r <= r1; r++) labelGrid.add(`${c},${r}`); + } + + let labelsShown = 0; + let visibleCount = 0; + + // Draw hovered last (on top) + const order: number[] = []; + for (let i = 0; i < preparedNodes.length; i++) { + if (!visible[i]) continue; + if (i === hoverIndex) continue; + order.push(i); + } + if (hoverIndex >= 0 && visible[hoverIndex]) order.push(hoverIndex); + + for (const i of order) { + const n = preparedNodes[i]; + const sx = screenX[i]; + const sy = screenY[i]; + visibleCount++; + + const isHovered = i === hoverIndex; + const isNeighbor = + hoveredLinks.size > 0 && + (linksByNode.get(i) || []).some((li: number) => hoveredLinks.has(li)); + + // Size varies slightly by link count — subtle range like star magnitudes + const baseR = 2.5 + Math.min(n.linkCount * 0.15, 2.5); + const r = Math.max(1.5, baseR * Math.min(zoom, 2)); + + // Opacity varies — fewer links = dimmer, more links = brighter + const baseAlpha = 0.45 + Math.min(n.linkCount * 0.03, 0.5); + + // Dot — star-like: heat-gradient color, varied size & opacity + ctx.beginPath(); + ctx.arc(sx, sy, r, 0, Math.PI * 2); + ctx.fillStyle = n.heatColor; + ctx.globalAlpha = isHovered ? 1 : isNeighbor ? 0.95 : hoverIndex >= 0 ? 0.08 : baseAlpha; + + if (isHovered || isNeighbor) { + ctx.shadowColor = n.heatColor; + ctx.shadowBlur = isHovered ? 20 : 10; + } + ctx.fill(); + + // Soft glow halo for brighter stars (high link count) + if (n.linkCount > 3 && !isHovered && hoverIndex < 0) { + ctx.beginPath(); + ctx.arc(sx, sy, r * 2, 0, Math.PI * 2); + ctx.fillStyle = n.heatColor; + ctx.globalAlpha = 0.06 + Math.min(n.linkCount * 0.005, 0.08); + ctx.fill(); + } + + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + + // Label — always use deconfliction grid to prevent overlap + if (isHovered) { + // Hovered node always gets a label (skip deconfliction) + drawLabel(ctx, n, i, sx, sy, r, true, true, isDark, zoom); + labelsShown++; + } else if (zoom > 1.5) { + const cardW = Math.min(200, 80 + zoom * 25); + const cardH = 50; + const lx = sx - cardW / 2; + const ly = sy + r + 4; + if (canPlace(lx, ly, cardW, cardH)) { + claim(lx, ly, cardW, cardH); + drawLabel(ctx, n, i, sx, sy, r, false, isNeighbor, isDark, zoom); + labelsShown++; + } + } else if (zoom > 0.5 || isNeighbor) { + // Show inline labels with deconfliction — neighbors included but grid-checked + const lw = 155; + const lh = 16; + const lx = sx + r + 5; + const ly = sy - 8; + if (canPlace(lx, ly, lw, lh)) { + claim(lx, ly, lw, lh); + drawLabel(ctx, n, i, sx, sy, r, false, isNeighbor, isDark, zoom); + labelsShown++; + } + } + } + + // HUD + ctx.font = MONO; + ctx.fillStyle = isDark ? "#71717a" : "#71717a"; + ctx.textAlign = "left"; + ctx.fillText( + `${preparedNodes.length} memories · ${visibleCount} visible · ${labelsShown} labels · ${linksDrawn} links · zoom ${zoom.toFixed(2)}x`, + 12, + H - 12 + ); + + // Legend — link types + ctx.textAlign = "right"; + let legendX = W - 12; + ctx.font = FONT_BOLD; + for (const [type, color] of Object.entries(LINK_TYPE_COLORS).reverse()) { + const tw = ctx.measureText(type).width; + ctx.fillStyle = isDark ? "#a1a1aa" : "#52525b"; + ctx.fillText(type, legendX, H - 12); + legendX -= tw + 4; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(legendX, H - 15, 3, 0, Math.PI * 2); + ctx.fill(); + legendX -= 14; + } + + // Heat gradient legend (top-left, below instructions) + ctx.textAlign = "left"; + ctx.font = FONT_BOLD; + ctx.fillStyle = isDark ? "#a1a1aa" : "#52525b"; + ctx.fillText("LINKS", 12, 36); + const gradW = 80; + for (let gx = 0; gx < gradW; gx++) { + ctx.fillStyle = heatColor(gx / gradW); + ctx.fillRect(12 + gx, 42, 1, 6); + } + ctx.font = MONO; + ctx.fillStyle = isDark ? "#a1a1aa" : "#71717a"; + ctx.fillText("few", 12, 60); + ctx.textAlign = "right"; + ctx.fillText("many", 12 + gradW, 60); + + // Instructions + ctx.textAlign = "left"; + ctx.font = MONO; + ctx.fillStyle = isDark ? "#52525b" : "#a1a1aa"; + ctx.fillText("Scroll to zoom · Drag to pan · Hover to explore · Click to select", 12, 16); + + ctx.restore(); + + animRef.current = requestAnimationFrame(animate); + }, [isDark, preparedNodes, linksWithIndices, linksByNode]); + + // ----- Label drawing helper ----- + function drawLabel( + ctx: CanvasRenderingContext2D, + n: PreparedNode, + _i: number, + sx: number, + sy: number, + r: number, + isHovered: boolean, + force: boolean, + dark: boolean, + zoom: number + ) { + if (zoom > 1.5 || isHovered) { + // Card mode + const cardW = Math.min(220, 80 + zoom * 25); + const textW = cardW - 16; + const { lines } = layoutWithLines(n.prepared, textW, 15); + const maxLines = isHovered + ? Math.min(lines.length, 5) + : Math.min(lines.length, Math.floor(zoom)); + const cardH = 8 + maxLines * 15 + 8; + + const cardX = sx - cardW / 2; + const cardY = sy + r + 4; + + ctx.fillStyle = isHovered + ? dark + ? "#1c1c1e" + : "#f4f4f5" + : dark + ? "rgba(9,9,11,0.92)" + : "rgba(255,255,255,0.92)"; + ctx.beginPath(); + ctx.roundRect(cardX, cardY, cardW, cardH, 6); + ctx.fill(); + + ctx.strokeStyle = isHovered ? n.color : dark ? "rgba(63,63,70,0.5)" : "rgba(212,212,216,0.6)"; + ctx.lineWidth = isHovered ? 1.5 : 0.5; + ctx.beginPath(); + ctx.roundRect(cardX, cardY, cardW, cardH, 6); + ctx.stroke(); + + if (isHovered) { + ctx.shadowColor = n.color; + ctx.shadowBlur = 15; + ctx.beginPath(); + ctx.roundRect(cardX, cardY, cardW, cardH, 6); + ctx.stroke(); + ctx.shadowBlur = 0; + } + + ctx.font = FONT_SMALL; + ctx.fillStyle = isHovered ? (dark ? "#e4e4e7" : "#18181b") : dark ? "#71717a" : "#71717a"; + ctx.textAlign = "left"; + for (let j = 0; j < maxLines; j++) { + ctx.fillText(lines[j].text, cardX + 8, cardY + 8 + j * 15 + 11); + } + } else { + // Inline label + ctx.font = FONT_SMALL; + ctx.fillStyle = isHovered ? (dark ? "#e4e4e7" : "#18181b") : dark ? "#52525b" : "#a1a1aa"; + ctx.globalAlpha = isHovered ? 1 : force ? 0.85 : Math.min(1, (zoom - 0.3) * 2.5); + ctx.textAlign = "left"; + const text = n.node.label || n.node.id.substring(0, 12); + const label = text.length > 45 ? text.slice(0, 45) + "..." : text; + ctx.fillText(label, sx + r + 5, sy + 4); + ctx.globalAlpha = 1; + } + } + + // ----- Setup & resize ----- + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resize = () => { + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const W = rect.width; + const H = rect.height; + canvas.width = W * dpr; + canvas.height = H * dpr; + stateRef.current.dpr = dpr; + stateRef.current.W = W; + stateRef.current.H = H; + }; + + resize(); + + // Auto-fit zoom based on node spread + if (preparedNodes.length > 0) { + let maxR = 0; + for (const n of preparedNodes) { + const d = Math.sqrt(n.wx * n.wx + n.wy * n.wy); + if (d > maxR) maxR = d; + } + const fitZoom = + maxR > 0 ? Math.min(stateRef.current.W, stateRef.current.H) / (maxR * 2.5) : 0.5; + stateRef.current.zoom = fitZoom; + stateRef.current.targetZoom = fitZoom; + } + + animRef.current = requestAnimationFrame(animate); + + // Events + const handleResize = () => resize(); + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const factor = e.deltaY > 0 ? 0.9 : 1.1; + stateRef.current.targetZoom = Math.max( + 0.03, + Math.min(8, stateRef.current.targetZoom * factor) + ); + }; + + const updateTooltip = (mx: number, my: number) => { + const tip = tooltipRef.current; + if (!tip) return; + const s = stateRef.current; + const idx = s.hoverIndex; + + if (idx < 0 || s.isDragging) { + tip.style.display = "none"; + s.prevHoverIndex = -1; + return; + } + + const node = preparedNodes[idx]?.node; + if (!node) { + tip.style.display = "none"; + return; + } + + // Only rebuild innerHTML when the hovered node changes + if (idx !== s.prevHoverIndex) { + s.prevHoverIndex = idx; + const meta = node.metadata as Record | undefined; + const fullText = meta?.text || node.label || node.id; + const entities: string[] = meta?.entities + ? String(meta.entities).split(",").map((e: string) => e.trim()).filter(Boolean) + : []; + const nodeColor = preparedNodes[idx].heatColor; + const linkCount = preparedNodes[idx].linkCount; + const factType = meta?.fact_type || node.group || "memory"; + const context = meta?.context && meta.context !== "N/A" ? meta.context : null; + const tags: string[] = Array.isArray(meta?.tags) ? meta.tags : []; + const date = meta?.date && meta.date !== "N/A" ? meta.date : null; + const occurredStart = meta?.occurred_start || null; + const occurredEnd = meta?.occurred_end || null; + const createdAt = meta?.created_at || null; + const proofCount = meta?.proof_count || null; + const documentId = meta?.document_id || null; + + const muted = isDark ? "#a1a1aa" : "#71717a"; + const dimmed = isDark ? "#71717a" : "#a1a1aa"; + const labelStyle = `font-size:10px;color:${dimmed};text-transform:uppercase;letter-spacing:0.04em;font-weight:500`; + const valStyle = `font-size:12px;color:${isDark ? "#e4e4e7" : "#18181b"}`; + const rowStyle = `display:flex;justify-content:space-between;align-items:baseline;gap:12px`; + + // Type badge + link count + let html = `
`; + html += `${factType}`; + html += `${linkCount} link${linkCount !== 1 ? "s" : ""}`; + html += `
`; + + // Text + html += `
${fullText}
`; + + // Metadata grid + html += `
`; + + if (context) { + html += `
Context${context}
`; + } + + if (date) { + html += `
Date${date}
`; + } else if (occurredStart) { + const timeRange = occurredEnd && occurredEnd !== occurredStart + ? `${occurredStart.slice(0, 10)} → ${occurredEnd.slice(0, 10)}` + : occurredStart.slice(0, 10); + html += `
Occurred${timeRange}
`; + } + + if (createdAt) { + html += `
Created${createdAt.slice(0, 16).replace("T", " ")}
`; + } + + if (proofCount && proofCount > 1) { + html += `
Evidence${proofCount} sources
`; + } + + if (documentId) { + html += `
Document${String(documentId).slice(0, 12)}...
`; + } + + html += `
`; + + // Entities + if (entities.length > 0 && !(entities.length === 1 && entities[0] === "None")) { + html += `
`; + for (const ent of entities) { + html += `${ent}`; + } + html += `
`; + } + + // Tags + if (tags.length > 0) { + html += `
`; + for (const tag of tags) { + html += `#${tag}`; + } + html += `
`; + } + + // ID + html += `
${node.id}
`; + + tip.innerHTML = html; + } + + tip.style.display = "block"; + const tipW = tip.offsetWidth; + const tipH = tip.offsetHeight; + const tx = Math.min(mx + 16, s.W - tipW - 12); + const ty = Math.min(my + 16, s.H - tipH - 12); + tip.style.left = `${Math.max(4, tx)}px`; + tip.style.top = `${Math.max(4, ty)}px`; + }; + + const handleMouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + stateRef.current.mouseX = mx; + stateRef.current.mouseY = my; + + if (stateRef.current.isDragging) { + stateRef.current.targetPanX = + stateRef.current.panStartX + (e.clientX - stateRef.current.dragStartX); + stateRef.current.targetPanY = + stateRef.current.panStartY + (e.clientY - stateRef.current.dragStartY); + canvas.style.cursor = "grabbing"; + if (tooltipRef.current) tooltipRef.current.style.display = "none"; + } else { + canvas.style.cursor = stateRef.current.hoverIndex >= 0 ? "pointer" : "default"; + updateTooltip(mx, my); + } + }; + + const handleMouseDown = (e: MouseEvent) => { + stateRef.current.isDragging = true; + stateRef.current.dragStartX = e.clientX; + stateRef.current.dragStartY = e.clientY; + stateRef.current.panStartX = stateRef.current.panX; + stateRef.current.panStartY = stateRef.current.panY; + }; + + const handleMouseUp = () => { + // If we didn't drag far, treat as click + if (stateRef.current.isDragging) { + const dx = Math.abs(stateRef.current.panX - stateRef.current.panStartX); + const dy = Math.abs(stateRef.current.panY - stateRef.current.panStartY); + if (dx < 3 && dy < 3 && stateRef.current.hoverIndex >= 0) { + const node = preparedNodes[stateRef.current.hoverIndex]?.node; + if (node && onNodeClick) onNodeClick(node); + } + } + stateRef.current.isDragging = false; + canvas.style.cursor = stateRef.current.hoverIndex >= 0 ? "pointer" : "default"; + }; + + const handleMouseLeave = () => { + stateRef.current.mouseX = -1; + stateRef.current.mouseY = -1; + stateRef.current.isDragging = false; + stateRef.current.hoverIndex = -1; + if (tooltipRef.current) tooltipRef.current.style.display = "none"; + }; + + window.addEventListener("resize", handleResize); + canvas.addEventListener("wheel", handleWheel, { passive: false }); + canvas.addEventListener("mousemove", handleMouseMove); + canvas.addEventListener("mousedown", handleMouseDown); + canvas.addEventListener("mouseup", handleMouseUp); + canvas.addEventListener("mouseleave", handleMouseLeave); + + return () => { + cancelAnimationFrame(animRef.current); + window.removeEventListener("resize", handleResize); + canvas.removeEventListener("wheel", handleWheel); + canvas.removeEventListener("mousemove", handleMouseMove); + canvas.removeEventListener("mousedown", handleMouseDown); + canvas.removeEventListener("mouseup", handleMouseUp); + canvas.removeEventListener("mouseleave", handleMouseLeave); + }; + }, [animate, preparedNodes, onNodeClick, isFullscreen]); + + const toggleFullscreen = useCallback(() => { + setIsFullscreen((prev) => !prev); + }, []); + + // Esc to exit fullscreen + useEffect(() => { + if (!isFullscreen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsFullscreen(false); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isFullscreen]); + + // Re-measure canvas when fullscreen changes + useEffect(() => { + if (!canvasRef.current) return; + // Small delay to let the DOM update + const t = setTimeout(() => { + const dpr = window.devicePixelRatio || 1; + const rect = canvasRef.current!.getBoundingClientRect(); + canvasRef.current!.width = rect.width * dpr; + canvasRef.current!.height = rect.height * dpr; + stateRef.current.dpr = dpr; + stateRef.current.W = rect.width; + stateRef.current.H = rect.height; + }, 50); + return () => clearTimeout(t); + }, [isFullscreen]); + + return ( +
+ + + {/* Fullscreen toggle */} + + + {/* Tooltip */} +
+
+ ); +} diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 236561e8..4514a0a2 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -39,9 +39,11 @@ import { Switch } from "@/components/ui/switch"; import { MemoryDetailPanel } from "./memory-detail-panel"; import { MemoryDetailModal } from "./memory-detail-modal"; import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d"; +import { Constellation } from "./constellation"; +import { ScatterChart } from "lucide-react"; type FactType = "world" | "experience" | "observation"; -type ViewMode = "graph" | "table" | "timeline"; +type ViewMode = "graph" | "table" | "timeline" | "constellation"; interface DataViewProps { factType: FactType; @@ -49,7 +51,7 @@ interface DataViewProps { export function DataView({ factType }: DataViewProps) { const { currentBank } = useBank(); - const [viewMode, setViewMode] = useState("graph"); + const [viewMode, setViewMode] = useState("constellation"); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -411,6 +413,17 @@ export function DataView({ factType }: DataViewProps) { )}
+
)} + {viewMode === "constellation" && ( +
+
+ +
+ + {/* Right Toggle Button */} + + + {/* Right Panel — reuse the same panel as graph view */} + {showControlPanel && ( +
+ {selectedGraphNode ? ( + setSelectedGraphNode(null)} + inPanel + bankId={currentBank || undefined} + /> + ) : ( +
+

Constellation View

+

+ Canvas-rendered memory map with spatial label deconfliction. Scroll to zoom, + drag to pan, hover to explore entity connections. Click a memory to view + details. +

+
+

Link types

+ {Object.entries({ + semantic: "#0074d9", + temporal: "#009296", + entity: "#f59e0b", + causal: "#8b5cf6", + }).map(([type, color]) => ( +
toggleLinkType(type)} + > +
+ + {type} + +
+ ))} +
+
+
+ Nodes: {graph2DData.nodes.length} +
+
+ Links: {graph2DData.links.length} +
+
+
+ )} +
+ )} +
+ )} + {viewMode === "table" && (
diff --git a/package-lock.json b/package-lock.json index ccfca729..30ad332b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -278,6 +278,7 @@ "version": "0.4.22", "license": "ISC", "dependencies": { + "@chenglou/pretext": "^0.0.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -2836,6 +2837,12 @@ "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", "license": "MIT" }, + "node_modules/@chenglou/pretext": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.3.tgz", + "integrity": "sha512-RQmqMqUAPRCyv4R3LlRi/ao6KbNWYclqLA+V1HS7sWgyUUbjn3JmmlfXZSY/BjM4rbmIaMSyIVisYocYGYftiQ==", + "license": "MIT" + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",