feat(control-plane): add Constellation view with Pretext canvas rendering (#831)
Add a new "Constellation" memory visualization as the default view in the control plane, powered by @chenglou/pretext for DOM-free text layout on canvas. - Canvas-rendered zoomable/pannable memory map with spatial label deconfliction - Nodes colored by link-count heat gradient (Hindsight brand teal→cyan→blue) - Star-like rendering with varied size/opacity based on connectivity - Hover shows rich tooltip with full memory metadata (text, entities, tags, dates) - Hover highlights connected nodes and their links, dims the rest - Click to select and view memory details in the side panel - Fullscreen mode toggle - Link type legend and heat gradient legend on the HUD Also optimizes the graph API endpoint: - Entity query now filters by visible unit IDs (was doing full table scan) - Links query caps at 10k edges sorted by weight (was returning 500k+ uncapped) - Replaced expensive DISTINCT ON with LEAST/GREATEST sort with simple ORDER BY
This commit is contained in:
parent
ee4510a762
commit
36783df320
5 changed files with 1091 additions and 18 deletions
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
958
hindsight-control-plane/src/components/constellation.tsx
Normal file
958
hindsight-control-plane/src/components/constellation.tsx
Normal file
|
|
@ -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<typeof prepareWithSegments>;
|
||||
preparedHeight: ReturnType<typeof prepare>;
|
||||
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<string, string> = {
|
||||
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<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const isDark = useIsDarkMode();
|
||||
const animRef = useRef<number>(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<string, number>();
|
||||
data.nodes.forEach((n, i) => nodeIndexMap.set(n.id, i));
|
||||
|
||||
// Count links per node
|
||||
const linkCounts = new Map<string, number>();
|
||||
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<number, number[]>();
|
||||
|
||||
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<number>();
|
||||
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<string>();
|
||||
|
||||
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<string, any> | 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 = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">`;
|
||||
html += `<span style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:${nodeColor};background:${nodeColor}18;padding:2px 8px;border-radius:4px">${factType}</span>`;
|
||||
html += `<span style="font-size:10px;color:${muted}">${linkCount} link${linkCount !== 1 ? "s" : ""}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Text
|
||||
html += `<div style="font-size:12px;line-height:1.6;margin-bottom:8px">${fullText}</div>`;
|
||||
|
||||
// Metadata grid
|
||||
html += `<div style="display:flex;flex-direction:column;gap:4px;border-top:1px solid ${isDark ? "#27272a" : "#e4e4e7"};padding-top:8px">`;
|
||||
|
||||
if (context) {
|
||||
html += `<div style="${rowStyle}"><span style="${labelStyle}">Context</span><span style="${valStyle}">${context}</span></div>`;
|
||||
}
|
||||
|
||||
if (date) {
|
||||
html += `<div style="${rowStyle}"><span style="${labelStyle}">Date</span><span style="${valStyle}">${date}</span></div>`;
|
||||
} else if (occurredStart) {
|
||||
const timeRange = occurredEnd && occurredEnd !== occurredStart
|
||||
? `${occurredStart.slice(0, 10)} → ${occurredEnd.slice(0, 10)}`
|
||||
: occurredStart.slice(0, 10);
|
||||
html += `<div style="${rowStyle}"><span style="${labelStyle}">Occurred</span><span style="${valStyle}">${timeRange}</span></div>`;
|
||||
}
|
||||
|
||||
if (createdAt) {
|
||||
html += `<div style="${rowStyle}"><span style="${labelStyle}">Created</span><span style="${valStyle}">${createdAt.slice(0, 16).replace("T", " ")}</span></div>`;
|
||||
}
|
||||
|
||||
if (proofCount && proofCount > 1) {
|
||||
html += `<div style="${rowStyle}"><span style="${labelStyle}">Evidence</span><span style="${valStyle}">${proofCount} sources</span></div>`;
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
html += `<div style="${rowStyle}"><span style="${labelStyle}">Document</span><span style="font-size:11px;font-family:monospace;color:${muted}">${String(documentId).slice(0, 12)}...</span></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
// Entities
|
||||
if (entities.length > 0 && !(entities.length === 1 && entities[0] === "None")) {
|
||||
html += `<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:4px">`;
|
||||
for (const ent of entities) {
|
||||
html += `<span style="font-size:10px;background:${isDark ? "#27272a" : "#f4f4f5"};color:${isDark ? "#d4d4d8" : "#3f3f46"};padding:2px 7px;border-radius:4px">${ent}</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (tags.length > 0) {
|
||||
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px">`;
|
||||
for (const tag of tags) {
|
||||
html += `<span style="font-size:10px;background:${nodeColor}15;color:${nodeColor};padding:2px 7px;border-radius:4px">#${tag}</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// ID
|
||||
html += `<div style="margin-top:8px;font-size:9px;font-family:monospace;color:${dimmed}">${node.id}</div>`;
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={
|
||||
isFullscreen
|
||||
? {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
background: isDark ? "#09090b" : "#ffffff",
|
||||
}
|
||||
: { position: "relative", width: "100%", height: `${height}px` }
|
||||
}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: "100%", height: "100%", display: "block" }}
|
||||
/>
|
||||
|
||||
{/* Fullscreen toggle */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 21,
|
||||
padding: "6px 10px",
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${isDark ? "#27272a" : "#e4e4e7"}`,
|
||||
background: isDark ? "#18181b" : "#ffffff",
|
||||
color: isDark ? "#a1a1aa" : "#71717a",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
opacity: 0.7,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.opacity = "1";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.opacity = "0.7";
|
||||
}}
|
||||
title={isFullscreen ? "Exit fullscreen (Esc)" : "Enter fullscreen"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 14 10 14 10 20" />
|
||||
<polyline points="20 10 14 10 14 4" />
|
||||
<line x1="14" y1="10" x2="21" y2="3" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
)}
|
||||
{isFullscreen ? "Exit" : "Fullscreen"}
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
display: "none",
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
zIndex: 22,
|
||||
maxWidth: 360,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.35)",
|
||||
border: `1px solid ${isDark ? "#27272a" : "#e4e4e7"}`,
|
||||
background: isDark ? "#18181b" : "#ffffff",
|
||||
color: isDark ? "#e4e4e7" : "#18181b",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ViewMode>("graph");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("constellation");
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -411,6 +413,17 @@ export function DataView({ factType }: DataViewProps) {
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("constellation")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
viewMode === "constellation"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<ScatterChart className="w-4 h-4" />
|
||||
Constellation
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("graph")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
|
|
@ -677,6 +690,92 @@ export function DataView({ factType }: DataViewProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "constellation" && (
|
||||
<div className="flex gap-0">
|
||||
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
|
||||
<Constellation
|
||||
data={graph2DData}
|
||||
height={700}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
nodeColorFn={nodeColorFn}
|
||||
linkColorFn={linkColorFn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Toggle Button */}
|
||||
<button
|
||||
onClick={() => setShowControlPanel(!showControlPanel)}
|
||||
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
|
||||
title={showControlPanel ? "Hide panel" : "Show panel"}
|
||||
>
|
||||
{showControlPanel ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Right Panel — reuse the same panel as graph view */}
|
||||
{showControlPanel && (
|
||||
<div className="w-72 flex-shrink-0 border border-border rounded-lg bg-muted/20 overflow-y-auto h-[700px]">
|
||||
{selectedGraphNode ? (
|
||||
<MemoryDetailPanel
|
||||
memory={selectedGraphNode}
|
||||
onClose={() => setSelectedGraphNode(null)}
|
||||
inPanel
|
||||
bankId={currentBank || undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Constellation View</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<div className="space-y-2 pt-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">Link types</h4>
|
||||
{Object.entries({
|
||||
semantic: "#0074d9",
|
||||
temporal: "#009296",
|
||||
entity: "#f59e0b",
|
||||
causal: "#8b5cf6",
|
||||
}).map(([type, color]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => toggleLinkType(type)}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
opacity: visibleLinkTypes.has(type) ? 1 : 0.2,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs capitalize ${visibleLinkTypes.has(type) ? "text-foreground" : "text-muted-foreground line-through"}`}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1 pt-2">
|
||||
<div>
|
||||
Nodes: <span className="text-foreground">{graph2DData.nodes.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
Links: <span className="text-foreground">{graph2DData.links.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "table" && (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue