From bfb36756ec7d1845960d93f26a1ad34ce40e7a02 Mon Sep 17 00:00:00 2001 From: Rassl Date: Mon, 18 May 2026 17:30:25 +0400 Subject: [PATCH 01/19] feat: add metro overlay --- src/components/feed/feed-view.tsx | 11 + src/components/universe/graph-canvas.tsx | 303 +++++++++++++- .../universe/metro-overlay/constants.ts | 103 +++++ .../universe/metro-overlay/glow-bullet.tsx | 38 ++ .../universe/metro-overlay/index.ts | 16 + .../universe/metro-overlay/metro-legend.tsx | 166 ++++++++ .../metro-overlay/metro-line-segment.tsx | 66 +++ .../metro-overlay/metro-lines-layer.tsx | 184 +++++++++ .../metro-overlay/metro-station-bullets.tsx | 104 +++++ src/data/metro.ts | 381 ++++++++++++++++++ src/data/metro2087-data.ts | 362 +++++++++++++++++ src/lib/theme.ts | 6 + 12 files changed, 1727 insertions(+), 13 deletions(-) create mode 100644 src/components/universe/metro-overlay/constants.ts create mode 100644 src/components/universe/metro-overlay/glow-bullet.tsx create mode 100644 src/components/universe/metro-overlay/index.ts create mode 100644 src/components/universe/metro-overlay/metro-legend.tsx create mode 100644 src/components/universe/metro-overlay/metro-line-segment.tsx create mode 100644 src/components/universe/metro-overlay/metro-lines-layer.tsx create mode 100644 src/components/universe/metro-overlay/metro-station-bullets.tsx create mode 100644 src/data/metro.ts create mode 100644 src/data/metro2087-data.ts create mode 100644 src/lib/theme.ts diff --git a/src/components/feed/feed-view.tsx b/src/components/feed/feed-view.tsx index 6942f6dd..14ac7136 100644 --- a/src/components/feed/feed-view.tsx +++ b/src/components/feed/feed-view.tsx @@ -8,6 +8,9 @@ import { useAppStore } from "@/stores/app-store" import { useSchemaStore } from "@/stores/schema-store" import { isMocksEnabled, MOCK_NODES, MOCK_EDGES } from "@/lib/mock-data" import { getLatestNodes } from "@/lib/graph-api" +import { isMetroTheme } from "@/lib/theme" +import { metroSeries } from "@/data/metro" +import type { GraphNode, GraphEdge } from "@/lib/graph-api" import { FeedCard } from "./feed-card" import { HotTakes } from "./hot-takes" import { cn } from "@/lib/utils" @@ -32,12 +35,20 @@ export function FeedView() { }, [searchTerm, clearSelection]) // Mocks mode seeds from fixtures so the Latest feed has content before any search. + // Metro theme bypasses the API: the metro dataset lives only in the local + // fixture (the platform search/list endpoints return a thin projection + // that strips mapX/mapZ, so the overlay can't position bullets from API + // payloads alone). useEffect(() => { if (useGraphStore.getState().nodes.length > 0) return if (isMocksEnabled()) { setGraphData(MOCK_NODES, MOCK_EDGES) return } + if (isMetroTheme()) { + setGraphData(metroSeries.nodes as GraphNode[], metroSeries.edges as GraphEdge[]) + return + } let cancelled = false setLoading(true) ;(async () => { diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 9d0820ed..f4cf98f7 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -23,6 +23,17 @@ import { useAppStore } from "@/stores/app-store" import type { SchemaNode } from "@/app/ontology/page" import { HoverPreviewCard } from "./hover-preview-card" import { DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" +import { isMetroTheme } from "@/lib/theme" +import { metroSeries } from "@/data/metro" +import { + MetroLinesLayer, + MetroStationBullets, + MetroLegend, + METRO_FORCE_GROUPED_TYPES, + LORE_Y_LIFT, + statusToState, + type StationState, +} from "./metro-overlay" function nodeLabel(node: ApiNode, schemas: SchemaNode[]): string { const props = node.properties @@ -67,7 +78,12 @@ function apiToGraph( nodes: ApiNode[], edges: ApiEdge[], schemas: SchemaNode[] -): { graph: Graph; indexMap: Map; refIdToIndex: Map } { +): { + graph: Graph + indexMap: Map + refIdToIndex: Map + fixedPositions: Map +} { const rawNodes: RawNode[] = nodes.map((n) => ({ id: n.ref_id, label: truncateLabel(nodeLabel(n, schemas)), @@ -126,6 +142,21 @@ function apiToGraph( } const orphans = nodes.filter((n) => !reached.has(n.ref_id)) + // Nodes carrying explicit `mapX`/`mapZ` properties opt out of layout — + // their position is data-driven (e.g. metro stations on a schematic map), + // so they should not be folded into __group_ hubs or stray-ring + // fallbacks, and their root count shouldn't trigger crowd grouping. + const fixedRefIds = new Set() + for (const n of nodes) { + const p = n.properties as Record | undefined + if (p && typeof p.mapX === "number" && typeof p.mapZ === "number") { + fixedRefIds.add(n.ref_id) + } + } + // Metro view is only activated when actual fixed-position data is present. + // This keeps the standard graph behavior unchanged for non-metro datasets. + const isMetroView = fixedRefIds.size > 0 + // ─── 2. Decide which types get a __group_ ──────────────────────── // A type gets its own synthetic group node when it either has orphans or // — under the existing crowd-control rule — when there are >10 roots and @@ -133,7 +164,9 @@ function apiToGraph( // have outgoing edges to known nodes. This keeps hierarchy parents (e.g. // Episode, which has outgoing HAS → Chapter) surfacing as individuals // instead of being collapsed into __group_Episode. - const orphanTypes = new Set(orphans.map((o) => o.node_type || "Unknown")) + const orphanTypes = new Set( + orphans.filter((o) => !fixedRefIds.has(o.ref_id)).map((o) => o.node_type || "Unknown") + ) const hasKnownOut = new Set() for (const e of edges) { if (incomingCount.has(e.source) && incomingCount.has(e.target)) { @@ -144,6 +177,7 @@ function apiToGraph( if (roots.length > 10) { const leafRootCountByType = new Map() for (const r of roots) { + if (fixedRefIds.has(r.ref_id)) continue if (hasKnownOut.has(r.ref_id)) continue const type = r.node_type || "Unknown" leafRootCountByType.set(type, (leafRootCountByType.get(type) ?? 0) + 1) @@ -152,7 +186,11 @@ function apiToGraph( if (count >= 2) crowdGroupedTypes.add(type) } } - const groupedTypes = new Set([...orphanTypes, ...crowdGroupedTypes]) + // In the metro view, force-group lore types under labeled hubs regardless + // of root status — Artyom etc. would otherwise stay as individual nodes and + // pull the hop-1 ring into a single arc instead of distributing evenly. + const forceGroupedTypes = isMetroView ? METRO_FORCE_GROUPED_TYPES : new Set() + const groupedTypes = new Set([...orphanTypes, ...crowdGroupedTypes, ...forceGroupedTypes]) // ─── 3. Bundle by (source, edge_type, target_type) ───────────────────── const bundles = new Map() @@ -211,14 +249,28 @@ function apiToGraph( for (const t of groupedTypes) memberByType.set(t, new Set()) for (const r of roots) { if (clusteredTargets.has(r.ref_id)) continue + if (fixedRefIds.has(r.ref_id)) continue const t = r.node_type || "Unknown" if (groupedTypes.has(t)) memberByType.get(t)!.add(r.ref_id) } for (const o of orphans) { if (clusteredTargets.has(o.ref_id)) continue + if (fixedRefIds.has(o.ref_id)) continue const t = o.node_type || "Unknown" if (groupedTypes.has(t)) memberByType.get(t)!.add(o.ref_id) } + // Force-grouped types: pull in every node of that type, not just + // roots/orphans, so well-connected members still cluster under the hub. + if (forceGroupedTypes.size > 0) { + for (const n of nodes) { + if (clusteredTargets.has(n.ref_id)) continue + if (fixedRefIds.has(n.ref_id)) continue + const t = n.node_type || "Unknown" + if (forceGroupedTypes.has(t) && memberByType.has(t)) { + memberByType.get(t)!.add(n.ref_id) + } + } + } for (const [t, members] of memberByType) { if (members.size === 0) continue const groupId = `__group_${t}` @@ -263,13 +315,46 @@ function apiToGraph( graph.extraEdges.push({ src, dst, label: e.edge_type }) } - return { graph, indexMap, refIdToIndex } + // Map graph-node-index → fixed (x, y, z) for nodes that opted out of layout. + // `mapY` is optional — defaults to 0 if absent. Consumed by applyLayout to + // override the radial-computed position. + const fixedPositions = new Map() + for (let i = 0; i < nodes.length; i++) { + if (!fixedRefIds.has(nodes[i].ref_id)) continue + const p = nodes[i].properties as Record + const y = typeof p.mapY === "number" ? (p.mapY as number) : 0 + fixedPositions.set(i, { x: p.mapX as number, y, z: p.mapZ as number }) + } + + return { graph, indexMap, refIdToIndex, fixedPositions } } -function applyLayout(graph: Graph) { +function applyLayout( + graph: Graph, + fixedPositions?: Map, + forceLift = false +) { + // Metro view lifts the lore graph onto a higher Y plane so it floats above + // the schematic. Lift when either: stations are present in the dataset + // (fixedPositions has entries) OR the caller forces it (metro theme, + // dataset replaced by a search result that doesn't include stations). + const hasFixed = !!fixedPositions && fixedPositions.size > 0 + const loreLift = hasFixed || forceLift ? LORE_Y_LIFT : 0 + // Bumped from the 30 default — transcript/conversation graphs have chains // 40+ deep; truncating leaves the tail at buildGraph's (0,0,0) default. const sub = extractInitialSubgraph(graph, 1000) + + // Strip fixed-position nodes out of the radial layout's input layers so + // they don't claim slots in the hop-1 angular budget. depthMap is left + // intact — GraphView reads it to size/dim each node. + if (hasFixed) { + const fixed = fixedPositions! + sub.neighborsByDepth = sub.neighborsByDepth.map((layer) => + layer.filter((id) => !fixed.has(id)) + ) + } + const { positions, treeEdgeSet, childrenOf } = computeRadialLayout( sub.centerId, sub.neighborsByDepth, @@ -279,7 +364,18 @@ function applyLayout(graph: Graph) { for (const [id, pos] of positions) { if (id !== VIRTUAL_CENTER && id < graph.nodes.length) { - graph.nodes[id].position = pos + const fixed = fixedPositions?.get(id) + graph.nodes[id].position = fixed ?? { x: pos.x, y: pos.y + loreLift, z: pos.z } + } + } + + // Fixed-position nodes the BFS never reached (e.g. stations connected only + // to other stations in their own subgraph) still need their coords applied. + if (hasFixed) { + for (const [id, pos] of fixedPositions!) { + if (!positions.has(id) && id < graph.nodes.length) { + graph.nodes[id].position = pos + } } } @@ -288,7 +384,9 @@ function applyLayout(graph: Graph) { // origin. Park them on an outer ring so they stay visible and selectable. const stray: number[] = [] for (let i = 0; i < graph.nodes.length; i++) { - if (!positions.has(i)) stray.push(i) + if (positions.has(i)) continue + if (fixedPositions?.has(i)) continue + stray.push(i) } if (stray.length > 0) { let maxR = 0 @@ -303,7 +401,7 @@ function applyLayout(graph: Graph) { const angle = i * angleStep graph.nodes[stray[i]].position = { x: Math.cos(angle) * ringR, - y: 0, + y: loreLift, z: Math.sin(angle) * ringR, } } @@ -314,9 +412,12 @@ function applyLayout(graph: Graph) { graph.childrenOf = childrenOf // Snapshot every node's laid-out position. Click handler scales these by - // an inflation factor so deeper nodes get R1-sized rings without relaying out. + // an inflation factor so deeper nodes get R1-sized rings without relaying + // out. Fixed-position nodes are omitted — they have data-driven coords + // that must not stretch with the rest of the graph. const snapshot = new Map() for (let i = 0; i < graph.nodes.length; i++) { + if (fixedPositions?.has(i)) continue const p = graph.nodes[i].position snapshot.set(i, { x: p.x, y: p.y, z: p.z }) } @@ -455,11 +556,62 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const dataVersion = useGraphStore((s) => s.dataVersion) const searchTerm = useAppStore((s) => s.searchTerm) + // The metro overlay is theme-driven, not data-driven. We want the + // schematic to stay visible even when search replaces the graph store + // with results that don't include Station nodes — so the lines, bullets + // and legend always render from the local metro fixture in metro theme, + // independent of whatever the current dataset is. + const isMetroView = isMetroTheme() + const overlayNodes = isMetroView ? (metroSeries.nodes as ApiNode[]) : [] + const overlayEdges = isMetroView ? (metroSeries.edges as ApiEdge[]) : [] + + // In metro theme, the *interactive* station layer (3D spheres + labels + + // hover behavior provided by GraphView) also has to persist through + // search, not just the schematic bullets. So we splice fixture stations + // and TUNNEL_TO edges into whatever the graph store currently has before + // running the radial layout. De-dupe by ref_id / edge identity so a + // future search that does return a station won't double-render it. + const effectiveNodes = useMemo(() => { + if (!isMetroView) return nodes + const seen = new Set(nodes.map((n) => n.ref_id)) + const fixtureStations = (metroSeries.nodes as ApiNode[]).filter( + (n) => n.node_type === "Station" && !seen.has(n.ref_id) + ) + return fixtureStations.length > 0 ? [...nodes, ...fixtureStations] : nodes + }, [isMetroView, nodes]) + + const effectiveEdges = useMemo(() => { + if (!isMetroView) return edges + const refIds = new Set(effectiveNodes.map((n) => n.ref_id)) + const seen = new Set( + edges.map((e) => `${e.source}|${e.target}|${e.edge_type}`) + ) + // Pull in every fixture edge whose endpoints both exist in the effective + // node set. That covers two cases at once: + // 1. Station↔Station TUNNEL_TO edges (both stations are in fixture). + // 2. Cross-edges from search results to stations — e.g. when the user + // searches "Librarian", the result has the Librarian node but no + // INHABITS edge to Biblioteka, because the backend only returns + // edges between nodes in the result set. The fixture has the edge. + // Edges whose other endpoint isn't in the dataset would just dangle, + // so we skip them. + const extras = (metroSeries.edges as ApiEdge[]).filter( + (e) => + !seen.has(`${e.source}|${e.target}|${e.edge_type}`) && + refIds.has(e.source) && + refIds.has(e.target) + ) + return extras.length > 0 ? [...edges, ...extras] : edges + }, [isMetroView, edges, effectiveNodes]) + const { graph, indexMap, refIdToIndex } = useMemo(() => { - const result = apiToGraph(nodes, edges, schemas) - applyLayout(result.graph) + const result = apiToGraph(effectiveNodes, effectiveEdges, schemas) + // Force the Y-lift on the lore graph in metro theme even when the + // dataset doesn't carry fixed-position nodes (e.g. after a search). + // Otherwise search results would drop to y=0 where the schematic sits. + applyLayout(result.graph, result.fixedPositions, isMetroView) return result - }, [nodes, edges, schemas]) + }, [effectiveNodes, effectiveEdges, schemas, isMetroView]) // Lowercase type → schema icon name (e.g. "EpisodeIcon"). The pill in // GraphView resolves this through schema-icons to a Lucide component. @@ -474,6 +626,11 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const [viewState, setViewState] = useState({ mode: "overview" }) const [hoveredCardNode, setHoveredCardNode] = useState(null) const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) + // Metro overlay focus state — driven by hovering the lines (3D) or the + // legend (DOM). null when nothing is hovered, which leaves every line and + // bullet at full opacity. + const [hoveredLine, setHoveredLine] = useState(null) + const [hoveredState, setHoveredState] = useState(null) // CameraSync drives this each frame using the same lerp curve as GraphView, // so geometry inflation and camera dolly stay in lockstep. @@ -526,6 +683,107 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas return set.size > 0 ? set : null }, [nodes]) + // Hovering a legend row spotlights every station in that state — reuses + // the search-match plumbing in GraphView (highlights members, dims the + // rest). Returns null outside the metro view. + const stateHoverMatches = useMemo(() => { + if (!isMetroView || !hoveredState) return null + const set = new Set() + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].node_type !== "Station") continue + const p = nodes[i].properties as Record | undefined + if (!p) continue + const status = p.station_status ?? p.status + if (statusToState(status, p.faction) === hoveredState) set.add(i) + } + return set.size > 0 ? set : null + }, [nodes, hoveredState, isMetroView]) + + // Hovering a metro line spotlights every node tagged with that line. + const lineHoverMatches = useMemo(() => { + if (!isMetroView || !hoveredLine) return null + const set = new Set() + for (let i = 0; i < nodes.length; i++) { + const p = nodes[i].properties as Record | undefined + const lineStr = + (p && typeof p.metro_line === "string" ? p.metro_line : null) ?? + (p && typeof p.line === "string" ? p.line : null) ?? + "" + const lines = lineStr.split(",").map((s: string) => s.trim().toLowerCase()) + if (lines.includes(hoveredLine)) set.add(i) + } + return set.size > 0 ? set : null + }, [nodes, hoveredLine, isMetroView]) + + // ref_id → set of metro line colors the node is associated with. Stations + // contribute their own line property; non-station nodes inherit lines from + // any station they share an edge with (1-hop). Used to dim unrelated + // lines/bullets when a node is hovered or selected. + const nodeToLines = useMemo(() => { + if (!isMetroView) return new Map>() + const stationLines = new Map>() + for (const n of nodes) { + if (n.node_type !== "Station") continue + const p = n.properties as Record | undefined + const raw = + (p && typeof p.metro_line === "string" ? p.metro_line : null) ?? + (p && typeof p.line === "string" ? p.line : null) ?? + "" + const lines = new Set( + raw.split(",").map((s: string) => s.trim().toLowerCase()).filter(Boolean) + ) + if (lines.size > 0) stationLines.set(n.ref_id, lines) + } + const map = new Map>() + for (const [refId, lines] of stationLines) map.set(refId, new Set(lines)) + for (const e of edges) { + const srcLines = stationLines.get(e.source) + const dstLines = stationLines.get(e.target) + if (srcLines && !stationLines.has(e.target)) { + let arr = map.get(e.target) + if (!arr) { + arr = new Set() + map.set(e.target, arr) + } + for (const l of srcLines) arr.add(l) + } + if (dstLines && !stationLines.has(e.source)) { + let arr = map.get(e.source) + if (!arr) { + arr = new Set() + map.set(e.source, arr) + } + for (const l of dstLines) arr.add(l) + } + } + return map + }, [nodes, edges, isMetroView]) + + // Lines currently in focus. Hovering a line directly wins; otherwise the + // active node (hover beats select; canvas beats sidebar) contributes its + // associated lines. `null` means no dimming — every line at full opacity. + const activeLines = useMemo | null>(() => { + if (!isMetroView) return null + if (hoveredLine) return new Set([hoveredLine]) + let activeRefId: string | null = null + if (hoveredCardNode) activeRefId = hoveredCardNode.ref_id + else if (sidebarHoveredNode) activeRefId = sidebarHoveredNode.ref_id + else if (viewState.mode === "subgraph") { + activeRefId = indexMap.get(viewState.selectedNodeId) ?? null + } else if (sidebarSelectedNode) activeRefId = sidebarSelectedNode.ref_id + if (!activeRefId) return null + return nodeToLines.get(activeRefId) ?? new Set() + }, [ + isMetroView, + hoveredLine, + hoveredCardNode, + sidebarHoveredNode, + sidebarSelectedNode, + viewState, + indexMap, + nodeToLines, + ]) + const handleHoverChange = useCallback( (nodeId: number | null) => { if (nodeId === null) { @@ -669,6 +927,21 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas style={{ background: "oklch(0.06 0.02 260)" }} > + {isMetroView && ( + <> + + + + )} { @@ -721,6 +994,10 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas )} + + {isMetroView && ( + + )} ) } diff --git a/src/components/universe/metro-overlay/constants.ts b/src/components/universe/metro-overlay/constants.ts new file mode 100644 index 00000000..88c316f2 --- /dev/null +++ b/src/components/universe/metro-overlay/constants.ts @@ -0,0 +1,103 @@ +// Y offset for the schematic map — pushes lines and bullets below the node +// layer so nodes float visibly on top instead of sharing the y=0 plane. +export const MAP_Y_OFFSET = -0.6 + +// Lore graph nodes are lifted onto a higher Y plane so they float above the +// metro schematic. Stations keep their fixed positions at y=0; edges crossing +// the gap visually connect the two layers. +export const LORE_Y_LIFT = 18 + +// Real Moscow Metro line colors — applied to TUNNEL_TO edges so the +// schematic map reads the way an actual metro guide does. +export const METRO_LINE_COLORS: Record = { + red: [0.878, 0.188, 0.188], + green: [0.0, 0.627, 0.188], + darkblue: [0.0, 0.376, 0.69], + lightblue: [0.0, 0.69, 0.941], + brown: [0.565, 0.251, 0.125], + orange: [0.941, 0.439, 0.0], + purple: [0.565, 0.188, 0.627], + yellow: [0.941, 0.753, 0.0], + gray: [0.61, 0.64, 0.66], + lightgreen: [0.61, 0.8, 0.33], +} + +// Station state — drives the bullet fill and (via either endpoint) marks +// tunnel segments as blocked. Encodes the lore status the way a player would +// think of it: "is this place safe, abandoned, or hostile?" +export type StationState = + | "inhabited" + | "neutral" + | "lost" + | "anomaly" + | "scorched" + | "flood" + | "quarantine" + +export const STATION_FILL: Record = { + inhabited: "#f5efde", + neutral: "#6b7280", + lost: "#374151", + anomaly: "#dc2626", + scorched: "#1f1410", + flood: "#1d5bbf", + quarantine: "#d4a017", +} + +export const STATION_STATE_LABEL: Record = { + inhabited: "Inhabited", + neutral: "Neutral", + lost: "Lost", + anomaly: "Anomaly (creatures)", + scorched: "Scorched", + flood: "Flooded", + quarantine: "Quarantine", +} + +// Atmospheric glow color per state — used by the legend bullets. +export const STATION_GLOW: Record = { + inhabited: "#e89c4a", + neutral: "#7a6f63", + lost: "#c11e34", + anomaly: "#7a1822", + scorched: "#d97a1f", + flood: "#2879d6", + quarantine: "#a89000", +} + +// States that make a tunnel impassable in lore — render its segments at +// reduced opacity so the map reads as "trunk lines that still work" plus +// "abandoned spurs you wouldn't dare walk." +export const BLOCKING_STATES = new Set([ + "lost", + "anomaly", + "scorched", + "flood", + "quarantine", +]) + +export function statusToState(status: unknown, faction: unknown): StationState { + const s = typeof status === "string" ? status : "" + const f = typeof faction === "string" ? faction : "none" + if (s === "anomaly") return "anomaly" + if (s === "lost") return "lost" + if (s === "scorched") return "scorched" + if (s === "flood") return "flood" + if (s === "quarantine") return "quarantine" + if (s === "stronghold") return "inhabited" + if (f !== "none" && f !== "") return "inhabited" + return "neutral" +} + +// Lore node types that should always cluster under labeled hubs in the metro +// view, even when individual members are well-connected. Each grouped type +// adds one hub on the hop-1 ring, which is what spreads the lore graph evenly +// around the circle outside the schematic. +export const METRO_FORCE_GROUPED_TYPES = new Set([ + "Person", + "Organization", + "Weapon", + "Item", + "Transport", + "Creature", +]) diff --git a/src/components/universe/metro-overlay/glow-bullet.tsx b/src/components/universe/metro-overlay/glow-bullet.tsx new file mode 100644 index 00000000..bf939a93 --- /dev/null +++ b/src/components/universe/metro-overlay/glow-bullet.tsx @@ -0,0 +1,38 @@ +"use client" + +export function GlowBullet({ + glow, + glyph, + size = 14, +}: { + glow: string + glyph?: string + size?: number +}) { + return ( +
+ {glyph} +
+ ) +} diff --git a/src/components/universe/metro-overlay/index.ts b/src/components/universe/metro-overlay/index.ts new file mode 100644 index 00000000..8292ee42 --- /dev/null +++ b/src/components/universe/metro-overlay/index.ts @@ -0,0 +1,16 @@ +export { MetroLinesLayer, readStationLines } from "./metro-lines-layer" +export { MetroStationBullets } from "./metro-station-bullets" +export { MetroLegend } from "./metro-legend" +export { GlowBullet } from "./glow-bullet" +export { + MAP_Y_OFFSET, + LORE_Y_LIFT, + METRO_LINE_COLORS, + STATION_FILL, + STATION_GLOW, + STATION_STATE_LABEL, + BLOCKING_STATES, + METRO_FORCE_GROUPED_TYPES, + statusToState, + type StationState, +} from "./constants" diff --git a/src/components/universe/metro-overlay/metro-legend.tsx b/src/components/universe/metro-overlay/metro-legend.tsx new file mode 100644 index 00000000..041dde10 --- /dev/null +++ b/src/components/universe/metro-overlay/metro-legend.tsx @@ -0,0 +1,166 @@ +"use client" + +import { GlowBullet } from "./glow-bullet" +import { STATION_GLOW, STATION_STATE_LABEL, type StationState } from "./constants" + +export function MetroLegend({ + hoveredState, + onHoverState, +}: { + hoveredState: StationState | null + onHoverState: (state: StationState | null) => void +}) { + const stationStates: StationState[] = [ + "inhabited", + "neutral", + "anomaly", + "scorched", + "flood", + "quarantine", + "lost", + ] + return ( +
onHoverState(null)} + > +
+ Stations +
+
+ {stationStates.map((state) => { + const isHovered = hoveredState === state + const isFaded = hoveredState !== null && !isHovered + return ( +
onHoverState(state)} + style={{ + display: "grid", + gridTemplateColumns: "18px 1fr", + alignItems: "center", + columnGap: 10, + padding: "3px 6px", + marginLeft: -6, + marginRight: -6, + borderRadius: 4, + cursor: "pointer", + background: isHovered ? "rgba(232,156,74,0.08)" : "transparent", + opacity: isFaded ? 0.4 : 1, + transition: "opacity 120ms, background 120ms", + }} + > + + + {STATION_STATE_LABEL[state]} + +
+ ) + })} +
+ +
+ +
+ Tunnels +
+
+
+
+ + Open + +
+
+
+ + Blocked + +
+
+
+ ) +} diff --git a/src/components/universe/metro-overlay/metro-line-segment.tsx b/src/components/universe/metro-overlay/metro-line-segment.tsx new file mode 100644 index 00000000..c37f95f3 --- /dev/null +++ b/src/components/universe/metro-overlay/metro-line-segment.tsx @@ -0,0 +1,66 @@ +"use client" + +import { useMemo } from "react" +import { useThree } from "@react-three/fiber" +import * as THREE from "three" +import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js" +import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js" +import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js" + +export function MetroLineSegment({ + lineId, + positions, + color, + onHover, + dimmed, + baseOpacity, +}: { + lineId: string + positions: Float32Array + color: [number, number, number] + onHover: (lineId: string | null) => void + dimmed: boolean + baseOpacity: number +}) { + const { size } = useThree() + + const geometry = useMemo(() => { + const g = new LineSegmentsGeometry() + g.setPositions(Array.from(positions)) + return g + }, [positions]) + + // Dimming (from line-focus highlight) drops the whole segment to ~10% of + // its natural opacity, preserving the open vs. blocked contrast even + // while another line is in focus. Built into the memo so a dim toggle + // rebuilds the material — cheap, and avoids post-memo mutation. + const material = useMemo(() => { + const opacity = dimmed ? baseOpacity * 0.1 : baseOpacity + const m = new LineMaterial({ + color: new THREE.Color(color[0], color[1], color[2]).getHex(), + // Pixel-space thickness — looks like a real metro map line. + linewidth: 7, + transparent: true, + opacity, + worldUnits: false, + depthTest: true, + }) + m.resolution.set(size.width, size.height) + return m + }, [color, size.width, size.height, baseOpacity, dimmed]) + + const object = useMemo(() => new LineSegments2(geometry, material), [geometry, material]) + return ( + void }) => { + e.stopPropagation() + onHover(lineId) + }} + onPointerOut={(e: { stopPropagation: () => void }) => { + e.stopPropagation() + onHover(null) + }} + /> + ) +} diff --git a/src/components/universe/metro-overlay/metro-lines-layer.tsx b/src/components/universe/metro-overlay/metro-lines-layer.tsx new file mode 100644 index 00000000..a38b766e --- /dev/null +++ b/src/components/universe/metro-overlay/metro-lines-layer.tsx @@ -0,0 +1,184 @@ +"use client" + +import { useMemo } from "react" +import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/graph-api" +import { MetroLineSegment } from "./metro-line-segment" +import { + BLOCKING_STATES, + MAP_Y_OFFSET, + METRO_LINE_COLORS, + statusToState, + type StationState, +} from "./constants" + +// Returns the lowercase metro line identifier(s) declared on a Station's +// properties. Reads `metro_line` first (the synced schema name) and falls +// back to legacy `line` for compatibility with older payloads. +function readStationLines(p: Record | undefined): string[] { + if (!p) return [] + const raw = + (typeof p.metro_line === "string" ? p.metro_line : null) ?? + (typeof p.line === "string" ? p.line : null) + if (!raw) return [] + return raw + .split(",") + .map((s: string) => s.trim().toLowerCase()) + .filter(Boolean) +} + +// Renders TUNNEL_TO edges as colored segments matching their metro line. +// Lives on top of the regular graph edge layer; the underlying purple +// cross-edges show through faintly which is fine — colored lines dominate. +// Renders nothing when no TUNNEL_TO edges are present, so safe to mount in +// non-metro themes. +export function MetroLinesLayer({ + nodes, + edges, + onLineHover, + activeLines, +}: { + nodes: ApiNode[] + edges: ApiEdge[] + onLineHover: (lineId: string | null) => void + activeLines: Set | null +}) { + const segmentsByLine = useMemo(() => { + const posByRefId = new Map() + const stateByStation = new Map() + for (const n of nodes) { + const p = n.properties as Record | undefined + if (!p) continue + if (typeof p.mapX !== "number" || typeof p.mapZ !== "number") continue + const y = typeof p.mapY === "number" ? (p.mapY as number) : 0 + posByRefId.set(n.ref_id, [p.mapX as number, y + MAP_Y_OFFSET, p.mapZ as number]) + if (n.node_type === "Station") { + // `station_status` is the synced schema name; `status` is the legacy. + const status = p.station_status ?? p.status + stateByStation.set(n.ref_id, statusToState(status, p.faction)) + } + } + + // Per-line position arrays split into "open" (both endpoints safe) and + // "blocked" (either endpoint in a BLOCKING_STATE). Rendered as two + // separate LineSegments2 passes so each can carry its own opacity. + const openByLine = new Map() + const blockedByLine = new Map() + const brownStations: Array<[number, number, number]> = [] + for (const e of edges) { + if (e.edge_type !== "TUNNEL_TO") continue + const props = (e as unknown as { properties?: Record }).properties + const lineStr = props?.line + if (typeof lineStr !== "string") continue + const primary = lineStr.split(",")[0].trim().toLowerCase() + const start = posByRefId.get(e.source) + const end = posByRefId.get(e.target) + if (!start || !end) continue + if (primary === "brown") { + brownStations.push(start) + continue + } + const srcState = stateByStation.get(e.source) ?? "neutral" + const dstState = stateByStation.get(e.target) ?? "neutral" + const isBlocked = BLOCKING_STATES.has(srcState) || BLOCKING_STATES.has(dstState) + const target = isBlocked ? blockedByLine : openByLine + let arr = target.get(primary) + if (!arr) { + arr = [] + target.set(primary, arr) + } + arr.push(start[0], start[1], start[2], end[0], end[1], end[2]) + } + + // Smooth the brown Koltsevaya ring: compute centroid + average radius + // from its stations, then emit a high-resolution closed arc. Real metro + // maps draw it as a perfect ring rather than a polygon. + if (brownStations.length >= 3) { + let cx = 0, + cy = 0, + cz = 0 + for (const p of brownStations) { + cx += p[0] + cy += p[1] + cz += p[2] + } + cx /= brownStations.length + cy /= brownStations.length + cz /= brownStations.length + let rSum = 0 + for (const p of brownStations) { + const dx = p[0] - cx + const dz = p[2] - cz + rSum += Math.sqrt(dx * dx + dz * dz) + } + const r = rSum / brownStations.length + const RING_SEGMENTS = 96 + const ring: number[] = [] + for (let i = 0; i < RING_SEGMENTS; i++) { + const a1 = (i / RING_SEGMENTS) * Math.PI * 2 + const a2 = ((i + 1) / RING_SEGMENTS) * Math.PI * 2 + ring.push( + cx + Math.cos(a1) * r, + cy, + cz + Math.sin(a1) * r, + cx + Math.cos(a2) * r, + cy, + cz + Math.sin(a2) * r, + ) + } + openByLine.set("brown", ring) + } + + return { open: openByLine, blocked: blockedByLine } + }, [nodes, edges]) + + const lines = useMemo(() => { + const set = new Set() + for (const k of segmentsByLine.open.keys()) set.add(k) + for (const k of segmentsByLine.blocked.keys()) set.add(k) + return Array.from(set) + }, [segmentsByLine]) + + // Reads of readStationLines belong on the consumer side; export for reuse. + void readStationLines + + return ( + <> + {lines.flatMap((line) => { + const rgb = METRO_LINE_COLORS[line] ?? [1, 1, 1] + const dimmed = activeLines !== null && !activeLines.has(line) + const open = segmentsByLine.open.get(line) + const blocked = segmentsByLine.blocked.get(line) + const out: React.ReactElement[] = [] + if (open && open.length > 0) { + out.push( + , + ) + } + if (blocked && blocked.length > 0) { + out.push( + , + ) + } + return out + })} + + ) +} + +export { readStationLines } diff --git a/src/components/universe/metro-overlay/metro-station-bullets.tsx b/src/components/universe/metro-overlay/metro-station-bullets.tsx new file mode 100644 index 00000000..bd02376c --- /dev/null +++ b/src/components/universe/metro-overlay/metro-station-bullets.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useMemo } from "react" +import * as THREE from "three" +import type { GraphNode as ApiNode } from "@/lib/graph-api" +import { + MAP_Y_OFFSET, + METRO_LINE_COLORS, + STATION_FILL, + statusToState, + type StationState, +} from "./constants" + +// Schematic-style station bullets — a flat white-cream disc ringed in the +// station's primary line color, lying on the y=0 plane. This is what makes +// a metro map *look* like one; lines without bullets read as plain edges. +// +// Renders nothing when no Station nodes carry mapX/mapZ, so safe to mount +// in non-metro themes. +export function MetroStationBullets({ + nodes, + activeLines, + activeState, +}: { + nodes: ApiNode[] + activeLines: Set | null + activeState: StationState | null +}) { + const bullets = useMemo(() => { + const result: Array<{ + id: string + x: number + y: number + z: number + color: [number, number, number] + lines: string[] + fill: string + state: StationState + }> = [] + for (const n of nodes) { + if (n.node_type !== "Station") continue + const p = n.properties as Record | undefined + if (!p) continue + if (typeof p.mapX !== "number" || typeof p.mapZ !== "number") continue + const lineStrRaw = + (typeof p.metro_line === "string" ? p.metro_line : null) ?? + (typeof p.line === "string" ? p.line : null) + const lineStr = lineStrRaw ?? "" + const lines = lineStr + .split(",") + .map((s: string) => s.trim().toLowerCase()) + .filter(Boolean) + const primary = lines[0] ?? "" + const color = METRO_LINE_COLORS[primary] ?? [0.85, 0.85, 0.85] + const status = p.station_status ?? p.status + const state = statusToState(status, p.faction) + const fill = STATION_FILL[state] + const baseY = typeof p.mapY === "number" ? (p.mapY as number) : 0 + result.push({ + id: n.ref_id, + x: p.mapX as number, + y: baseY + MAP_Y_OFFSET + 0.05, + z: p.mapZ as number, + color, + lines, + fill, + state, + }) + } + return result + }, [nodes]) + + return ( + + {bullets.map((b) => { + const lineDimmed = + activeLines !== null && !b.lines.some((l) => activeLines.has(l)) + const stateDimmed = activeState !== null && b.state !== activeState + const dimmed = lineDimmed || stateDimmed + const opacity = dimmed ? 0.12 : 1 + return ( + + + + + + + + + + + ) + })} + + ) +} diff --git a/src/data/metro.ts b/src/data/metro.ts new file mode 100644 index 00000000..f78133cd --- /dev/null +++ b/src/data/metro.ts @@ -0,0 +1,381 @@ +import { stations as stations2087, lines as lines2087 } from "./metro2087-data" +import type { Station } from "./metro2087-data" + +// 2087 line id → graph color name. The names match keys in MetroCanvas's +// METRO_LINE_COLORS so each line renders in the correct palette. +const LINE_COLOR_BY_ID: Record = { + circle: "brown", + sokol: "red", + zamosk: "green", + arbat: "darkblue", + filyov: "lightblue", + kalrij: "orange", + tagan: "purple", + kalin: "yellow", + serp: "gray", + lyub: "lightgreen", +} + +// Compute the comma-joined `line` property for each station — used by +// MetroCanvas to color the brown ring smoothing and as ambient metadata. +const LINES_BY_STATION = (() => { + const map = new Map() + for (const L of lines2087) { + const color = LINE_COLOR_BY_ID[L.id] + if (!color) continue + for (const sid of L.stations) { + let arr = map.get(sid) + if (!arr) { arr = []; map.set(sid, arr) } + if (!arr.includes(color)) arr.push(color) + } + } + return map +})() + +// Lore overrides applied on top of the auto-generated station properties. +// Drop a `description` for the popover blurb and `image` for the thumbnail +// — files live in /public/images/ and are referenced like "/images/foo.png". +const STATION_LORE: Record< + string, + { description?: string; image?: string } +> = { + vdnkh: { + description: + "Northern terminus of the red Sokolnicheskaya line and Artyom's home station. Raised here under Sukhoi's command, Artyom set out from VDNKh in 2033 to call for help after the Dark Ones broke through. The mushroom farms and pig pens of VDNKh are the metro's most famous green-thumb economy.", + }, + biblioteka: { + description: + "Capital of Polis and seat of the Brahmins — keepers of pre-war knowledge. Four lines meet under the Great Library above, where Librarians prowl the stacks. The path to D6 began here in 2033.", + }, + park_pobedy: { + description: + "Western terminus on the dark-blue line and one of the deepest stations in Moscow. The Dark Ones surfaced from here onto the surface ruins of Victory Park — Artyom's final mission in Metro 2033 launched out of this hall.", + }, + sevastopolskaya: { + description: + "Southern frontier of the gray Serpukhovskaya line — a small republic that survives on hydropower and constant mutant warfare. Hunter is brigadier here in 2034; the watchmen on the dark south tunnels are its grim immortals.", + }, + polyanka: { + description: + "Mid-gray-line junction whose flooded lower decks separate Sevastopolskaya from the safe north. Setting of much of Metro 2034 as Hunter, Homer, and Sasha cross the gray frontier.", + }, + tulskaya: { + description: + "Gray-line station overrun by the Worm Cult fanatics in 2034. Their plague and panic almost cost the rest of the metro the south.", + }, + komsomolskaya_k: { + description: + "Ring-line transfer above three mainline rail termini — Hansa's heaviest garrison. The three-station transfer cluster (Komsomolskaya / Komsomolskaya-R / Krasnoselskaya) is the crown jewel of the brown ring.", + }, + paveletskaya_k: { + description: + "Wealthy Hansa station on the southern ring; a major trade hub where MGR rounds change hands by the crate.", + }, + dobryninskaya: { + description: + "Hansa ring station guarding the south transfer to the gray line — frontier checkpoint against Fourth Reich incursions.", + }, + oktyabrskaya_k: { + description: + "Hansa ring station between Dobryninskaya and Park Kultury — controls trade between the southwest and the central core.", + }, + borovitskaya: { + description: + "Polis member station; the Council of Brahmins convenes in its halls.", + }, + arbatskaya: { + description: + "Polis member station and one of Moscow's deepest — Polis's military fist, garrisoned by the Spartans before their move to D6.", + }, +} + +// Convert a 2087 station into a graph node. mapZ is negated because the +// 2087 dataset uses +z = north while the graph (and Three.js) expect -z = north. +function stationNode(s: Station) { + const properties: Record = { + // `name` drives the 3D label and the popover title — keep it English so + // the visualization is readable for non-Russian-speaking viewers. + // `name_ru` is preserved for context (rendered as a small subtitle). + name: s.en, + name_ru: s.ru, + line: (LINES_BY_STATION.get(s.id) ?? []).join(","), + status: s.status, + faction: s.faction, + mapX: s.x, + mapZ: -s.z, + } + if (s.note) properties.note = s.note + const lore = STATION_LORE[s.id] + if (lore) Object.assign(properties, lore) + return { + date_added_to_graph: 1778230600.0, + node_type: "Station", + properties, + ref_id: s.id, + } +} + +interface RawEdge { + edge_type: string + source: string + target: string + ref_id: string + weight: number + properties: Record +} + +// Walk each line and emit a TUNNEL_TO edge between every consecutive pair of +// stations; closed lines (only the brown ring today) get a final wraparound +// segment so the smooth-circle pass in MetroCanvas has a complete loop. +function tunnelEdges(): RawEdge[] { + const edges: RawEdge[] = [] + for (const L of lines2087) { + const color = LINE_COLOR_BY_ID[L.id] + if (!color) continue + for (let i = 0; i < L.stations.length - 1; i++) { + edges.push({ + edge_type: "TUNNEL_TO", + properties: { line: color }, + ref_id: `m-t-${L.id}-${i}`, + source: L.stations[i], + target: L.stations[i + 1], + weight: 1, + }) + } + if (L.closed && L.stations.length > 1) { + edges.push({ + edge_type: "TUNNEL_TO", + properties: { line: color }, + ref_id: `m-t-${L.id}-close`, + source: L.stations[L.stations.length - 1], + target: L.stations[0], + weight: 1, + }) + } + } + return edges +} + +const stationNodes = Object.values(stations2087).map(stationNode) +const tunnels = tunnelEdges() + +export const metroSeries = { + edges: [ + // --- Family -------------------------------------------------------- + { "edge_type": "STEPSON_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-25", "source": "Artyom", "target": "Sukhoi", "weight": 1 }, + { "edge_type": "MARRIED_TO", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-26", "source": "Artyom", "target": "Anna", "weight": 1 }, + { "edge_type": "DAUGHTER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-27", "source": "Anna", "target": "Miller", "weight": 1 }, + { "edge_type": "FATHER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-28", "source": "Khlebnikov", "target": "Kirill", "weight": 1 }, + + // --- Mentorship / Spartan Order ------------------------------------ + { "edge_type": "MENTOR_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-29", "source": "Khan", "target": "Artyom", "weight": 1 }, + { "edge_type": "LEADER_OF", "properties": { "date_added_to_graph": "1778230600.0", "rank": "Colonel" }, "ref_id": "m-e-30", "source": "Miller", "target": "SpartanOrder", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-31", "source": "Artyom", "target": "SpartanOrder", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-32", "source": "Anna", "target": "SpartanOrder", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-33", "source": "Hunter", "target": "SpartanOrder", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-34", "source": "Ulman", "target": "SpartanOrder", "weight": 1 }, + { "edge_type": "BASED_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-35", "source": "SpartanOrder", "target": "D6", "weight": 1 }, + + // --- Aurora crew --------------------------------------------------- + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-36", "source": "Artyom", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-37", "source": "Anna", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-38", "source": "Miller", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "Tatar mechanic" }, "ref_id": "m-e-39", "source": "Damir", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "engineer" }, "ref_id": "m-e-40", "source": "Tokarev", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "comms / musician" }, "ref_id": "m-e-41", "source": "Stepan", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "veteran mechanic" }, "ref_id": "m-e-42", "source": "Krest", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-43", "source": "Idiot", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "US Marine radio operator" }, "ref_id": "m-e-104", "source": "Sam", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "OPERATES", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-44", "source": "AuroraCrew", "target": "Aurora", "weight": 1 }, + + // --- Aurora journey (Exodus locations) ----------------------------- + { "edge_type": "TRAVELS_TO", "properties": { "date_added_to_graph": "1778230600.0", "chapter": "1" }, "ref_id": "m-e-45", "source": "Aurora", "target": "Volga", "weight": 1 }, + { "edge_type": "TRAVELS_TO", "properties": { "date_added_to_graph": "1778230600.0", "chapter": "2" }, "ref_id": "m-e-46", "source": "Aurora", "target": "Caspian", "weight": 1 }, + { "edge_type": "TRAVELS_TO", "properties": { "date_added_to_graph": "1778230600.0", "chapter": "3" }, "ref_id": "m-e-47", "source": "Aurora", "target": "Taiga", "weight": 1 }, + { "edge_type": "TRAVELS_TO", "properties": { "date_added_to_graph": "1778230600.0", "chapter": "4" }, "ref_id": "m-e-48", "source": "Aurora", "target": "DeadCity", "weight": 1 }, + { "edge_type": "TRAVELS_TO", "properties": { "date_added_to_graph": "1778230600.0", "chapter": "interlude" }, "ref_id": "m-e-105", "source": "Aurora", "target": "Yamantau", "weight": 1 }, + { "edge_type": "TRAVELS_TO", "properties": { "date_added_to_graph": "1778230600.0", "chapter": "destination" }, "ref_id": "m-e-106", "source": "Aurora", "target": "Baikal", "weight": 1 }, + + // --- Factions ------------------------------------------------------ + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "communist commander" }, "ref_id": "m-e-49", "source": "Pavel", "target": "RedLine", "weight": 1 }, + { "edge_type": "ANTAGONIST_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-50", "source": "Pavel", "target": "Artyom", "weight": 1 }, + { "edge_type": "ANTAGONIST_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-51", "source": "Lesnitsky", "target": "Khlebnikov", "weight": 1 }, + { "edge_type": "OPPOSED_BY", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-52", "source": "RedLine", "target": "FourthReich", "weight": 1 }, + // Polis (the org) is now based at Biblioteka Lenina — the central + // archive in 2087 lore. Replaces the old PolisStation reference. + { "edge_type": "BASED_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-53", "source": "Polis", "target": "biblioteka", "weight": 1 }, + + // --- Cults / surface tribes ---------------------------------------- + { "edge_type": "INHABITS", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-54", "source": "GreatWormCult", "target": "Volga", "weight": 1 }, + { "edge_type": "INHABITS", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-55", "source": "TaigaWatchmen", "target": "Taiga", "weight": 1 }, + { "edge_type": "INHABITS", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-56", "source": "ChildrenOfForest", "target": "Taiga", "weight": 1 }, + { "edge_type": "INHABITS", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-57", "source": "CaspianPirates", "target": "Caspian", "weight": 1 }, + + // --- Companions ----------------------------------------------------- + { "edge_type": "COMPANION_OF", "properties": { "date_added_to_graph": "1778230600.0", "game": "Metro 2033" }, "ref_id": "m-e-58", "source": "Bourbon", "target": "Artyom", "weight": 1 }, + // Lore station refs rewired onto 2087 ids: VDNKh→vdnkh, PolisStation→ + // biblioteka, ParkPobedy→park_pobedy. The graph relies on these as + // both endpoints existing — without the rename they'd dangle. + { "edge_type": "HOME_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-59", "source": "vdnkh", "target": "Artyom", "weight": 1 }, + { "edge_type": "PART_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-60", "source": "vdnkh", "target": "MoscowMetro", "weight": 1 }, + { "edge_type": "PART_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-61", "source": "biblioteka", "target": "MoscowMetro", "weight": 1 }, + { "edge_type": "PART_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-62", "source": "park_pobedy", "target": "MoscowMetro", "weight": 1 }, + { "edge_type": "PART_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-63", "source": "D6", "target": "MoscowMetro", "weight": 1 }, + + // --- Mutants by habitat -------------------------------------------- + { "edge_type": "FOUND_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-64", "source": "Nosalis", "target": "MoscowMetro", "weight": 1 }, + { "edge_type": "FOUND_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-65", "source": "Lurker", "target": "MoscowMetro", "weight": 1 }, + { "edge_type": "FOUND_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-66", "source": "Watcher", "target": "DeadCity", "weight": 1 }, + { "edge_type": "FOUND_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-67", "source": "Demon", "target": "DeadCity", "weight": 1 }, + { "edge_type": "FOUND_AT", "properties": { "date_added_to_graph": "1778230600.0", "place": "Great Library" }, "ref_id": "m-e-68", "source": "Librarian", "target": "biblioteka", "weight": 1 }, + { "edge_type": "FOUND_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-69", "source": "Shrimp", "target": "Volga", "weight": 1 }, + { "edge_type": "FOUND_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-70", "source": "Humanimal", "target": "Caspian", "weight": 1 }, + { "edge_type": "FOUND_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-71", "source": "MutantBear", "target": "Taiga", "weight": 1 }, + { "edge_type": "INHABITS", "properties": { "date_added_to_graph": "1778230600.0", "alias": "Black Ones" }, "ref_id": "m-e-72", "source": "DarkOnes", "target": "park_pobedy", "weight": 1 }, + + // --- Weapons ------------------------------------------------------- + { "edge_type": "WIELDED_BY", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-73", "source": "Bastard", "target": "Artyom", "weight": 1 }, + { "edge_type": "WIELDED_BY", "properties": { "date_added_to_graph": "1778230600.0", "type": "pneumatic sniper" }, "ref_id": "m-e-74", "source": "Tikhar", "target": "Anna", "weight": 1 }, + { "edge_type": "WIELDED_BY", "properties": { "date_added_to_graph": "1778230600.0", "type": "pneumatic crossbow" }, "ref_id": "m-e-75", "source": "Helsing", "target": "Artyom", "weight": 1 }, + { "edge_type": "WIELDED_BY", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-76", "source": "Ashot", "target": "Artyom", "weight": 1 }, + { "edge_type": "WIELDED_BY", "properties": { "date_added_to_graph": "1778230600.0", "type": "electric rifle" }, "ref_id": "m-e-77", "source": "VoltDriver", "target": "Artyom", "weight": 1 }, + + // --- Items / survival gear ----------------------------------------- + { "edge_type": "REQUIRED_ON", "properties": { "date_added_to_graph": "1778230600.0", "purpose": "filtered air" }, "ref_id": "m-e-78", "source": "GasMask", "target": "Surface", "weight": 1 }, + { "edge_type": "USED_WITH", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-79", "source": "Filter", "target": "GasMask", "weight": 1 }, + + // --- Polis alliance & Hansa ring ----------------------------------- + // Polis is an alliance of central red-line stations — Biblioteka is + // already linked above; Borovitskaya and Arbatskaya are the other + // member stations. + { "edge_type": "BASED_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-83", "source": "Polis", "target": "borovitskaya", "weight": 1 }, + { "edge_type": "BASED_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-84", "source": "Polis", "target": "arbatskaya", "weight": 1 }, + // Hansa = Commonwealth of the Ring Line — controls the brown ring. + // Three representative stations rather than all 12 to keep the graph + // readable; any ring station's faction:"union" already reads as Hansa. + { "edge_type": "CONTROLS", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-85", "source": "Hansa", "target": "dobryninskaya", "weight": 1 }, + { "edge_type": "CONTROLS", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-86", "source": "Hansa", "target": "paveletskaya_k", "weight": 1 }, + { "edge_type": "CONTROLS", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-87", "source": "Hansa", "target": "oktyabrskaya_k", "weight": 1 }, + // Hunter is brigadier of Sevastopolskaya in Metro 2034. + { "edge_type": "BASED_AT", "properties": { "date_added_to_graph": "1778230600.0", "year": "2034", "rank": "Brigadier" }, "ref_id": "m-e-88", "source": "Hunter", "target": "sevastopolskaya", "weight": 1 }, + // Khlebnikov (and the Two Colonels arc) is anchored at Novosibirsk. + { "edge_type": "BASED_AT", "properties": { "date_added_to_graph": "1778230600.0", "year": "2036", "rank": "Colonel" }, "ref_id": "m-e-107", "source": "Khlebnikov", "target": "Novosibirsk", "weight": 1 }, + + // --- Survival-gear network — bridge the items cluster into the main + // graph through Stalkers (Khan / Hunter / Bourbon), Artyom's own kit, + // pneumatic chargers used by Tikhar/Helsing/Volt Driver, MGR currency + // (Hansa's trade), and crafting on the Aurora. Without these edges + // GasMask + Filter sit as a 2-node island far from everything else. + { "edge_type": "USES", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-89", "source": "Artyom", "target": "GasMask", "weight": 1 }, + { "edge_type": "USES", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-90", "source": "Artyom", "target": "Filter", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "mystic" }, "ref_id": "m-e-91", "source": "Khan", "target": "Stalkers", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "veteran" }, "ref_id": "m-e-92", "source": "Hunter", "target": "Stalkers", "weight": 1 }, + { "edge_type": "MEMBER_OF", "properties": { "date_added_to_graph": "1778230600.0", "role": "mercenary" }, "ref_id": "m-e-93", "source": "Bourbon", "target": "Stalkers", "weight": 1 }, + { "edge_type": "REQUIRES", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-94", "source": "Stalkers", "target": "GasMask", "weight": 1 }, + { "edge_type": "REQUIRES", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-95", "source": "Stalkers", "target": "Filter", "weight": 1 }, + { "edge_type": "USED_WITH", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-96", "source": "Charger", "target": "Helsing", "weight": 1 }, + { "edge_type": "USED_WITH", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-97", "source": "Charger", "target": "Tikhar", "weight": 1 }, + { "edge_type": "USED_WITH", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-98", "source": "Charger", "target": "VoltDriver", "weight": 1 }, + { "edge_type": "TRADES_IN", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-99", "source": "Hansa", "target": "MGR", "weight": 1 }, + { "edge_type": "CURRENCY_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-100", "source": "MGR", "target": "MoscowMetro", "weight": 1 }, + { "edge_type": "CARRIES", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-101", "source": "AuroraCrew", "target": "Medkit", "weight": 1 }, + { "edge_type": "INSTALLED_AT", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-102", "source": "Workbench", "target": "Aurora", "weight": 1 }, + { "edge_type": "OPERATES", "properties": { "date_added_to_graph": "1778230600.0", "role": "engineer" }, "ref_id": "m-e-103", "source": "Tokarev", "target": "Workbench", "weight": 1 }, + + // --- Transport (vehicles in-universe) ------------------------------ + // Handcars are the stalkers' workhorse in the Moscow tunnels. + { "edge_type": "OPERATED_BY", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-108", "source": "Handcar", "target": "Stalkers", "weight": 1 }, + { "edge_type": "OPERATES_IN", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-109", "source": "Handcar", "target": "MoscowMetro", "weight": 1 }, + // Hansa runs trade convoys around the brown ring. + { "edge_type": "OPERATED_BY", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-110", "source": "HansaTrain", "target": "Hansa", "weight": 1 }, + { "edge_type": "OPERATES_IN", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-111", "source": "HansaTrain", "target": "MoscowMetro", "weight": 1 }, + // The Aurora crew commandeers a sailboat in the Caspian chapter. + { "edge_type": "OPERATED_BY", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-112", "source": "CaspianBoat", "target": "AuroraCrew", "weight": 1 }, + { "edge_type": "OPERATES_IN", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-113", "source": "CaspianBoat", "target": "Caspian", "weight": 1 }, + + // --- Schematic Moscow Metro 2087 — generated from metro2087/data --- + ...tunnels, + ], + nodes: [ + // --- Persons -------------------------------------------------------- + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Artyom", "title": "Ranger", "home": "VDNKh", "description": "Raised at VDNKh by his stepfather Sukhoi, Artyom answered Hunter's call in 2033 and journeyed across the metro to call down a missile strike on the Dark Ones — only to discover in Last Light that they were trying to talk to him, not destroy. By Exodus he commands the Aurora's expedition east." }, "ref_id": "Artyom" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Anna", "role": "Sniper", "description": "Miller's daughter and the Order's deadliest sharpshooter. Marries Artyom on the Aurora and bears the cost of his radiation poisoning through Exodus." }, "ref_id": "Anna" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Colonel Miller", "rank": "Colonel", "description": "Commander of the Spartan Order and master of the Aurora. Hard-line and pragmatic — believes only the surviving metro is worth fighting for, until Anna and Artyom prove him wrong on the Volga." }, "ref_id": "Miller" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Khan", "occupation": "Mystic stalker", "description": "Buddhist-aligned wanderer who walks tunnels closed to other men. Artyom's first guide in 2033; reappears in Last Light to push him toward the Dark Ones' truth." }, "ref_id": "Khan" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Hunter", "title": "Ranger", "description": "The Spartan who set Artyom on the road in 2033 with the words \"If it's hostile, you kill it\" — and was killed by the Dark Ones at VDNKh. Reborn as the Brigadier of Sevastopolskaya in Metro 2034." }, "ref_id": "Hunter" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Pavel Morozov", "faction": "Red Line", "description": "Charismatic Red Line officer who befriends and betrays Artyom in Last Light. The series' best-written villain — half mentor, half executioner." }, "ref_id": "Pavel" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Bourbon", "occupation": "Mercenary", "description": "Foul-mouthed stalker who takes Artyom south of VDNKh in 2033, trading curses for vodka. Killed by Watchmen before they reach Polis." }, "ref_id": "Bourbon" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Ulman", "title": "Ranger" }, "ref_id": "Ulman" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Alex Sukhoi", "relation": "Artyom's stepfather" }, "ref_id": "Sukhoi" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Damir", "origin": "Tatar" }, "ref_id": "Damir" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Tokarev" }, "ref_id": "Tokarev" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Stepan" }, "ref_id": "Stepan" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Krest", "background": "veteran" }, "ref_id": "Krest" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Idiot", "real_name": "Yermak" }, "ref_id": "Idiot" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Sam", "background": "US Marine" }, "ref_id": "Sam" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Khlebnikov", "rank": "Colonel" }, "ref_id": "Khlebnikov" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Kirill" }, "ref_id": "Kirill" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Person", "properties": { "name": "Lesnitsky" }, "ref_id": "Lesnitsky" }, + + // --- Factions / Organizations --------------------------------------- + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Order of the Spartans", "alias": "Rangers", "description": "Elite paramilitary order garrisoning D6 — the pre-war bunker beneath the metro. Equal parts soldiers, scholars, and stalkers. Take their oath from Polis but answer to Miller." }, "ref_id": "SpartanOrder" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Red Line", "ideology": "Communist", "description": "Successor state to the Soviet Union claiming the red Sokolnicheskaya line. Ruled from a chain of NKVD-style purges; uses biological warfare and propaganda as readily as bullets." }, "ref_id": "RedLine" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Fourth Reich", "ideology": "Neo-Nazi", "description": "Neo-Nazi enclave squatting at Tverskaya / Pushkinskaya / Chekhovskaya. Reviled by every other faction — even Hansa won't trade with them." }, "ref_id": "FourthReich" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Hansa", "alias": "Commonwealth of the Ring Line", "description": "Pragmatic trade federation controlling the entire brown Koltsevaya ring. Owns the metro's economy — MGR cartridges are accepted everywhere because Hansa says so." }, "ref_id": "Hansa" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Polis", "ideology": "Neutral", "description": "Alliance of four central stations under the State Library. Home to the Brahmins (keepers of knowledge) and the Order of the Spartans. The closest the metro has to a civilization." }, "ref_id": "Polis" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Aurora Crew", "description": "Spartan expedition aboard the Aurora — Miller, Artyom, Anna, Damir, Tokarev, Stepan, Krest, Idiot, and Sam. The first humans in two decades to learn the metro is not the only home left in Russia." }, "ref_id": "AuroraCrew" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Cult of the Great Worm" }, "ref_id": "GreatWormCult" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Watchmen of the Taiga", "alias": "Pioneers" }, "ref_id": "TaigaWatchmen" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Children of the Forest" }, "ref_id": "ChildrenOfForest" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Caspian Slavers" }, "ref_id": "CaspianPirates" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Organization", "properties": { "name": "Stalkers", "alias": "surface scouts", "ideology": "Independent" }, "ref_id": "Stalkers" }, + + // --- Locations (non-station) ---------------------------------------- + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Moscow Metro", "context": "post-nuclear shelter network" }, "ref_id": "MoscowMetro" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "D6", "type": "Pre-war military bunker" }, "ref_id": "D6" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Volga River", "season": "winter" }, "ref_id": "Volga" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Caspian Sea", "season": "summer", "biome": "desert" }, "ref_id": "Caspian" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Taiga", "season": "autumn", "biome": "forest" }, "ref_id": "Taiga" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Yamantau", "type": "Pre-war bunker" }, "ref_id": "Yamantau" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Lake Baikal", "destination": "true" }, "ref_id": "Baikal" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Novosibirsk", "irradiation": "extreme" }, "ref_id": "Novosibirsk" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Dead City", "context": "Moscow surface" }, "ref_id": "DeadCity" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Location", "properties": { "name": "Surface", "context": "Irradiated outdoors" }, "ref_id": "Surface" }, + + // --- Transport (vehicles) ------------------------------------------- + { "date_added_to_graph": 1778230600.0, "node_type": "Transport", "properties": { "name": "Aurora", "type": "Steam locomotive", "role": "Spartan expedition train", "description": "Armored steam locomotive commandeered by the Spartans for the eastward journey of Exodus. Carries the entire Aurora Crew between Moscow, the Volga, the Caspian, the Taiga, and finally the shores of Baikal." }, "ref_id": "Aurora" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Transport", "properties": { "name": "Handcar", "type": "Hand-pumped railcar", "role": "Stalker tunnel transit" }, "ref_id": "Handcar" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Transport", "properties": { "name": "Hansa Caravan", "type": "Trade train", "role": "Ring-line trade convoys" }, "ref_id": "HansaTrain" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Transport", "properties": { "name": "Caspian Sailboat", "type": "Sail-powered boat", "role": "Coastal travel" }, "ref_id": "CaspianBoat" }, + + // --- Schematic Moscow Metro 2087 stations (generated) ------------- + ...stationNodes, + + // --- Creatures (mutants) ------------------------------------------- + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Nosalis", "type": "rat-like mutant" }, "ref_id": "Nosalis" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Lurker", "type": "tunnel scavenger" }, "ref_id": "Lurker" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Watcher", "type": "canine mutant" }, "ref_id": "Watcher" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Demon", "type": "winged predator" }, "ref_id": "Demon" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Librarian", "type": "ape-like mutant", "description": "Towering simian mutants that haunt the Great Library above Biblioteka Lenina. Intelligent enough to issue territorial threat displays — and lethal if you break eye contact. The Spartans' worst stalking ground.", "images": ["/images/librarian.jpg", "/images/librarian1.webp", "/images/librarian2.webp"] }, "ref_id": "Librarian" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Dark Ones", "alias": "Black Ones", "trait": "telepathic", "description": "Next-generation psychic mutants born from the irradiated surface. Hunted and exterminated in 2033 as the metro's nightmare — revealed in Last Light to have been trying to reach humanity, not destroy it." }, "ref_id": "DarkOnes" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Shrimp", "type": "aquatic mutant" }, "ref_id": "Shrimp" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Humanimal", "type": "feral child mutant" }, "ref_id": "Humanimal" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Watchman", "type": "intelligent canine pack" }, "ref_id": "Watchman" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Creature", "properties": { "name": "Mutant Bear" }, "ref_id": "MutantBear" }, + + // --- Weapons -------------------------------------------------------- + { "date_added_to_graph": 1778230600.0, "node_type": "Weapon", "properties": { "name": "Bastard Gun", "type": "improvised SMG" }, "ref_id": "Bastard" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Weapon", "properties": { "name": "Tikhar", "type": "pneumatic rifle" }, "ref_id": "Tikhar" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Weapon", "properties": { "name": "Helsing", "type": "pneumatic crossbow" }, "ref_id": "Helsing" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Weapon", "properties": { "name": "Ashot", "type": "sawn-off shotgun pistol" }, "ref_id": "Ashot" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Weapon", "properties": { "name": "Volt Driver", "type": "improvised electric weapon" }, "ref_id": "VoltDriver" }, + + // --- Items / survival gear ----------------------------------------- + { "date_added_to_graph": 1778230600.0, "node_type": "Item", "properties": { "name": "Gas Mask", "purpose": "Surface survival" }, "ref_id": "GasMask" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Item", "properties": { "name": "Filter", "duration_minutes": "5", "purpose": "Air filtration cartridge" }, "ref_id": "Filter" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Item", "properties": { "name": "Pneumatic Charger", "purpose": "Pressurises air-powered weapons" }, "ref_id": "Charger" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Item", "properties": { "name": "Military-Grade Round", "alias": "MGR", "purpose": "Pre-war currency in the Metro" }, "ref_id": "MGR" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Item", "properties": { "name": "Medkit", "purpose": "Field first aid" }, "ref_id": "Medkit" }, + { "date_added_to_graph": 1778230600.0, "node_type": "Item", "properties": { "name": "Workbench", "purpose": "Crafting and weapon upgrades" }, "ref_id": "Workbench" }, + ], +} diff --git a/src/data/metro2087-data.ts b/src/data/metro2087-data.ts new file mode 100644 index 00000000..dd1fc7c2 --- /dev/null +++ b/src/data/metro2087-data.ts @@ -0,0 +1,362 @@ +// Moscow Metro 2087 — topology + lore. +// Coordinates are 2D plan view (x = east, z = north). The graph viz flips z +// (mapZ = -z) so north points "into the screen" in three.js space. +// +// Coordinates extracted directly from the official 2012 Moscow Metro SVG +// schematic (origin (1476, 1477), scale 25 SVG units = 1 our unit). +// Ring radius ≈ 15. Stations match the schematic's exact positions. + +export type Status = + | "stronghold" + | "neutral" + | "anomaly" + | "scorched" + | "flood" + | "quarantine" + | "lost" + | "free" + | "iron" + | "swamp" + | "commune" + | "central" + | "union" + | "commune-line" + | "central-zone" + +export type Faction = + | "union" + | "central" + | "commune" + | "iron" + | "free" + | "swamp" + | "none" + +export interface Station { + id: string + ru: string + en: string + x: number + z: number + status: Status + faction: Faction + note?: string +} + +export interface Line { + id: string + name: string + en: string + color: number + depthBias: number + closed?: boolean + stations: string[] +} + +export interface Ruin { + x: number + z: number + w: number + d: number + h: number + label: string +} + +const S: Record = {} +const def = ( + id: string, + ru: string, + en: string, + x: number, + z: number, + status: Status = "neutral", + faction: Faction = "none", + note?: string, +): void => { + S[id] = { id, ru, en, x, z, status, faction, note } +} + +// ===== Ring (Koltsevaya) — 12 stations, R≈15 ===== +def("belorusskaya_k", "Белорусская", "Belorusskaya", -10.68, 10.6, "stronghold", "union") +def("novoslobodskaya", "Новослободская", "Novoslobodskaya", -4.36, 14.4, "stronghold", "union") +def("prospekt_mira_k", "Проспект Мира", "Prospekt Mira", 6.68, 13.32, "stronghold", "union") +def("komsomolskaya_k", "Комсомольская", "Komsomolskaya", 10.76, 10.2, "stronghold", "union", "three rail termini above — heavy garrison") +def("kurskaya_k", "Курская", "Kurskaya", 14.24, 2.52, "stronghold", "union") +def("taganskaya_k", "Таганская", "Taganskaya", 12.96, -5.92, "stronghold", "union") +def("paveletskaya_k", "Павелецкая", "Paveletskaya", 9.84, -10.08, "stronghold", "union") +def("dobryninskaya", "Добрынинская", "Dobryninskaya", 3.28, -13.76, "stronghold", "union") +def("oktyabrskaya_k", "Октябрьская", "Oktyabrskaya", -3.92, -13.64, "stronghold", "union") +def("park_kultury_k", "Парк Культуры", "Park Kultury", -10.48, -9.88, "stronghold", "union") +def("kievskaya_k", "Киевская", "Kievskaya", -14.32, -3.24, "stronghold", "union") +def("krasnopresnenskaya", "Краснопресненская", "Krasnopresnenskaya", -13.88, 5.52, "stronghold", "union") + +// ===== Sokolnicheskaya (Red, line 1) ===== +def("salaryevo", "Саларьево", "Salaryevo", -23.04, -38.92, "lost") +def("rumyantsevo", "Румянцево", "Rumyantsevo", -23.84, -35.12, "lost") +def("troparyovo", "Тропарёво", "Troparyovo", -23.84, -31.32, "scorched", "commune") +def("yugo_zapadnaya", "Юго-Западная", "Yugo-Zapadnaya", -23.84, -27.32, "stronghold", "commune") +def("prospekt_vernad", "Просп. Вернадского", "Prospekt Vernadskogo", -23.84, -24.12, "neutral", "commune") +def("universitet", "Университет", "Universitet", -21.08, -20.44, "stronghold", "commune", "pre-Fall MGU bunker network") +def("vorobyovy_gory", "Воробьёвы Горы", "Vorobyovy Gory", -17.8, -17.16, "flood", "none", "bridge collapsed — flooded") +def("sportivnaya", "Спортивная", "Sportivnaya", -14.52, -13.88, "neutral", "commune") +def("frunzenskaya", "Фрунзенская", "Frunzenskaya", -12.12, -11.48, "commune-line", "commune") +def("park_kultury_r", "Парк Культуры", "Park Kultury", -9.72, -9.12, "stronghold", "union") +def("kropotkinskaya", "Кропоткинская", "Kropotkinskaya", -7.44, -6.96, "neutral", "central") +def("biblioteka", "Библиотека Ленина", "Biblioteka", -5.04, -4.56, "stronghold", "central", "archive of the Republic") +def("okhotny_ryad", "Охотный Ряд", "Okhotny Ryad", 0.12, 0.6, "stronghold", "central") +def("lubyanka", "Лубянка", "Lubyanka", 2.92, 3.4, "quarantine", "central", "plague vaults — sealed") +def("chistye_prudy", "Чистые Пруды", "Chistye Prudy", 5.8, 6.28, "neutral") +def("krasnye_vorota", "Красные Ворота", "Krasnye Vorota", 7.96, 8.44, "neutral") +def("komsomolskaya_r", "Комсомольская", "Komsomolskaya", 10.72, 11.2, "stronghold", "union") +def("krasnoselskaya", "Красносельская", "Krasnoselskaya", 11.96, 14.48, "commune-line", "commune") +def("sokolniki", "Сокольники", "Sokolniki", 11.96, 18.56, "stronghold", "commune", "commune capital") +def("preobrazhenskaya", "Преображенская пл.", "Preobrazhenskaya", 14.56, 21.92, "commune-line", "commune") +def("cherkizovskaya", "Черкизовская", "Cherkizovskaya", 18.36, 27.32, "scorched", "commune") +def("ulitsa_pod", "Улица Подбельского", "Bulvar Rokossovskogo", 18.36, 29.32, "lost", "commune") + +// ===== Zamoskvoretskaya (Green, line 2) ===== +def("khovrino", "Ховрино", "Khovrino", -20.64, 39.08, "lost") +def("rechnoy_vokzal", "Речной Вокзал", "Rechnoy Vokzal", -20.64, 32.68, "anomaly", "none", "spider nests reported, 2086") +def("vodny_stadion", "Водный Стадион", "Vodny Stadion", -20.64, 29.48, "neutral") +def("voykovskaya", "Войковская", "Voykovskaya", -19.44, 25.64, "free", "free") +def("sokol", "Сокол", "Sokol", -16.48, 22.68, "iron", "iron", "Iron Order outpost") +def("aeroport", "Аэропорт", "Aeroport", -14.88, 21.08, "iron", "iron") +def("dinamo", "Динамо", "Dinamo", -14.08, 18.56, "iron", "iron", "Iron Order capital") +def("belorusskaya_z", "Белорусская", "Belorusskaya", -9.96, 9.88, "stronghold", "union") +def("mayakovskaya", "Маяковская", "Mayakovskaya", -8.04, 7.96, "stronghold", "central") +def("tverskaya", "Тверская", "Tverskaya", -5.0, 4.76, "stronghold", "central") +def("teatralnaya", "Театральная", "Teatralnaya", 0.12, -0.36, "stronghold", "central", "the Theatre — neutral ground") +def("novokuznetskaya", "Новокузнецкая", "Novokuznetskaya", 5.24, -5.48, "neutral") +def("paveletskaya_z", "Павелецкая", "Paveletskaya", 10.52, -10.76, "stronghold", "union") +def("avtozavodskaya", "Автозаводская", "Avtozavodskaya", 15.68, -16.12, "neutral") +def("kolomenskaya", "Коломенская", "Kolomenskaya", 16.88, -22.84, "free", "free") +def("kashirskaya", "Каширская", "Kashirskaya", 16.88, -25.96, "free", "free") +def("kantemirovskaya", "Кантемировская", "Kantemirovskaya", 18.36, -30.08, "neutral") +def("tsaritsyno", "Царицыно", "Tsaritsyno", 20.4, -32.12, "swamp", "swamp", "mutant tribes — DO NOT APPROACH") +def("orekhovo", "Орехово", "Orekhovo", 22.36, -34.08, "lost") +def("domodedovskaya", "Домодедовская", "Domodedovskaya", 26.36, -37.28, "lost") +def("krasnogvardeyskaya", "Красногвардейская", "Krasnogvardeyskaya", 32.92, -37.24, "lost") + +// ===== Arbatsko-Pokrovskaya (Dark Blue, line 3) ===== +def("shchyolkovskaya", "Щёлковская", "Shchyolkovskaya", 34.84, 32.76, "lost") +def("pervomayskaya", "Первомайская", "Pervomayskaya", 31.64, 29.56, "scorched") +def("izmaylovskaya", "Измайловская", "Izmaylovskaya", 28.44, 26.36, "neutral") +def("partizanskaya", "Партизанская", "Partizanskaya", 25.24, 23.16, "neutral", "free") +def("semyonovskaya", "Семёновская", "Semyonovskaya", 22.04, 19.96, "neutral") +def("elektrozavodskaya", "Электрозаводская", "Elektrozavodskaya", 20.6, 17.32, "stronghold", "iron", "munitions plant") +def("baumanskaya", "Бауманская", "Baumanskaya", 19.52, 7.88, "iron", "iron") +def("ploshchad_rev", "Площадь Революции", "Ploshchad Revolyutsii", 0.12, -1.32, "stronghold", "central") +def("arbatskaya", "Арбатская", "Arbatskaya", -5.92, -3.72, "stronghold", "central") +def("smolenskaya", "Смоленская", "Smolenskaya", -11.92, -3.72, "central-zone", "central") +def("park_pobedy", "Парк Победы", "Park Pobedy", -26.28, -7.68, "neutral") +def("slavyansky_b", "Славянский Бульвар", "Slavyansky Bulvar", -32.36, -3.96, "neutral") +def("kuntsevskaya", "Кунцевская", "Kuntsevskaya", -36.2, -0.12, "free", "free", "free traders") +def("molodezhnaya", "Молодёжная", "Molodyozhnaya", -37.84, 4.08, "scorched") +def("krylatskoye", "Крылатское", "Krylatskoye", -37.84, 9.88, "lost") +def("strogino", "Строгино", "Strogino", -37.84, 15.68, "lost") +def("myakinino", "Мякинино", "Myakinino", -37.84, 21.48, "lost") +def("volokolamskaya", "Волоколамская", "Volokolamskaya", -37.84, 28.28, "lost") + +// ===== Filyovskaya (Light Blue, line 4) ===== +def("studencheskaya", "Студенческая", "Studencheskaya", -19.48, -3.16, "neutral") +def("kutuzovskaya", "Кутузовская", "Kutuzovskaya", -24.12, -4.12, "neutral") +def("fili", "Фили", "Fili", -26.64, -3.12, "neutral") +def("bagrationovskaya", "Багратионовская", "Bagrationovskaya", -28.44, -1.32, "neutral") +def("filyovsky_park", "Филёвский Парк", "Filyovsky Park", -30.24, 0.48, "neutral") +def("pionerskaya", "Пионерская", "Pionerskaya", -33.04, 0.88, "neutral") + +// ===== Tagansko-Krasnopresnenskaya (Purple, line 7) ===== +def("planernaya", "Планерная", "Planernaya", -30.24, 39.08, "lost") +def("skhodnenskaya", "Сходненская", "Skhodnenskaya", -30.24, 34.28, "lost") +def("tushinskaya", "Тушинская", "Tushinskaya", -29.2, 26.92, "free", "free", "free traders / leader: Korbut") +def("shchukinskaya", "Щукинская", "Shchukinskaya", -24.8, 22.52, "anomaly") +def("oktyabrskoye_pole", "Октябрьское поле", "Oktyabrskoye Pole", -24.32, 20.08, "iron", "iron") +def("polezhayevskaya", "Полежаевская", "Polezhayevskaya", -23.92, 16.68, "iron", "iron") +def("begovaya", "Беговая", "Begovaya", -19.24, 12.0, "free", "free") +def("ulitsa_1905", "Улица 1905 года", "1905 goda St.", -15.72, 8.48, "free", "free") +def("barrikadnaya", "Баррикадная", "Barrikadnaya", -12.92, 5.52, "central-zone", "central") +def("pushkinskaya", "Пушкинская", "Pushkinskaya", -5.48, 3.96, "stronghold", "central") +def("kuznetsky_most", "Кузнецкий Мост", "Kuznetsky Most", 3.52, 3.96, "central-zone", "central") +def("kitay_gorod", "Китай-Город", "Kitay-Gorod", 6.68, 1.52, "stronghold", "central", "old town — heavy patrols") +def("proletarskaya", "Пролетарская", "Proletarskaya", 13.8, -6.4, "neutral") +def("volgogradsky", "Волгоградский пр.", "Volgogradsky Pr.", 18.6, -7.48, "scorched") +def("tekstilshchiki", "Текстильщики", "Tekstilshchiki", 23.36, -7.48, "lost") +def("kuzminki", "Кузьминки", "Kuzminki", 32.68, -7.48, "lost") +def("ryazansky", "Рязанский пр.", "Ryazansky Pr.", 35.36, -7.48, "lost") +def("vykhino", "Выхино", "Vykhino", 39.36, -7.48, "lost") +def("lermontovsky", "Лермонтовский пр.", "Lermontovsky Pr.", 41.6, -9.72, "lost") + +// ===== Kaluzhsko-Rizhskaya (Orange, line 6) ===== +def("medvedkovo", "Медведково", "Medvedkovo", 6.68, 39.08, "anomaly", "none", "sniper — base of north / north-north") +def("babushkinskaya", "Бабушкинская", "Babushkinskaya", 6.68, 37.08, "scorched") +def("sviblovo", "Свиблово", "Sviblovo", 6.68, 35.08, "neutral") +def("botanichesky", "Ботанический сад", "Botanichesky Sad", 6.68, 32.36, "swamp", "swamp", "overgrown — fungal blooms") +def("vdnkh", "ВДНХ", "VDNKh", 6.68, 27.88, "free", "free") +def("alekseyevskaya", "Алексеевская", "Alekseyevskaya", 6.68, 23.0, "neutral") +def("rizhskaya", "Рижская", "Rizhskaya", 6.68, 17.56, "neutral") +def("sukharevskaya", "Сухаревская", "Sukharevskaya", 6.68, 8.68, "neutral") +def("turgenevskaya", "Тургеневская", "Turgenevskaya", 6.68, 6.08, "central-zone", "central") +def("tretyakovskaya", "Третьяковская", "Tretyakovskaya", 4.28, -5.48, "neutral") +def("shabolovskaya", "Шаболовская", "Shabolovskaya", -4.24, -16.52, "neutral") +def("leninsky_pr", "Ленинский пр.", "Leninsky Pr.", -4.24, -19.12, "neutral") +def("akademicheskaya", "Академическая", "Akademicheskaya", -4.24, -21.72, "neutral") +def("profsoyuznaya", "Профсоюзная", "Profsoyuznaya", -4.24, -24.12, "free", "free") +def("cheryomushki", "Новые Черёмушки", "Novye Cheryomushki", -4.24, -26.12, "free", "free") +def("kaluzhskaya", "Калужская", "Kaluzhskaya", -4.24, -28.72, "neutral") +def("belyayevo", "Беляево", "Belyayevo", -4.24, -31.32, "lost") +def("konkovo", "Коньково", "Konkovo", -4.24, -33.92, "lost") +def("tyoply_stan", "Тёплый Стан", "Tyoply Stan", -4.24, -36.52, "anomaly", "none", "territories of the worm-cult") +def("yasenevo", "Ясенево", "Yasenevo", -4.24, -39.12, "lost") +def("novoyasenevskaya", "Новоясеневская", "Novoyasenevskaya", -4.24, -41.72, "lost") + +// ===== Serpukhovsko-Timiryazevskaya (Gray, line 9) ===== +def("altufyevo", "Алтуфьево", "Altufyevo", -0.32, 39.08, "lost", "none", "Spartan command — last contact 2083") +def("bibirevo", "Бибирево", "Bibirevo", -0.32, 37.08, "lost") +def("otradnoye", "Отрадное", "Otradnoye", -0.32, 35.08, "lost") +def("vladykino", "Владыкино", "Vladykino", -0.32, 32.36, "anomaly") +def("petrovsko_raz", "Петровско-Разум.", "Petrovsko-Razum.", -3.68, 28.28, "free", "free") +def("timiryazevskaya", "Тимирязевская", "Timiryazevskaya", -6.12, 25.88, "neutral") +def("dmitrovskaya", "Дмитровская", "Dmitrovskaya", -6.52, 22.36, "neutral") +def("savyolovskaya", "Савёловская", "Savyolovskaya", -6.52, 18.08, "neutral") +def("mendeleyevskaya", "Менделеевская", "Mendeleyevskaya", -3.68, 13.68, "neutral") +def("tsvetnoy", "Цветной Бульвар", "Tsvetnoy Bulvar", -1.16, 10.36, "neutral") +def("chekhovskaya", "Чеховская", "Chekhovskaya", -5.96, 4.76, "central-zone", "central") +def("borovitskaya", "Боровицкая", "Borovitskaya", -5.92, -4.56, "central-zone", "central") +def("polyanka", "Полянка", "Polyanka", 0.72, -11.2, "central-zone", "central") +def("serpukhovskaya", "Серпуховская", "Serpukhovskaya", 3.96, -14.44, "neutral") +def("tulskaya", "Тульская", "Tulskaya", 6.04, -16.52, "commune-line", "commune") +def("nagatinskaya", "Нагатинская", "Nagatinskaya", 7.56, -19.92, "commune-line", "commune") +def("nagornaya", "Нагорная", "Nagornaya", 7.56, -22.84, "neutral") +def("nakhimovsky", "Нахимовский пр.", "Nakhimovsky Pr.", 7.56, -25.32, "neutral") +def("sevastopolskaya", "Севастопольская", "Sevastopolskaya", 7.56, -27.76, "free", "free", "military intel — Sevastopol garrison") +def("chertanovskaya", "Чертановская", "Chertanovskaya", 7.56, -30.12, "lost") +def("yuzhnaya", "Южная", "Yuzhnaya", 7.56, -32.72, "lost") +def("prazhskaya", "Пражская", "Prazhskaya", 7.56, -35.32, "lost") +def("annino", "Аннино", "Annino", 7.56, -37.92, "lost") +def("bulvar_dd", "Б. Дм. Донского", "Bulvar D.Donskogo", 7.56, -40.52, "lost") + +// ===== Kalininskaya (Yellow, line 8) ===== +def("marksistskaya", "Марксистская", "Marksistskaya", 12.96, -6.88, "neutral") +def("ploshchad_il", "Площадь Ильича", "Ploshchad Ilyicha", 18.88, -2.56, "neutral") +def("aviamotornaya", "Авиамоторная", "Aviamotornaya", 29.0, 15.8, "anomaly", "none", "“Engine’s Breath” anomaly") +def("shosse_ent", "Шоссе Энтузиастов", "Shosse Entuziastov", 33.76, 16.48, "scorched") +def("perovo", "Перово", "Perovo", 38.56, 16.48, "lost") +def("novogireevo", "Новогиреево", "Novogireevo", 42.96, 16.48, "lost") + +// ===== Lyublinsko-Dmitrovskaya (Light Green, line 10) ===== +def("maryina_roshcha", "Марьина Роща", "Maryina Roshcha", -8.44, 35.88, "neutral") +def("dostoyevskaya", "Достоевская", "Dostoyevskaya", -7.96, 33.32, "neutral") +def("trubnaya", "Трубная", "Trubnaya", -5.92, 31.28, "central-zone", "central") +def("sretensky_b", "Сретенский б.", "Sretensky Bulvar", -2.92, 28.28, "central-zone", "central") +def("chkalovskaya", "Чкаловская", "Chkalovskaya", 6.0, 5.4, "neutral") +def("rimskaya", "Римская", "Rimskaya", 14.24, 1.08, "neutral", "none", "old town — “Venice”") +def("krestyanskaya", "Крестьянская Зст.", "Krestyanskaya Z.", 17.88, -2.56, "neutral") +def("dubrovka", "Дубровка", "Dubrovka", 18.6, -8.4, "scorched") +def("kozhukhovskaya", "Кожуховская", "Kozhukhovskaya", 20.0, -12.6, "lost") +def("pechatniki", "Печатники", "Pechatniki", 22.2, -14.8, "lost") +def("volzhskaya", "Волжская", "Volzhskaya", 29.72, -15.8, "lost") +def("lyublino", "Люблино", "Lyublino", 32.92, -20.68, "lost") +def("bratislavskaya", "Братиславская", "Bratislavskaya", 32.92, -23.28, "lost") +def("maryino", "Марьино", "Maryino", 32.92, -25.88, "lost") + +export const stations: Record = S + +export const lines: Line[] = [ + { + id: "circle", name: "Кольцевая", en: "Koltsevaya", color: 0xb6724a, depthBias: 0.0, closed: true, + stations: ["belorusskaya_k","novoslobodskaya","prospekt_mira_k","komsomolskaya_k","kurskaya_k","taganskaya_k","paveletskaya_k","dobryninskaya","oktyabrskaya_k","park_kultury_k","kievskaya_k","krasnopresnenskaya"], + }, + { + id: "sokol", name: "Сокольническая", en: "Sokolnicheskaya", color: 0xe53935, depthBias: 0.4, + stations: ["salaryevo","rumyantsevo","troparyovo","yugo_zapadnaya","prospekt_vernad","universitet","vorobyovy_gory","sportivnaya","frunzenskaya","park_kultury_r","kropotkinskaya","biblioteka","okhotny_ryad","lubyanka","chistye_prudy","krasnye_vorota","komsomolskaya_r","krasnoselskaya","sokolniki","preobrazhenskaya","cherkizovskaya","ulitsa_pod"], + }, + { + id: "zamosk", name: "Замоскворецкая", en: "Zamoskvoretskaya", color: 0x44b85d, depthBias: 0.8, + stations: ["khovrino","rechnoy_vokzal","vodny_stadion","voykovskaya","sokol","aeroport","dinamo","belorusskaya_z","mayakovskaya","tverskaya","teatralnaya","novokuznetskaya","paveletskaya_z","avtozavodskaya","kolomenskaya","kashirskaya","kantemirovskaya","tsaritsyno","orekhovo","domodedovskaya","krasnogvardeyskaya"], + }, + { + id: "arbat", name: "Арбатско-Покровская", en: "Arbatsko-Pokrovskaya", color: 0x1f5fb1, depthBias: 1.2, + stations: ["volokolamskaya","myakinino","strogino","krylatskoye","molodezhnaya","kuntsevskaya","slavyansky_b","park_pobedy","kievskaya_k","smolenskaya","arbatskaya","ploshchad_rev","kurskaya_k","baumanskaya","elektrozavodskaya","semyonovskaya","partizanskaya","izmaylovskaya","pervomayskaya","shchyolkovskaya"], + }, + { + id: "filyov", name: "Филёвская", en: "Filyovskaya", color: 0x4cc4ee, depthBias: 1.6, + stations: ["kievskaya_k","studencheskaya","kutuzovskaya","fili","bagrationovskaya","filyovsky_park","pionerskaya","kuntsevskaya"], + }, + { + id: "kalrij", name: "Калужско-Рижская", en: "Kaluzhsko-Rizhskaya", color: 0xee8033, depthBias: 2.0, + stations: ["medvedkovo","babushkinskaya","sviblovo","botanichesky","vdnkh","alekseyevskaya","rizhskaya","prospekt_mira_k","sukharevskaya","turgenevskaya","kitay_gorod","tretyakovskaya","oktyabrskaya_k","shabolovskaya","leninsky_pr","akademicheskaya","profsoyuznaya","cheryomushki","kaluzhskaya","belyayevo","konkovo","tyoply_stan","yasenevo","novoyasenevskaya"], + }, + { + id: "tagan", name: "Таганско-Краснопр.", en: "Tagansko-Krasnopresnenskaya", color: 0x8d59a8, depthBias: 2.4, + stations: ["planernaya","skhodnenskaya","tushinskaya","shchukinskaya","oktyabrskoye_pole","polezhayevskaya","begovaya","ulitsa_1905","barrikadnaya","pushkinskaya","kuznetsky_most","kitay_gorod","taganskaya_k","proletarskaya","volgogradsky","tekstilshchiki","kuzminki","ryazansky","vykhino","lermontovsky"], + }, + { + id: "kalin", name: "Калининская", en: "Kalininskaya", color: 0xe5b53a, depthBias: 2.8, + stations: ["tretyakovskaya","marksistskaya","ploshchad_il","aviamotornaya","shosse_ent","perovo","novogireevo"], + }, + { + id: "serp", name: "Серпуховско-Тимир.", en: "Serpukhovsko-Timir.", color: 0x9aa3a8, depthBias: 3.2, + stations: ["altufyevo","bibirevo","otradnoye","vladykino","petrovsko_raz","timiryazevskaya","dmitrovskaya","savyolovskaya","mendeleyevskaya","tsvetnoy","chekhovskaya","borovitskaya","polyanka","serpukhovskaya","tulskaya","nagatinskaya","nagornaya","nakhimovsky","sevastopolskaya","chertanovskaya","yuzhnaya","prazhskaya","annino","bulvar_dd"], + }, + { + id: "lyub", name: "Люблинско-Дмитр.", en: "Lyublinsko-Dmitr.", color: 0x9bcc55, depthBias: 3.6, + stations: ["maryina_roshcha","dostoyevskaya","trubnaya","sretensky_b","chkalovskaya","rimskaya","krestyanskaya","dubrovka","kozhukhovskaya","pechatniki","volzhskaya","lyublino","bratislavskaya","maryino"], + }, +] + +export const ruins: Ruin[] = [ + { x: 0, z: 0, w: 4, d: 4, h: 3.0, label: "KREMLIN" }, + { x: -16, z: -10, w: 2, d: 2, h: 2.0, label: "MGU" }, + { x: -1, z: 21, w: 2, d: 2, h: 2.5, label: "OSTANKINO" }, + { x: 18, z: -2, w: 1.5, d: 1.5, h: 1.6, label: "YAUZA" }, + { x: -3, z: 12, w: 2, d: 2, h: 1.4, label: "STATION" }, + { x: 12, z: 5, w: 3, d: 2, h: 1.6, label: "TERMINI" }, + { x: -20, z: -3, w: 2, d: 2, h: 2.0, label: "POBEDY" }, + { x: 10, z: -38, w: 2, d: 2, h: 1.4, label: "TSARITSYNO" }, +] + +export const STATUS_COLOR: Record = { + stronghold: 0xe9c970, + "commune-line": 0xe53935, + "central-zone": 0xdadcc8, + neutral: 0x9aa3a8, + anomaly: 0xc43a2c, + scorched: 0x7a4a2a, + flood: 0x5a8aa8, + quarantine: 0xcc7733, + lost: 0x2a2a2a, + free: 0xa5b48a, + iron: 0x6f7a82, + swamp: 0x7d6a4a, + commune: 0xe53935, + central: 0xdadcc8, + union: 0xe9c970, +} + +export const FACTION_NAME_RU: Record = { + union: "Союз Кольца", + central: "Оплот Центра", + commune: "Коммуна", + iron: "Орден Железа", + free: "Вольные", + swamp: "Болотники", + none: "—", +} + +export const STATUS_NAME_RU: Record = { + stronghold: "Оплот", + neutral: "Нейтральная", + anomaly: "Аномалия", + scorched: "Выжжено", + flood: "Затоплено", + quarantine: "Карантин", + lost: "Потеряно", + "commune-line": "Линия Коммуны", + "central-zone": "Зона Оплота", + free: "Вольные торговцы", + iron: "Орден Железа", + swamp: "Болотники", + commune: "Коммуна", + central: "Оплот", + union: "Союз Кольца", +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 00000000..729044ce --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,6 @@ +// Theme detection — single source of truth for metro-vs-default visuals. +// Read from NEXT_PUBLIC_THEME so swapping themes is a deploy-config concern +// rather than a code change. +export function isMetroTheme(): boolean { + return process.env.NEXT_PUBLIC_THEME === "metro" +} From 04c8fb5f54bbfbe86ad931e53640f339e69091f6 Mon Sep 17 00:00:00 2001 From: Rassl Date: Mon, 25 May 2026 22:12:12 +0400 Subject: [PATCH 02/19] feat: bullet change --- src/components/boost/boost-button.tsx | 6 ++-- src/components/layout/my-content-panel.tsx | 5 +-- src/components/layout/node-preview-panel.tsx | 11 +++--- src/components/layout/node-row.tsx | 7 ++-- src/components/layout/toolkit.tsx | 10 +++--- src/components/modals/add-content-modal.tsx | 13 ++++--- src/components/modals/add-node-modal.tsx | 6 ++-- src/components/modals/budget-modal.tsx | 35 ++++++++++--------- src/components/ui/bullet-icon.tsx | 34 ++++++++++++++++++ src/lib/__tests__/add-content-modal.test.tsx | 2 +- src/lib/__tests__/budget-modal.test.tsx | 18 +++++----- src/lib/__tests__/my-content-page.test.tsx | 12 +++---- src/lib/__tests__/node-preview-panel.test.tsx | 30 ++++++++-------- src/lib/__tests__/toolkit-fab.test.tsx | 4 +-- 14 files changed, 117 insertions(+), 76 deletions(-) create mode 100644 src/components/ui/bullet-icon.tsx diff --git a/src/components/boost/boost-button.tsx b/src/components/boost/boost-button.tsx index ee362c2d..9ba094d5 100644 --- a/src/components/boost/boost-button.tsx +++ b/src/components/boost/boost-button.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useState } from "react" -import { Zap } from "lucide-react" +import { BulletIcon } from "@/components/ui/bullet-icon" import { cn } from "@/lib/utils" import { api } from "@/lib/api" import { isMocksEnabled } from "@/lib/mock-data" @@ -97,7 +97,7 @@ export function BoostButton({ className )} > - 0 ? count : DEFAULT_BOOST_AMOUNT} - {count > 0 ? "sats" : "boost"} + {count > 0 ? "bullets" : "boost"} {error && ( diff --git a/src/components/layout/my-content-panel.tsx b/src/components/layout/my-content-panel.tsx index d6b8b0c1..113e3321 100644 --- a/src/components/layout/my-content-panel.tsx +++ b/src/components/layout/my-content-panel.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { Socket } from "socket.io-client" import { getSocket } from "@/lib/socket" import { X, Loader2, BookMarked, Trash2, ShoppingBag } from "lucide-react" +import { BulletIcon } from "@/components/ui/bullet-icon" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" @@ -351,8 +352,8 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { ) : insights && insights.total_unlocks > 0 ? (
-

⚡ {insights.total_sats_earned}

-

sats earned

+

{insights.total_sats_earned}

+

bullets earned

🔓 {insights.total_unlocks}

diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index 70f7edb3..dbad246b 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react" import { ArrowLeft, Link, Zap, Loader2, Play, Film, ExternalLink, Heart, Repeat2, ChevronDown, ChevronUp, MessageCircle, Quote, Eye, BadgeCheck, AtSign, HeartOff, X, Pencil, FlaskConical, GitMerge, MoreHorizontal } from "lucide-react" +import { BulletIcon } from "@/components/ui/bullet-icon" import { Badge } from "@/components/ui/badge" import { BoostButton } from "@/components/boost/boost-button" @@ -1260,8 +1261,8 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp )}
)} @@ -1276,7 +1277,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp

Unlock failed — tap to retry

@@ -1335,9 +1336,9 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp )} {sats !== null && (
- + {sats} - sats + bullets
)}
diff --git a/src/components/layout/node-row.tsx b/src/components/layout/node-row.tsx index ae723507..ca87b3eb 100644 --- a/src/components/layout/node-row.tsx +++ b/src/components/layout/node-row.tsx @@ -1,7 +1,8 @@ "use client" import { useState } from "react" -import { Zap, ExternalLink } from "lucide-react" +import { ExternalLink } from "lucide-react" +import { BulletIcon } from "@/components/ui/bullet-icon" import { getSchemaIconInfo } from "@/lib/schema-icons" import { Badge } from "@/components/ui/badge" import { BoostButton } from "@/components/boost/boost-button" @@ -210,9 +211,9 @@ export function NodeRow({ )} {!hideBoost && !ownerReference && boostAmt > 0 && (
- + {boostAmt} - sats + bullets
)} diff --git a/src/components/layout/toolkit.tsx b/src/components/layout/toolkit.tsx index fef51843..80bb77e0 100644 --- a/src/components/layout/toolkit.tsx +++ b/src/components/layout/toolkit.tsx @@ -6,7 +6,6 @@ import { Layers, Plus, Settings, - Zap, Network, BookMarked, Tag, @@ -18,6 +17,7 @@ import { Cpu, GitMerge, } from "lucide-react" +import { BulletIcon } from "@/components/ui/bullet-icon" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { useUserStore } from "@/stores/user-store" import { useModalStore } from "@/stores/modal-store" @@ -129,7 +129,7 @@ export function Toolkit({ const formattedBudget = budget !== null && budget !== undefined ? formatSatsCompact(budget) : "--" const fullBudget = - budget !== null && budget !== undefined ? `${budget.toLocaleString()} sats` : "Manage budget" + budget !== null && budget !== undefined ? `${budget.toLocaleString()} bullets` : "Manage budget" const sphinxConnected = typeof window !== "undefined" && isSphinx() const weblnAvailable = typeof window !== "undefined" && hasWebLN() @@ -151,7 +151,7 @@ export function Toolkit({ onClick={() => openModal("budget")} className="group flex flex-col items-center justify-center gap-0.5 px-1.5 py-1.5 rounded-md text-muted-foreground hover:bg-muted/40 transition-colors" > - + {formattedBudget} @@ -309,8 +309,8 @@ export function ToolkitFAB({ onClick={() => { openModal("budget"); setOpen(false) }} className="flex items-center gap-2 px-3 py-2 rounded-md text-muted-foreground hover:bg-muted/40" > - - {formattedBudget} sats + + {formattedBudget} bullets {/* Connection indicator */}
diff --git a/src/components/modals/add-content-modal.tsx b/src/components/modals/add-content-modal.tsx index 111ec324..c0670615 100644 --- a/src/components/modals/add-content-modal.tsx +++ b/src/components/modals/add-content-modal.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react" import { Loader2, CheckCircle2, LinkIcon, Zap, X, RefreshCw } from "lucide-react" +import { BulletIcon } from "@/components/ui/bullet-icon" import { Dialog, DialogContent, @@ -499,17 +500,17 @@ export function AddContentModal() {
- + Cost
- {price} sats + {price} bullets
Budget - {formattedBudget} sats + {formattedBudget} bullets
@@ -528,7 +529,7 @@ export function AddContentModal() { {/* Anon-loss disclosure */} {!pubKey && (

- Earnings are credited to this browser's L402. Clearing storage will lose your sats. + Earnings are credited to this browser's L402. Clearing storage will lose your bullets.

)} @@ -571,7 +572,9 @@ export function AddContentModal() { ) : ( <> - {(price && price > 0) || cacheStatus === "hit-completed" ? ( + {price && price > 0 ? ( + + ) : cacheStatus === "hit-completed" ? ( ) : null} {submitLabel} diff --git a/src/components/modals/add-node-modal.tsx b/src/components/modals/add-node-modal.tsx index ca38d6cc..9f536bc8 100644 --- a/src/components/modals/add-node-modal.tsx +++ b/src/components/modals/add-node-modal.tsx @@ -464,14 +464,14 @@ export function AddNodeModal() { {/* Anon-loss disclosure */} {!pubKey && (

- Earnings are credited to this browser's L402. Clearing storage will lose your sats. + Earnings are credited to this browser's L402. Clearing storage will lose your bullets.

)} {/* Price + Submit */}
{price !== null && price > 0 ? ( - {price} sats + {price} bullets ) : ( )} @@ -492,7 +492,7 @@ export function AddNodeModal() { const verb = selectedSchema ? `Add ${selectedSchema.type}` : "Add" - return price && price > 0 ? `${verb} · ${price} sats` : verb + return price && price > 0 ? `${verb} · ${price} bullets` : verb })()}
diff --git a/src/components/modals/budget-modal.tsx b/src/components/modals/budget-modal.tsx index fa72abc6..8b502b58 100644 --- a/src/components/modals/budget-modal.tsx +++ b/src/components/modals/budget-modal.tsx @@ -1,7 +1,8 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" -import { Zap, Copy, Check, Loader2, ArrowLeft, History, Key, RefreshCw, ArrowUpRight, Clock } from "lucide-react" +import { Copy, Check, Loader2, ArrowLeft, History, Key, RefreshCw, ArrowUpRight, Clock } from "lucide-react" +import { BulletIcon } from "@/components/ui/bullet-icon" import { QRCodeSVG } from "qrcode.react" import { Dialog, @@ -336,7 +337,7 @@ export function BudgetModal() { // Pay with selected amount — same flow for Sphinx, WebLN, and manual const handlePay = useCallback(async () => { if (!amount || amount < 1 || amount > 10000) { - setError("Enter an amount between 1 and 10,000 sats.") + setError("Enter an amount between 1 and 10,000 bullets.") return } @@ -491,10 +492,10 @@ export function BudgetModal() { {formattedBudget} - sats + bullets
- +
@@ -511,7 +512,7 @@ export function BudgetModal() { {loading ? ( ) : ( - + )} {loading ? "Processing..." : "Top Up"} @@ -555,10 +556,10 @@ export function BudgetModal() { <>
- +

- Pending invoice for {pendingChallenge.amount.toLocaleString()} sats + Pending invoice for {pendingChallenge.amount.toLocaleString()} bullets

You started a top-up earlier. Pay this invoice or generate a new one below. @@ -573,7 +574,7 @@ export function BudgetModal() { {loading ? ( ) : ( - + )} {loading ? "Checking..." : "Pay Pending Invoice"} @@ -598,7 +599,7 @@ export function BudgetModal() { }`} > {preset} - sats + bullets ))}

@@ -615,7 +616,7 @@ export function BudgetModal() { placeholder="Custom amount" className="h-10 w-full rounded-md border border-border/50 bg-muted/30 px-3 pr-12 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none" /> - sats + bullets
{error &&

{error}

} @@ -628,7 +629,7 @@ export function BudgetModal() { {loading ? ( ) : ( - + )} {loading ? "Generating Invoice..." : "Generate Invoice"} @@ -724,7 +725,7 @@ export function BudgetModal() { {preset} - sats + bullets ))} @@ -743,7 +744,7 @@ export function BudgetModal() { className="h-10 w-full rounded-md border border-border/50 bg-muted/30 px-3 pr-12 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none" /> - sats + bullets
@@ -759,7 +760,7 @@ export function BudgetModal() { {loading ? ( ) : ( - + )} {loading ? "Processing..." @@ -880,7 +881,7 @@ export function BudgetModal() { tx.refunded ? 'text-muted-foreground' : tx.type === 'credit' ? 'text-emerald-400' : 'text-muted-foreground' }`}> - {tx.refunded ? '—' : `${tx.type === 'credit' ? '+' : '-'}${tx.amount} sats`} + {tx.refunded ? '—' : `${tx.type === 'credit' ? '+' : '-'}${tx.amount} bullets`}
))} @@ -973,13 +974,13 @@ export function BudgetModal() {

{successDelta !== null && (

- +{successDelta.toLocaleString()} sats added + +{successDelta.toLocaleString()} bullets added

)}

{formattedBudget} - sats + bullets

diff --git a/src/components/ui/bullet-icon.tsx b/src/components/ui/bullet-icon.tsx new file mode 100644 index 00000000..87a51a74 --- /dev/null +++ b/src/components/ui/bullet-icon.tsx @@ -0,0 +1,34 @@ +import { type SVGProps } from "react" + +import { cn } from "@/lib/utils" + +type BulletIconProps = SVGProps & { + strokeWidth?: number | string +} + +export function BulletIcon({ + className, + strokeWidth = 1.5, + ...rest +}: BulletIconProps) { + return ( + + ) +} diff --git a/src/lib/__tests__/add-content-modal.test.tsx b/src/lib/__tests__/add-content-modal.test.tsx index c6e8dda1..d8b84dfc 100644 --- a/src/lib/__tests__/add-content-modal.test.tsx +++ b/src/lib/__tests__/add-content-modal.test.tsx @@ -205,7 +205,7 @@ describe("AddContentModal — preview probe", () => { await waitFor(() => { expect(screen.getByText("Pay & Unlock")).toBeInTheDocument() }) - expect(screen.getByText(/10 sats/)).toBeInTheDocument() + expect(screen.getByText(/10 bullets/)).toBeInTheDocument() }) it("fallback (network error): modal stays open with Pay & Unlock button", async () => { diff --git a/src/lib/__tests__/budget-modal.test.tsx b/src/lib/__tests__/budget-modal.test.tsx index 434fefb7..52daae49 100644 --- a/src/lib/__tests__/budget-modal.test.tsx +++ b/src/lib/__tests__/budget-modal.test.tsx @@ -123,7 +123,7 @@ describe("BudgetModal success screen delta", () => { cookieStorage.removeItem("l402") }) - it("shows +N sats added after amount-picker top-up (Sphinx/WebLN path)", async () => { + it("shows +N bullets added after amount-picker top-up (Sphinx/WebLN path)", async () => { // Setup: has existing L402 + Sphinx connected cookieStorage.setItem("l402", JSON.stringify({ macaroon: "mac123", preimage: "" })) mockIsSphinx.mockReturnValue(true) @@ -149,11 +149,11 @@ describe("BudgetModal success screen delta", () => { expect(screen.getByText("Top-up complete")).toBeInTheDocument() }) - expect(screen.getByText("+200 sats added")).toBeInTheDocument() + expect(screen.getByText("+200 bullets added")).toBeInTheDocument() expect(screen.getByText(/270/)).toBeInTheDocument() }) - it("shows +N sats added from firstPurchaseAmount after first-purchase QR flow", async () => { + it("shows +N bullets added from firstPurchaseAmount after first-purchase QR flow", async () => { // Setup: no L402, no Sphinx, no WebLN → first-purchase flow mockIsSphinx.mockReturnValue(false) mockHasWebLN.mockReturnValue(false) @@ -178,7 +178,7 @@ describe("BudgetModal success screen delta", () => { expect(screen.getByText("Top-up complete")).toBeInTheDocument() }) - expect(screen.getByText("+500 sats added")).toBeInTheDocument() + expect(screen.getByText("+500 bullets added")).toBeInTheDocument() }) beforeEach(() => { @@ -200,7 +200,7 @@ describe("BudgetModal success screen delta", () => { }) // No delta line should be rendered - expect(screen.queryByText(/sats added/)).not.toBeInTheDocument() + expect(screen.queryByText(/bullets added/)).not.toBeInTheDocument() // Total balance still shows expect(screen.getByText(/270/)).toBeInTheDocument() }) @@ -558,9 +558,9 @@ describe("BudgetModal history view-grant filtering", () => { }) // Zero-amount purchase should be filtered out - expect(screen.queryByText("-0 sats")).not.toBeInTheDocument() + expect(screen.queryByText("-0 bullets")).not.toBeInTheDocument() // top_up row should be present - expect(screen.getByText("+500 sats")).toBeInTheDocument() + expect(screen.getByText("+500 bullets")).toBeInTheDocument() }) it("renders non-zero purchase rows in History", async () => { @@ -578,11 +578,11 @@ describe("BudgetModal history view-grant filtering", () => { await waitFor(() => { // The non-zero purchase row should be visible - expect(screen.getByText("-10 sats")).toBeInTheDocument() + expect(screen.getByText("-10 bullets")).toBeInTheDocument() }) // The zero-amount purchase should NOT appear - expect(screen.queryByText("-0 sats")).not.toBeInTheDocument() + expect(screen.queryByText("-0 bullets")).not.toBeInTheDocument() }) }) diff --git a/src/lib/__tests__/my-content-page.test.tsx b/src/lib/__tests__/my-content-page.test.tsx index 6231129c..ebd81be1 100644 --- a/src/lib/__tests__/my-content-page.test.tsx +++ b/src/lib/__tests__/my-content-page.test.tsx @@ -157,7 +157,7 @@ describe("MyContentPanel", () => { await waitFor(() => { expect(screen.getByText("Bitcoin is freedom")).toBeInTheDocument() }) - expect(screen.queryByText("sats")).not.toBeInTheDocument() + expect(screen.queryByText("bullets")).not.toBeInTheDocument() }) it("renders no boost display when boost is absent", async () => { @@ -174,7 +174,7 @@ describe("MyContentPanel", () => { }) render( {}} />) await waitFor(() => { - expect(screen.queryByText("sats")).not.toBeInTheDocument() + expect(screen.queryByText("bullets")).not.toBeInTheDocument() }) }) @@ -191,7 +191,7 @@ describe("MyContentPanel", () => { ) }) - it("hides boost sats display when node has owner_reference_id (contributor)", async () => { + it("hides boost bullets display when node has owner_reference_id (contributor)", async () => { mockApiGet.mockResolvedValue({ nodes: [ { @@ -213,10 +213,10 @@ describe("MyContentPanel", () => { await waitFor(() => { expect(screen.getByText("Bitcoin is freedom")).toBeInTheDocument() }) - expect(screen.queryByText("sats")).not.toBeInTheDocument() + expect(screen.queryByText("bullets")).not.toBeInTheDocument() }) - it("hides boost sats display when isAdmin is true", async () => { + it("hides boost bullets display when isAdmin is true", async () => { mockApiGet.mockResolvedValue({ nodes: [ { @@ -238,7 +238,7 @@ describe("MyContentPanel", () => { await waitFor(() => { expect(screen.getByText("Bitcoin is freedom")).toBeInTheDocument() }) - expect(screen.queryByText("sats")).not.toBeInTheDocument() + expect(screen.queryByText("bullets")).not.toBeInTheDocument() }) }) diff --git a/src/lib/__tests__/node-preview-panel.test.tsx b/src/lib/__tests__/node-preview-panel.test.tsx index d8e45ee6..889312e0 100644 --- a/src/lib/__tests__/node-preview-panel.test.tsx +++ b/src/lib/__tests__/node-preview-panel.test.tsx @@ -199,7 +199,7 @@ describe("NodePreviewPanel – price display", () => { userStoreOverrides = {} }) - it("renders 'Unlock for 10 sats' when 402 body has price: 10", async () => { + it("renders 'Unlock for 10 bullets' when 402 body has price: 10", async () => { mockApiGet.mockRejectedValue( new Response(JSON.stringify({ price: 10 }), { status: 402, @@ -210,7 +210,7 @@ describe("NodePreviewPanel – price display", () => { render() await waitFor(() => { - expect(screen.getByRole("button", { name: /Unlock for 10 sats/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Unlock for 10 bullets/i })).toBeInTheDocument() }) }) @@ -260,7 +260,7 @@ describe("NodePreviewPanel – price display", () => { ) await waitFor(() => { - expect(screen.getByRole("button", { name: /Unlock for 10 sats/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Unlock for 10 bullets/i })).toBeInTheDocument() }) // Switch to second node with price = 25 @@ -274,11 +274,11 @@ describe("NodePreviewPanel – price display", () => { rerender() // Stale price from first node should be gone - expect(screen.queryByRole("button", { name: /Unlock for 10 sats/i })).toBeNull() + expect(screen.queryByRole("button", { name: /Unlock for 10 bullets/i })).toBeNull() // After second node's 402 resolves, new price appears await waitFor(() => { - expect(screen.getByRole("button", { name: /Unlock for 25 sats/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Unlock for 25 bullets/i })).toBeInTheDocument() }) }) }) @@ -419,7 +419,7 @@ describe("NodePreviewPanel – core property rendering", () => { expect(screen.queryByText(/ago/i)).not.toBeInTheDocument() }) - it("shows sats counter when boost is a positive number", async () => { + it("shows bullets counter when boost is a positive number", async () => { const node = makeUnlockedNode({ boost: 50 }) mockApiGet.mockResolvedValue(makeGraphData(node)) @@ -427,7 +427,7 @@ describe("NodePreviewPanel – core property rendering", () => { await waitFor(() => { expect(screen.getByText("50")).toBeInTheDocument() - expect(screen.getByText("sats")).toBeInTheDocument() + expect(screen.getByText("bullets")).toBeInTheDocument() }) }) @@ -444,7 +444,7 @@ describe("NodePreviewPanel – core property rendering", () => { expect(screen.queryByText("Done")).toBeNull() expect(screen.queryByText("Paused")).toBeNull() expect(screen.queryByText("Failed")).toBeNull() - expect(screen.queryByText("sats")).toBeNull() + expect(screen.queryByText("bullets")).toBeNull() }) }) @@ -697,7 +697,7 @@ describe("NodePreviewPanel – preview=1 probe behaviour", () => { render() await waitFor(() => { - expect(screen.getByRole("button", { name: /Unlock for 15 sats/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Unlock for 15 bullets/i })).toBeInTheDocument() }) }) @@ -714,10 +714,10 @@ describe("NodePreviewPanel – preview=1 probe behaviour", () => { render() await waitFor(() => { - expect(screen.getByRole("button", { name: /Unlock for 5 sats/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Unlock for 5 bullets/i })).toBeInTheDocument() }) - screen.getByRole("button", { name: /Unlock for 5 sats/i }).click() + screen.getByRole("button", { name: /Unlock for 5 bullets/i }).click() await waitFor(() => { expect(mockUnlockNode).toHaveBeenCalledWith("abc") @@ -737,10 +737,10 @@ describe("NodePreviewPanel – preview=1 probe behaviour", () => { render() await waitFor(() => { - expect(screen.getByRole("button", { name: /Unlock for 5 sats/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Unlock for 5 bullets/i })).toBeInTheDocument() }) - screen.getByRole("button", { name: /Unlock for 5 sats/i }).click() + screen.getByRole("button", { name: /Unlock for 5 bullets/i }).click() await waitFor(() => { expect(screen.queryByRole("button", { name: /unlock/i })).toBeNull() @@ -761,10 +761,10 @@ describe("NodePreviewPanel – preview=1 probe behaviour", () => { render() await waitFor(() => { - expect(screen.getByRole("button", { name: /Unlock for 10 sats/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Unlock for 10 bullets/i })).toBeInTheDocument() }) - screen.getByRole("button", { name: /Unlock for 10 sats/i }).click() + screen.getByRole("button", { name: /Unlock for 10 bullets/i }).click() await waitFor(() => { expect(mockOpen).toHaveBeenCalledWith("budget") diff --git a/src/lib/__tests__/toolkit-fab.test.tsx b/src/lib/__tests__/toolkit-fab.test.tsx index d9b601d6..76d6cc77 100644 --- a/src/lib/__tests__/toolkit-fab.test.tsx +++ b/src/lib/__tests__/toolkit-fab.test.tsx @@ -181,13 +181,13 @@ describe("ToolkitFAB", () => { expect(screen.getByText("Reviews (5)")).toBeInTheDocument() }) - it("budget sats display shows formatted value", async () => { + it("budget bullets display shows formatted value", async () => { userState.budget = 2500 const { ToolkitFAB } = await import("@/components/layout/toolkit") render() fireEvent.click(screen.getByRole("button", { name: "Open menu" })) - expect(screen.getByText("2.5k sats")).toBeInTheDocument() + expect(screen.getByText("2.5k bullets")).toBeInTheDocument() }) }) From 8ab090bb222d56572715a557869ee84e46a2ae3d Mon Sep 17 00:00:00 2001 From: Rassl Date: Mon, 25 May 2026 22:31:06 +0400 Subject: [PATCH 03/19] feat: rm metro envs --- src/components/feed/feed-view.tsx | 32 ++------- src/components/universe/graph-canvas.tsx | 86 ++++++++++-------------- src/lib/theme.ts | 6 -- 3 files changed, 39 insertions(+), 85 deletions(-) delete mode 100644 src/lib/theme.ts diff --git a/src/components/feed/feed-view.tsx b/src/components/feed/feed-view.tsx index 14ac7136..6b386574 100644 --- a/src/components/feed/feed-view.tsx +++ b/src/components/feed/feed-view.tsx @@ -7,8 +7,6 @@ import { useGraphStore } from "@/stores/graph-store" import { useAppStore } from "@/stores/app-store" import { useSchemaStore } from "@/stores/schema-store" import { isMocksEnabled, MOCK_NODES, MOCK_EDGES } from "@/lib/mock-data" -import { getLatestNodes } from "@/lib/graph-api" -import { isMetroTheme } from "@/lib/theme" import { metroSeries } from "@/data/metro" import type { GraphNode, GraphEdge } from "@/lib/graph-api" import { FeedCard } from "./feed-card" @@ -23,7 +21,6 @@ export function FeedView() { const setHoveredNode = useGraphStore((s) => s.setHoveredNode) const clearSelection = useGraphStore((s) => s.clearSelection) const setGraphData = useGraphStore((s) => s.setGraphData) - const setLoading = useGraphStore((s) => s.setLoading) const searchTerm = useAppStore((s) => s.searchTerm) const schemas = useSchemaStore((s) => s.schemas) @@ -34,37 +31,16 @@ export function FeedView() { setActiveTypes(new Set()) }, [searchTerm, clearSelection]) - // Mocks mode seeds from fixtures so the Latest feed has content before any search. - // Metro theme bypasses the API: the metro dataset lives only in the local - // fixture (the platform search/list endpoints return a thin projection - // that strips mapX/mapZ, so the overlay can't position bullets from API - // payloads alone). + // Seed from the local metro fixture. The platform search/list endpoints + // strip mapX/mapZ, so the overlay can't position bullets from API payloads + // alone — the fixture is the only source of truth for the schematic. useEffect(() => { if (useGraphStore.getState().nodes.length > 0) return if (isMocksEnabled()) { setGraphData(MOCK_NODES, MOCK_EDGES) return } - if (isMetroTheme()) { - setGraphData(metroSeries.nodes as GraphNode[], metroSeries.edges as GraphEdge[]) - return - } - let cancelled = false - setLoading(true) - ;(async () => { - try { - const result = await getLatestNodes() - if (cancelled) return - setGraphData(result.nodes ?? [], result.edges ?? []) - } catch (err) { - console.error("[feed-view] getLatestNodes failed:", err) - } finally { - if (!cancelled) setLoading(false) - } - })() - return () => { - cancelled = true - } + setGraphData(metroSeries.nodes as GraphNode[], metroSeries.edges as GraphEdge[]) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index fbf07753..21bc2832 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -24,7 +24,6 @@ import { useAppStore } from "@/stores/app-store" import type { SchemaNode } from "@/app/ontology/page" import { HoverPreviewCard } from "./hover-preview-card" import { DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" -import { isMetroTheme } from "@/lib/theme" import { metroSeries } from "@/data/metro" import { MetroLinesLayer, @@ -733,32 +732,26 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const dataVersion = useGraphStore((s) => s.dataVersion) const searchTerm = useAppStore((s) => s.searchTerm) - // The metro overlay is theme-driven, not data-driven. We want the - // schematic to stay visible even when search replaces the graph store - // with results that don't include Station nodes — so the lines, bullets - // and legend always render from the local metro fixture in metro theme, - // independent of whatever the current dataset is. - const isMetroView = isMetroTheme() - const overlayNodes = isMetroView ? (metroSeries.nodes as ApiNode[]) : [] - const overlayEdges = isMetroView ? (metroSeries.edges as ApiEdge[]) : [] - - // In metro theme, the *interactive* station layer (3D spheres + labels + - // hover behavior provided by GraphView) also has to persist through - // search, not just the schematic bullets. So we splice fixture stations - // and TUNNEL_TO edges into whatever the graph store currently has before - // running the radial layout. De-dupe by ref_id / edge identity so a + // The metro overlay always renders from the local fixture so the + // schematic stays visible even when search replaces the graph store + // with results that don't include Station nodes. + const overlayNodes = metroSeries.nodes as ApiNode[] + const overlayEdges = metroSeries.edges as ApiEdge[] + + // The *interactive* station layer (3D spheres + labels + hover behavior + // provided by GraphView) also has to persist through search. Splice fixture + // stations and TUNNEL_TO edges into whatever the graph store currently has + // before running the radial layout. De-dupe by ref_id / edge identity so a // future search that does return a station won't double-render it. const effectiveNodes = useMemo(() => { - if (!isMetroView) return nodes const seen = new Set(nodes.map((n) => n.ref_id)) const fixtureStations = (metroSeries.nodes as ApiNode[]).filter( (n) => n.node_type === "Station" && !seen.has(n.ref_id) ) return fixtureStations.length > 0 ? [...nodes, ...fixtureStations] : nodes - }, [isMetroView, nodes]) + }, [nodes]) const effectiveEdges = useMemo(() => { - if (!isMetroView) return edges const refIds = new Set(effectiveNodes.map((n) => n.ref_id)) const seen = new Set( edges.map((e) => `${e.source}|${e.target}|${e.edge_type}`) @@ -779,16 +772,16 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas refIds.has(e.target) ) return extras.length > 0 ? [...edges, ...extras] : edges - }, [isMetroView, edges, effectiveNodes]) + }, [edges, effectiveNodes]) const { graph, indexMap, refIdToIndex } = useMemo(() => { const result = apiToGraph(effectiveNodes, effectiveEdges, schemas) - // Force the Y-lift on the lore graph in metro theme even when the - // dataset doesn't carry fixed-position nodes (e.g. after a search). - // Otherwise search results would drop to y=0 where the schematic sits. - applyLayout(result.graph, result.fixedPositions, isMetroView) + // Force the Y-lift on the lore graph even when the dataset doesn't carry + // fixed-position nodes (e.g. after a search). Otherwise search results + // would drop to y=0 where the schematic sits. + applyLayout(result.graph, result.fixedPositions, true) return result - }, [effectiveNodes, effectiveEdges, schemas, isMetroView]) + }, [effectiveNodes, effectiveEdges, schemas]) // Lowercase type → schema icon name (e.g. "EpisodeIcon"). The pill in // GraphView resolves this through schema-icons to a Lucide component. @@ -886,9 +879,9 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // Hovering a legend row spotlights every station in that state — reuses // the search-match plumbing in GraphView (highlights members, dims the - // rest). Returns null outside the metro view. + // rest). const stateHoverMatches = useMemo(() => { - if (!isMetroView || !hoveredState) return null + if (!hoveredState) return null const set = new Set() for (let i = 0; i < nodes.length; i++) { if (nodes[i].node_type !== "Station") continue @@ -898,11 +891,11 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas if (statusToState(status, p.faction) === hoveredState) set.add(i) } return set.size > 0 ? set : null - }, [nodes, hoveredState, isMetroView]) + }, [nodes, hoveredState]) // Hovering a metro line spotlights every node tagged with that line. const lineHoverMatches = useMemo(() => { - if (!isMetroView || !hoveredLine) return null + if (!hoveredLine) return null const set = new Set() for (let i = 0; i < nodes.length; i++) { const p = nodes[i].properties as Record | undefined @@ -914,14 +907,13 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas if (lines.includes(hoveredLine)) set.add(i) } return set.size > 0 ? set : null - }, [nodes, hoveredLine, isMetroView]) + }, [nodes, hoveredLine]) // ref_id → set of metro line colors the node is associated with. Stations // contribute their own line property; non-station nodes inherit lines from // any station they share an edge with (1-hop). Used to dim unrelated // lines/bullets when a node is hovered or selected. const nodeToLines = useMemo(() => { - if (!isMetroView) return new Map>() const stationLines = new Map>() for (const n of nodes) { if (n.node_type !== "Station") continue @@ -958,13 +950,12 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas } } return map - }, [nodes, edges, isMetroView]) + }, [nodes, edges]) // Lines currently in focus. Hovering a line directly wins; otherwise the // active node (hover beats select; canvas beats sidebar) contributes its // associated lines. `null` means no dimming — every line at full opacity. const activeLines = useMemo | null>(() => { - if (!isMetroView) return null if (hoveredLine) return new Set([hoveredLine]) let activeRefId: string | null = null if (hoveredCardNode) activeRefId = hoveredCardNode.ref_id @@ -975,7 +966,6 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas if (!activeRefId) return null return nodeToLines.get(activeRefId) ?? new Set() }, [ - isMetroView, hoveredLine, hoveredCardNode, sidebarHoveredNode, @@ -1162,21 +1152,17 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas style={{ background: "oklch(0.06 0.02 260)" }} > - {isMetroView && ( - <> - - - - )} + + - {isMetroView && ( - - )} + ) } diff --git a/src/lib/theme.ts b/src/lib/theme.ts deleted file mode 100644 index 729044ce..00000000 --- a/src/lib/theme.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Theme detection — single source of truth for metro-vs-default visuals. -// Read from NEXT_PUBLIC_THEME so swapping themes is a deploy-config concern -// rather than a code change. -export function isMetroTheme(): boolean { - return process.env.NEXT_PUBLIC_THEME === "metro" -} From 342f212580d87f709fbcd21677c7247474c486a5 Mon Sep 17 00:00:00 2001 From: Rassl Date: Tue, 26 May 2026 03:25:23 +0400 Subject: [PATCH 04/19] feat: map fixtures to db values --- src/components/feed/feed-view.tsx | 50 ++++++++- src/components/layout/node-preview-panel.tsx | 25 +++++ src/components/universe/graph-canvas.tsx | 4 +- src/data/metro.ts | 101 ++++++++++++++++++- src/graph-viz-kit/GraphView.tsx | 22 ++-- 5 files changed, 184 insertions(+), 18 deletions(-) diff --git a/src/components/feed/feed-view.tsx b/src/components/feed/feed-view.tsx index 6b386574..fd7c03d9 100644 --- a/src/components/feed/feed-view.tsx +++ b/src/components/feed/feed-view.tsx @@ -7,6 +7,7 @@ import { useGraphStore } from "@/stores/graph-store" import { useAppStore } from "@/stores/app-store" import { useSchemaStore } from "@/stores/schema-store" import { isMocksEnabled, MOCK_NODES, MOCK_EDGES } from "@/lib/mock-data" +import { getLatestNodes } from "@/lib/graph-api" import { metroSeries } from "@/data/metro" import type { GraphNode, GraphEdge } from "@/lib/graph-api" import { FeedCard } from "./feed-card" @@ -21,6 +22,7 @@ export function FeedView() { const setHoveredNode = useGraphStore((s) => s.setHoveredNode) const clearSelection = useGraphStore((s) => s.clearSelection) const setGraphData = useGraphStore((s) => s.setGraphData) + const setLoading = useGraphStore((s) => s.setLoading) const searchTerm = useAppStore((s) => s.searchTerm) const schemas = useSchemaStore((s) => s.schemas) @@ -31,16 +33,56 @@ export function FeedView() { setActiveTypes(new Set()) }, [searchTerm, clearSelection]) - // Seed from the local metro fixture. The platform search/list endpoints - // strip mapX/mapZ, so the overlay can't position bullets from API payloads - // alone — the fixture is the only source of truth for the schematic. + // Seed from the local metro fixture first — the platform search/list + // endpoints strip mapX/mapZ, so the overlay can't position bullets from + // API payloads alone. Then fetch /v2/nodes/latest and splice in anything + // the backend has that the fixture doesn't (deduped by ref_id). Backend + // Station rows are dropped — they'd float without positions, and the + // fixture is the source of truth for the schematic. useEffect(() => { if (useGraphStore.getState().nodes.length > 0) return if (isMocksEnabled()) { setGraphData(MOCK_NODES, MOCK_EDGES) return } - setGraphData(metroSeries.nodes as GraphNode[], metroSeries.edges as GraphEdge[]) + const fixtureNodes = metroSeries.nodes as GraphNode[] + const fixtureEdges = metroSeries.edges as GraphEdge[] + setGraphData(fixtureNodes, fixtureEdges) + + let cancelled = false + setLoading(true) + ;(async () => { + try { + const result = await getLatestNodes() + if (cancelled) return + const seenNodeIds = new Set(fixtureNodes.map((n) => n.ref_id)) + const seenEdgeKeys = new Set( + fixtureEdges.map((e) => `${e.source}|${e.target}|${e.edge_type}`), + ) + const extraNodes = (result.nodes ?? []).filter( + (n) => n.node_type !== "Station" && !seenNodeIds.has(n.ref_id), + ) + const extraNodeIds = new Set(extraNodes.map((n) => n.ref_id)) + const extraEdges = (result.edges ?? []).filter((e) => { + if (seenEdgeKeys.has(`${e.source}|${e.target}|${e.edge_type}`)) return false + // Both endpoints must exist in the visible node set (fixture or extras) + // — otherwise the edge dangles. Backend station endpoints get filtered + // out here since they were dropped above. + const hasSource = seenNodeIds.has(e.source) || extraNodeIds.has(e.source) + const hasTarget = seenNodeIds.has(e.target) || extraNodeIds.has(e.target) + return hasSource && hasTarget + }) + if (extraNodes.length === 0 && extraEdges.length === 0) return + setGraphData([...fixtureNodes, ...extraNodes], [...fixtureEdges, ...extraEdges]) + } catch (err) { + console.error("[feed-view] getLatestNodes failed:", err) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index dbad246b..2a7992e7 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -31,9 +31,22 @@ import type { SchemaNode } from "@/app/ontology/page" import { ConnectionsSection } from "./connections-section" import { formatDateAbsolute, formatDateRelative } from "@/lib/date-format" import { useGraphStore } from "@/stores/graph-store" +import { metroSeries } from "@/data/metro" const DEEP_RESEARCH_NODE_TYPES = ["Topic"] +// Stations live only in the local fixture — the backend collapses fixture's +// transfer-platform variants (komsomolskaya_k / _r) into one row, so we can't +// map fixture station ref_ids 1:1 to backend UUIDs without losing the dual- +// platform schematic. Short-circuit clicks on station nodes so they render +// from the fixture instead of 500-ing. All other fixture nodes (Persons, +// Orgs, etc.) have backend UUIDs applied in metro.ts and hit the API normally. +const METRO_FIXTURE_STATION_REF_IDS = new Set( + (metroSeries.nodes as { ref_id: string; node_type?: string }[]) + .filter((n) => n.node_type === "Station") + .map((n) => n.ref_id), +) + const INTERNAL_FIELDS = new Set([ "ref_id", "pubkey", "owner_reference_id", "node_type", "date_added_to_graph", "status", "project_id", // Fields rendered by rich widgets — hide from the fallback key/value list @@ -843,6 +856,11 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp const showThumbnail = !!thumbnail && !isThisNodePlayingHere && !isImageNode async function handleUnlock() { + if (METRO_FIXTURE_STATION_REF_IDS.has(currentNode.ref_id)) { + setFullNode(currentNode) + setUnlockState("unlocked") + return + } setUnlockState("loading") try { const unlocked = await unlockNode(currentNode.ref_id) @@ -911,6 +929,13 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp setUnlockState("loading") async function probe() { + // Fixture-only metro lore — no backend representation, so skip the API + // entirely and treat the local node as the unlocked payload. + if (METRO_FIXTURE_STATION_REF_IDS.has(currentNode.ref_id)) { + setFullNode(currentNode) + setUnlockState("unlocked") + return + } if (isMocksEnabled()) { await new Promise((r) => setTimeout(r, 300)) if (controller.signal.aborted) return diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 21bc2832..40430171 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -1211,9 +1211,9 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas diff --git a/src/data/metro.ts b/src/data/metro.ts index f78133cd..c207bfdf 100644 --- a/src/data/metro.ts +++ b/src/data/metro.ts @@ -159,7 +159,94 @@ function tunnelEdges(): RawEdge[] { const stationNodes = Object.values(stations2087).map(stationNode) const tunnels = tunnelEdges() -export const metroSeries = { +// Map fixture ref_ids to their backend UUIDs. Keeps the inline node/edge +// definitions readable while letting the runtime data align with the seeded +// graph so unlock/preview calls hit the right backend records. +// +// Stations are intentionally omitted — the backend collapses fixture's +// transfer-platform variants (komsomolskaya_k / komsomolskaya_r, etc.) into +// one row per real station. Keeping station ref_ids as fixture slugs +// preserves the dual-platform schematic; node-preview-panel short-circuits +// stations to the fixture data so they don't try to fetch. +const BACKEND_REF_ID_MAP: Record = { + // Persons + Artyom: "e7542bf7-1390-458a-af69-f334d1c59f8c", + Anna: "a4f71239-a934-4600-90f4-692b3caa1a8f", + Miller: "d3caecc7-941d-40ac-978a-849f9c4ceca4", + Khan: "52dd33b9-13a3-4216-aef6-b616e18f8e80", + Hunter: "4b5d9f6a-3cb1-4876-a7f5-e1c339ac223c", + Pavel: "4cd2d13e-9d0d-4da6-a6ff-5cf7944b6b89", + Bourbon: "6375bc51-0173-4ba1-b81a-5a1ece283263", + Ulman: "0c041493-6c6f-4c70-93e6-cdf488dab8e7", + Sukhoi: "48775912-5cee-4f8e-9927-2ea7cf7018e4", + Damir: "dd79822e-2b2e-4d55-a5e0-839c45f90dd2", + Tokarev: "a357aa9b-c8ac-4292-99f8-40ef5fefef17", + Stepan: "422057e8-fbc1-4000-8226-27026bd58c1a", + Krest: "af7fdb5c-2527-4e86-b754-9c5aea526b60", + Idiot: "2ec82fc8-5dac-463d-a1e0-f6a8676aded0", + Sam: "5e0e0816-21c6-4290-a83a-06b38c863765", + Khlebnikov: "edbe69a6-ab4c-48e6-8698-94e948bd00a3", + Kirill: "9b913f07-2ffa-488f-878f-2cb135c4a250", + Lesnitsky: "e8441af8-ee86-4841-955e-53ff741a4f0c", + // Organizations + SpartanOrder: "061e560b-05f4-4e88-ba86-d2abf23cd730", + RedLine: "948ca7bd-bf56-4bdb-a3ff-06a44c35b82b", + FourthReich: "3e37e9be-c66a-4fd0-83af-bf45e192b01f", + Hansa: "a933966a-600f-4dd5-a0e2-0a60bc7b2e4a", + Polis: "9753580e-2c13-43ff-a391-da9d904ff2ff", + AuroraCrew: "00f302c2-cdcc-4f54-99b0-975b401f80f1", + GreatWormCult: "2798e9d4-c41d-4189-8d90-7b9b25d48a7e", + TaigaWatchmen: "0bb29bd8-1511-4c0b-9f02-971f428a105c", + ChildrenOfForest: "8991d537-911d-4437-9c3a-7cfe3552d5c4", + CaspianPirates: "7f91e83f-f8ef-4387-8ced-4d85e9e77619", + Stalkers: "0b9f640c-2859-45f3-a533-29a98877af26", + // Locations + MoscowMetro: "e44dad45-8c38-4470-a830-50c2eba36659", + D6: "3be86cf6-e108-4bc8-9a49-e8881228a251", + Volga: "5978ade2-956c-40fa-a814-d1fe16f86f2b", + Caspian: "ac2d3d1d-b322-4771-b9f2-3cf417a272f3", + Taiga: "ed75df74-4e5f-4a63-a5e8-16f40494ea1a", + Yamantau: "86eaa97b-9254-4e13-a251-af31d82ec86c", + Baikal: "2b0679ea-778a-4661-ac5e-b11d6ee94e45", + Novosibirsk: "e78548d9-bcb7-413f-bc5c-581da4bd1361", + DeadCity: "bb9f9fbf-a033-4b25-bb9f-ecfc60a92b93", + Surface: "7581a86a-ce16-4b68-89fc-f87ff87ca55b", + // Transports + Aurora: "1cad61aa-761b-499b-be0e-551115a9e0e7", + Handcar: "86b59186-c58c-475b-970b-f7e6a4be4334", + HansaTrain: "3a488abf-2ccc-419e-9507-9d4d5f98a585", + CaspianBoat: "888635f3-cb65-4d44-8a1a-983b6ed0aca0", + // Creatures + Nosalis: "5d074699-4692-436b-bbfe-ca8208325394", + Lurker: "64924159-a014-4bea-83c9-d8b0be510a3d", + Watcher: "aac06586-0a77-4fc2-9ac6-54faaee8b6c0", + Demon: "da67ad65-6ad0-4e16-9de2-2d77d1d83477", + Librarian: "1ee142e8-a1c1-4ff4-8ddf-b0d9abdb7e53", + DarkOnes: "1f17c4b4-35b2-4710-be9c-3ae3c19f07f2", + Shrimp: "803c4b1b-679e-4511-87f1-aa9988aa63a7", + Humanimal: "fa79b68d-6e1b-4c3f-af04-43eae18736da", + Watchman: "6d9ce4e1-b836-4a7a-943a-f99162d2cf10", + MutantBear: "4fcdc414-8600-4d4b-998f-201052cfeede", + // Weapons + Bastard: "57f5dbf1-7d92-4979-865a-6f49c2e93278", + Tikhar: "fdd6ff7f-c851-436b-ac9b-9c5a6afb989e", + Helsing: "51f6fef1-e318-4b49-ad8f-3b0c9730f773", + Ashot: "61f0a4ac-8562-4e50-87c9-7ed8a3d75cc7", + VoltDriver: "06a928c6-add4-4a5c-8ed8-a4390c2b563f", + // Items + GasMask: "def60281-b0c7-4047-93a9-399251f819a8", + Filter: "9dff2b2c-2c30-4f0b-9f14-5935cb20e25f", + Charger: "9bf3dbf4-57c8-41eb-b322-a260b0e0b3f7", + MGR: "a8353dcb-d156-4961-a6d5-d355f20e6b75", + Medkit: "4fa8c19b-5731-4ed3-be2b-446deea91ce3", + Workbench: "41d28422-9013-4761-8273-756c19df360b", +} + +function rid(id: string): string { + return BACKEND_REF_ID_MAP[id] ?? id +} + +const rawMetroSeries = { edges: [ // --- Family -------------------------------------------------------- { "edge_type": "STEPSON_OF", "properties": { "date_added_to_graph": "1778230600.0" }, "ref_id": "m-e-25", "source": "Artyom", "target": "Sukhoi", "weight": 1 }, @@ -379,3 +466,15 @@ export const metroSeries = { { "date_added_to_graph": 1778230600.0, "node_type": "Item", "properties": { "name": "Workbench", "purpose": "Crafting and weapon upgrades" }, "ref_id": "Workbench" }, ], } + +// Apply BACKEND_REF_ID_MAP to the raw fixture so consumers see backend UUIDs +// for any node the seeded graph has. Stations and any unmapped entries fall +// through unchanged (resolved by rid() identity). +export const metroSeries = { + nodes: rawMetroSeries.nodes.map((n) => ({ ...n, ref_id: rid(n.ref_id) })), + edges: rawMetroSeries.edges.map((e) => ({ + ...e, + source: rid(e.source), + target: rid(e.target), + })), +} diff --git a/src/graph-viz-kit/GraphView.tsx b/src/graph-viz-kit/GraphView.tsx index b52ff018..b4e9239b 100644 --- a/src/graph-viz-kit/GraphView.tsx +++ b/src/graph-viz-kit/GraphView.tsx @@ -1864,18 +1864,18 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima const topTint = topRank === 0 ? "rgba(255, 215, 80, 0.98)" // gold for the best hit : "rgba(120, 200, 255, 0.95)"; // cool blue for ranks 1-2 - const labelColor = isHovered ? "rgba(255,255,255,0.95)" + const labelColor = isHovered ? "rgba(255,255,255,0.98)" : isTopHit ? topTint - : isSelected ? "rgba(100,220,255,0.95)" - : isHoverNeighbor ? "rgba(200,200,200,0.85)" + : isSelected ? "rgba(100,220,255,0.98)" + : isHoverNeighbor ? "rgba(210,215,222,0.95)" : isRecentNode ? `rgba(100,255,180,${(0.5 + 0.45 * recentOpacity).toFixed(2)})` - : "rgba(190,200,210,0.75)"; - const labelSize = isHovered || isSelected ? 15 - : isTopHit ? (topRank === 0 ? 17 : 15) - : isRecentNode ? 14 - : isHoverNeighbor ? 13 - : 12; - const labelWeight = isHovered || isSelected || isExpandedProxy || isTopHit ? 700 : 500; + : "rgba(205,212,222,0.92)"; + const labelSize = isHovered || isSelected ? 17 + : isTopHit ? (topRank === 0 ? 19 : 17) + : isRecentNode ? 16 + : isHoverNeighbor ? 15 + : 14; + const labelWeight = isHovered || isSelected || isExpandedProxy || isTopHit ? 700 : 600; // Placement priority: hovered > selected > top-hit > expanded-proxy > // search-match > recent > hover-neighbor > high-weight > base. Used @@ -1958,7 +1958,7 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima fontWeight: labelWeight, letterSpacing: "0.3px", whiteSpace: "nowrap", - textShadow: "0 0 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.7)", + textShadow: "0 0 4px rgba(0,0,0,1), 0 0 10px rgba(0,0,0,0.95), 0 0 18px rgba(0,0,0,0.8)", }}> {isExpandedProxy ? { e.stopPropagation(); onNodeClick(i); }}>{node.label} From 9ea750ed067a7ef28874fb60265d47d40b801b8c Mon Sep 17 00:00:00 2001 From: Rassl Date: Tue, 26 May 2026 16:24:37 +0400 Subject: [PATCH 05/19] feat: semantic zoom try, does not work --- NODE_DETAIL_VIEW.md | 263 ++++++++++++++ src/components/case-view/adapter.ts | 103 ++++++ src/components/case-view/camera.ts | 41 +++ src/components/case-view/case-view.tsx | 442 +++++++++++++++++++++++ src/components/case-view/constants.ts | 66 ++++ src/components/case-view/draw.ts | 241 ++++++++++++ src/components/case-view/index.ts | 2 + src/components/case-view/layout.ts | 51 +++ src/components/case-view/types.ts | 33 ++ src/components/universe/graph-canvas.tsx | 199 +++++++++- 10 files changed, 1440 insertions(+), 1 deletion(-) create mode 100644 NODE_DETAIL_VIEW.md create mode 100644 src/components/case-view/adapter.ts create mode 100644 src/components/case-view/camera.ts create mode 100644 src/components/case-view/case-view.tsx create mode 100644 src/components/case-view/constants.ts create mode 100644 src/components/case-view/draw.ts create mode 100644 src/components/case-view/index.ts create mode 100644 src/components/case-view/layout.ts create mode 100644 src/components/case-view/types.ts diff --git a/NODE_DETAIL_VIEW.md b/NODE_DETAIL_VIEW.md new file mode 100644 index 00000000..d2ca8563 --- /dev/null +++ b/NODE_DETAIL_VIEW.md @@ -0,0 +1,263 @@ +# Node Detail View — Peaky Blinders-style Case Board + +## Goal + +When the user selects a node in the 3D universe (e.g. Artyom) and zooms in, transition into a 2D editable case-board view inspired by Peaky Blinders / Maltego-style investigation graphs: + +- The selected node becomes a rich card with image, structured fields, and connection handles +- Direct 1-hop connections render as cards around it, joined by dashed bezier "linked to" edges +- The user can pan/zoom within the 2D view, drag-bend edges, possibly add/remove links +- Backing out returns to the 3D universe view + +**Reference UX:** see the Peaky Blinders screenshot — entity-typed cards (PERSON / PHONE / VEHICLE) with a colored chrome per type, hero image, labeled fields, blue-dot connection handles, dashed bezier edges with mid-edge "linked to" pills. + +--- + +## Current architecture (what's already in place) + +Relevant files in graphmindset's existing 3D view: + +- `src/components/universe/graph-canvas.tsx` — main R3F `` with `CameraControls`, EffectComposer/Bloom, metro overlay layers +- `src/graph-viz-kit/GraphView.tsx` — 3D node rendering, label sizing, hover/select state +- `src/data/metro.ts` — fixture + `BACKEND_REF_ID_MAP` resolving fixture ref_ids to backend UUIDs +- `src/components/layout/node-preview-panel.tsx` — current sidebar detail (text-only, no graph visualization) +- `src/stores/graph-store.ts` — selection state (selected node, sidebar selected, hovered) + +Already wired for this work: +- `CameraControls` from `camera-controls` supports `setLookAt(..., enableTransition: true)` for animated camera moves +- Node positions are available in world coordinates via the existing layout pipeline +- Backend serves full node + edges via `/v2/nodes/?expand=edges` + +--- + +## Two architectural paths + +### Path 1 — Continuous semantic zoom (the Pixar feel) + +**One scene, three render variants per node, driven by camera distance.** No view switch — fly the camera in and the same node morphs from 3D sphere → label pill → full 2D card. + +Primitive: **`` from `@react-three/drei`** attaches DOM elements to world coordinates. Sized via `distanceFactor` so they scale with camera distance. + +```tsx +function NodeWithLOD({ position, node }: Props) { + const distance = useNodeDistance(position) // useFrame + camera.distanceTo + const variant = + distance > 60 ? "dot" // current 3D sphere + : distance > 15 ? "pill" // your current label + : "card" // Peaky Blinders full card + + return ( + <> + {variant === "dot" && } + {variant !== "dot" && ( + + {variant === "pill" + ? + : } + + )} + + ) +} +``` + +Camera animation already available via existing `cameraRef`: +```ts +function flyToNode(node: GraphNode) { + const target = new Vector3(node.x, node.y, node.z) + cameraRef.current.setLookAt( + target.x, target.y + 2, target.z + 10, // close-up position + target.x, target.y, target.z, // look at + true // animate + ) +} +``` + +**Edges during transition** — three options for handling the visual: +- **Fade 3D lines out** as cards fade in. Simplest. +- **Re-draw as 2D SVG** when zoomed-in, using projected node positions. Overlay `` outside `` reading projected coords each frame. Looks exactly like Peaky Blinders dashed beziers. +- **Hybrid** — 3D LineSegments far out, lerp opacity, swap to 2D-style dashed at close range. + +**Pros:** +- Continuous; one camera, one source of truth, no view switching +- Cinematic — feels like flying into the node + +**Cons:** +- Card density at close zoom can hammer DOM — mitigate with frustum + distance culling +- Edge rendering during transition is tricky +- Several-day refactor of how nodes render + +--- + +### Path 2 — View handoff with shared anchor (simpler to ship) + +**Two views, cross-faded with the node "landing" at the same screen position.** Less ambitious, faster, looks great if the handoff is precise. + +Flow: +1. User clicks Artyom in 3D → camera dollies in for ~400 ms +2. At animation midpoint, project Artyom's 3D world position → screen coords +3. Mount a React Flow canvas as a fullscreen overlay, with Artyom's node initialized at the same screen position +4. Fade 3D scene → 0, React Flow → 1 over ~300 ms +5. React Flow's `fitView()` smoothly re-centers the graph after the fade completes + +The trick that sells the handoff: **place the target node at the exact same pixel location** before fading, so the user's eye doesn't lose it. Everything else is opacity. + +Tech stack additions: +- **React Flow** (`@xyflow/react`) — 2D node/edge canvas, custom node types, pan/zoom, edge bending +- New route: `/case/[refId]` or modal overlay (either works) +- Reuse existing `metroSeries` + backend `/v2/nodes/?expand=edges` for the 1-hop fetch + +**Pros:** +- Clean separation; each view uses the right tool (3D for spatial overview, React Flow for editable case-board) +- No perf concerns from `` overload +- 1–2 day prototype to validate UX +- React Flow handles all the editing affordances (drag-bend, connection handles, edge labels) out of the box + +**Cons:** +- There's a discontinuity — feels like a smart cut, not flying in +- Two view trees to maintain + +--- + +## Recommended plan + +**Start with Path 2** to validate the UX in ~1–2 days. If the cross-fade feels right, ship it. If it feels jarring and the cinematic version is worth the investment, *then* do Path 1. + +The piece that's reusable between both paths: **the `` component** that takes a node and renders the case-board card with image, fields, and connection-handle dots. Build that first — it's the same artifact regardless of which path wins. + +--- + +## Shared building block — `` + +Spec for the component used by both paths. + +```tsx +type PeakyCardProps = { + node: GraphNode + variant?: "default" | "selected" + onClick?: () => void + /** Render connection handles at top/right/bottom/left for React Flow integration */ + showHandles?: boolean +} +``` + +Visual requirements (matching reference screenshot): +- Rounded card with type-colored chrome border + glow (Person = teal, Vehicle = orange, etc.) +- Type label pill at top ("PERSON", "VEHICLE", "PHONE") +- Hero image or icon if `node.properties.image_url` / type-default icon +- Title (large, bold) — `node.properties.name` +- 2–4 labeled field rows from `node.properties` (whitelist per type, e.g. Person → Born, Phone, Role) +- Blue connection handle dots on each edge (top/right/bottom/left) when `showHandles` +- Drop shadow + subtle backdrop blur for depth on dark bg + +Type → field config: +```ts +const FIELD_CONFIG: Record = { + Person: [ + { label: "Title", key: "title" }, + { label: "Role", key: "role" }, + { label: "Home", key: "home" }, + { label: "Faction", key: "faction" }, + ], + Station: [ + { label: "Line", key: "metro_line" }, + { label: "Status", key: "station_status" }, + { label: "Faction", key: "faction" }, + ], + Organization: [ + { label: "Alias", key: "alias" }, + { label: "Ideology", key: "ideology" }, + ], + // ... etc per node_type +} +``` + +Type → chrome color: +```ts +const TYPE_THEME: Record = { + Person: { border: "border-cyan-500/50", pill: "bg-cyan-500/20 text-cyan-300", glow: "shadow-cyan-500/30" }, + Organization: { border: "border-purple-500/50", pill: "bg-purple-500/20 text-purple-300", glow: "shadow-purple-500/30" }, + Location: { border: "border-emerald-500/50",pill: "bg-emerald-500/20 text-emerald-300", glow: "shadow-emerald-500/30" }, + Station: { border: "border-amber-500/50", pill: "bg-amber-500/20 text-amber-300", glow: "shadow-amber-500/30" }, + Weapon: { border: "border-red-500/50", pill: "bg-red-500/20 text-red-300", glow: "shadow-red-500/30" }, + Item: { border: "border-slate-500/50", pill: "bg-slate-500/20 text-slate-300", glow: "shadow-slate-500/30" }, + Transport: { border: "border-orange-500/50", pill: "bg-orange-500/20 text-orange-300", glow: "shadow-orange-500/30" }, + Creature: { border: "border-rose-500/50", pill: "bg-rose-500/20 text-rose-300", glow: "shadow-rose-500/30" }, +} +``` + +--- + +## Implementation steps (Path 2) + +Concrete punch list: + +1. **Build ``** (~half day) + - New file: `src/components/case/peaky-card.tsx` + - Storybook-able / can be dropped into existing sidebar for visual testing first + - Field whitelist + type theme as above + +2. **Add React Flow** (~1 hour) + - `npm install @xyflow/react` + - Register `` as a custom `NodeTypes` entry + - Custom `EdgeTypes` for the dashed bezier "linked to" style + +3. **New route or modal: `/case/[refId]`** (~half day) + - On mount: fetch node + 1-hop neighbors via `/v2/nodes/?expand=edges` + - Layout: place selected node at center, neighbors in a radial ring around it (simple polar coords) + - Render React Flow with the data + +4. **Click handoff from 3D** (~half day) + - Add click handler on graph-canvas selected node → trigger camera dolly + navigate to `/case/` + - Capture pre-navigation screen position of the clicked node + - On case view mount: place selected node at that screen position initially, then `fitView()` after fade + +5. **Cross-fade animation** (~few hours) + - Wrap both views in an animation container + - 3D Canvas opacity → 0 over 300ms after click + - Case view opacity → 1 over 300ms (overlapping) + - Back button reverses + +6. **Backend integration** (~few hours) + - Verify `/v2/nodes/?expand=edges` returns expected shape + - For metro fixture nodes that don't exist in backend (stations), fall back to local fixture lookup (similar pattern to the existing short-circuit in `node-preview-panel.tsx`) + +--- + +## Open questions / decisions to make + +- **Editable or read-only?** Peaky Blinders demo lets you drag-bend edges and presumably add links. For graphmindset's first iteration, probably read-only (no link editing) — pan/zoom + drag node positions only. Editing is a v2. +- **What goes back to the backend?** If editable, link adds/removes need new endpoints. For read-only, nothing. +- **Layout algorithm for the 2D case view?** Options: + - Radial (selected in center, neighbors around it) — simple, predictable + - Force-directed (Cytoscape-like) — looks more organic, more compute + - Manual saved layouts per node (user can drag, persist positions) — most polish, needs storage +- **Multi-hop?** Just 1-hop neighbors, or expand on click for 2-hop / 3-hop? Probably 1-hop default with "expand" affordance per neighbor. +- **Image sources?** Persons have `properties.image_url` in some cases. For nodes without images, use type icon (already have `schema-icons.ts`). Need a default per type. +- **Back UX?** Browser back button, or in-app close button on the case view, or both? +- **Mobile?** Case view on mobile = vertical stack? Skip for v1 and focus desktop? +- **Animation library?** `framer-motion` for the cross-fade, or pure CSS transitions? Probably `framer-motion` for the more complex sequencing. + +--- + +## Inspiration / prior art to study + +- **G6 (AntV)** — MIT licensed (`github.com/antvis/G6`), has the cleanest declarative LOD pattern (`{ lod: 1 }` per element). Worth reading `packages/g6/src/elements/nodes/base-node.ts` and the `behaviors/` folder. Patterns translate; code does not (2D Canvas vs our R3F). +- **React Flow / xyflow** — `reactflow.dev`. Built-in semantic zoom via `useViewport()` hook returning `{ zoom }`. Edge bending, connection handles, custom nodes all native. +- **Cytoscape.js** — alternative to React Flow with zoom-conditional styling built into the style API (`min-zoomed-font-size` etc.). +- **The Peaky Blinders demo itself** — almost certainly React Flow under the hood. The blue connection-handle dots and bend-by-drag interaction are giveaways. + +--- + +## Files likely to be touched + +When implementing Path 2: +- **New:** `src/components/case/peaky-card.tsx`, `src/components/case/case-view.tsx`, `src/app/case/[refId]/page.tsx` (or a modal slot in the existing layout) +- **Edit:** `src/components/universe/graph-canvas.tsx` — wire click → navigate transition +- **Edit:** `src/stores/graph-store.ts` — possibly add case-view state (current refId, transition phase) +- **New (maybe):** `src/lib/case-layout.ts` — radial/force layout for 2D case view + +When implementing Path 1 (later, if pursued): +- **Edit:** `src/graph-viz-kit/GraphView.tsx` — major refactor to use `` overlays with LOD +- **New:** `src/components/universe/node-lod.tsx` — the variant-switching wrapper component +- **Edit:** `src/components/universe/graph-canvas.tsx` — replace direct rendering with NodeLOD diff --git a/src/components/case-view/adapter.ts b/src/components/case-view/adapter.ts new file mode 100644 index 00000000..d3004d8b --- /dev/null +++ b/src/components/case-view/adapter.ts @@ -0,0 +1,103 @@ +import type { GraphNode, GraphEdge } from "@/lib/graph-api" +import type { SchemaNode } from "@/app/ontology/page" +import { resolveNodeTitle } from "@/lib/node-display" +import type { SigEntity, SigEdge, SigDataset } from "./types" +import { + TYPE_HUES, + KIND_RADIUS, + DEFAULT_KIND_RADIUS, + SELECTED_SCALE, + C, +} from "./constants" +import { layoutRing, computeWorldBBox } from "./layout" + +interface BuildArgs { + selectedRefId: string + nodes: GraphNode[] + edges: GraphEdge[] + schemas: SchemaNode[] +} + +export function buildCaseDataset({ + selectedRefId, + nodes, + edges, + schemas, +}: BuildArgs): SigDataset | null { + const selectedNode = nodes.find((n) => n.ref_id === selectedRefId) + if (!selectedNode) return null + + // 1-hop neighbors: any node connected to selected by one edge in either + // direction. The backend returns the union via expand=edges; we just need to + // map source/target → neighbor refs. + const neighborRefIds = new Set() + for (const e of edges) { + if (e.source === selectedRefId) neighborRefIds.add(e.target) + if (e.target === selectedRefId) neighborRefIds.add(e.source) + } + + const byId = new Map() + const flat: SigEntity[] = [] + + function toSig(node: GraphNode, isSelected: boolean): SigEntity { + const type = node.node_type || "Unknown" + const baseR = KIND_RADIUS[type] ?? DEFAULT_KIND_RADIUS + const r = isSelected ? baseR * SELECTED_SCALE : baseR + const color = TYPE_HUES[type] ?? C.accent + return { + id: node.ref_id, + name: resolveNodeTitle(node, schemas), + kind: type, + isSelected, + x: 0, + y: 0, + r, + color, + node, + } + } + + const selectedSig = toSig(selectedNode, true) + byId.set(selectedSig.id, selectedSig) + flat.push(selectedSig) + + const neighbors: SigEntity[] = [] + for (const refId of neighborRefIds) { + const node = nodes.find((n) => n.ref_id === refId) + if (!node) continue + const sig = toSig(node, false) + byId.set(sig.id, sig) + flat.push(sig) + neighbors.push(sig) + } + + layoutRing(selectedSig, neighbors) + + const sigEdges: SigEdge[] = [] + const seen = new Set() + for (const e of edges) { + const from = byId.get(e.source) + const to = byId.get(e.target) + if (!from || !to || from === to) continue + const key = `${e.source}→${e.target}→${e.edge_type}` + if (seen.has(key)) continue + seen.add(key) + sigEdges.push({ + id: key, + fromId: e.source, + toId: e.target, + from, + to, + label: e.edge_type, + }) + } + + return { + selectedId: selectedSig.id, + selected: selectedSig, + byId, + flat, + edges: sigEdges, + worldBBox: computeWorldBBox(flat), + } +} diff --git a/src/components/case-view/camera.ts b/src/components/case-view/camera.ts new file mode 100644 index 00000000..1559fb23 --- /dev/null +++ b/src/components/case-view/camera.ts @@ -0,0 +1,41 @@ +export interface Cam { + x: number + y: number + scale: number +} + +export function smoothstep(x: number, a: number, b: number): number { + const t = Math.max(0, Math.min(1, (x - a) / (b - a))) + return t * t * (3 - 2 * t) +} + +export function worldToScreen( + wx: number, + wy: number, + cam: Cam, + w: number, + h: number, +) { + return { + x: (wx - cam.x) * cam.scale + w / 2, + y: (wy - cam.y) * cam.scale + h / 2, + } +} + +export function screenToWorld( + sx: number, + sy: number, + cam: Cam, + w: number, + h: number, +) { + return { + x: (sx - w / 2) / cam.scale + cam.x, + y: (sy - h / 2) / cam.scale + cam.y, + } +} + +export function hexToRGB(hex: string): string { + const n = parseInt(hex.slice(1), 16) + return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}` +} diff --git a/src/components/case-view/case-view.tsx b/src/components/case-view/case-view.tsx new file mode 100644 index 00000000..5da2cd5f --- /dev/null +++ b/src/components/case-view/case-view.tsx @@ -0,0 +1,442 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { GraphNode, GraphData, GraphEdge } from "@/lib/graph-api" +import { getNode } from "@/lib/graph-api" +import type { SchemaNode } from "@/app/ontology/page" +import { metroSeries } from "@/data/metro" +import { buildCaseDataset } from "./adapter" +import { LOD, C, FONT_MONO } from "./constants" +import { worldToScreen, screenToWorld, type Cam } from "./camera" +import { + clear, + drawDot, + drawLeafGlyph, + drawEdge, +} from "./draw" +import type { SigDataset, SigEntity } from "./types" + +const METRO_FIXTURE_STATION_REF_IDS = new Set( + (metroSeries.nodes as { ref_id: string; node_type?: string }[]) + .filter((n) => n.node_type === "Station") + .map((n) => n.ref_id), +) + +// Read the 1-hop subgraph for a refId. Backend nodes go through /v2/nodes; +// metro stations short-circuit to the local fixture (same pattern as +// node-preview-panel.tsx — backend collapses platform variants we want to keep). +async function fetchCaseSubgraph( + refId: string, + signal?: AbortSignal, +): Promise { + if (METRO_FIXTURE_STATION_REF_IDS.has(refId)) { + const allNodes = metroSeries.nodes as GraphNode[] + const allEdges = metroSeries.edges as GraphEdge[] + const neighborIds = new Set([refId]) + for (const e of allEdges) { + if (e.source === refId) neighborIds.add(e.target) + if (e.target === refId) neighborIds.add(e.source) + } + return { + nodes: allNodes.filter((n) => neighborIds.has(n.ref_id)), + edges: allEdges.filter( + (e) => e.source === refId || e.target === refId, + ), + } + } + return getNode(refId, "edges", signal) +} + +const INITIAL_SCALE = 1.0 +const EXIT_ZOOM_THRESHOLD = 0.25 // cam.scale below this → trigger 2D→3D exit + +export interface CaseViewProps { + initialNode: GraphNode + schemas: SchemaNode[] + // Apparent screen radius the selected node had in 3D at the handoff frame. + // We initialize cam.scale so the same node has the same on-screen radius + // here — the user's eye doesn't lose it. + initialApparentRadius?: number + onExit: () => void +} + +export function CaseView({ + initialNode, + schemas, + initialApparentRadius, + onExit, +}: CaseViewProps) { + const [currentRefId, setCurrentRefId] = useState(initialNode.ref_id) + const [data, setData] = useState(null) + const [loadError, setLoadError] = useState(null) + const stageRef = useRef(null) + const bgRef = useRef(null) + const edgeRef = useRef(null) + const nodeRef = useRef(null) + + // Fetch dataset whenever the active refId changes + useEffect(() => { + const controller = new AbortController() + setLoadError(null) + fetchCaseSubgraph(currentRefId, controller.signal) + .then((g) => { + if (controller.signal.aborted) return + const ds = buildCaseDataset({ + selectedRefId: currentRefId, + nodes: g.nodes, + edges: g.edges, + schemas, + }) + if (!ds) { + setLoadError("No node data") + return + } + setData(ds) + }) + .catch((err) => { + if (controller.signal.aborted) return + setLoadError(err instanceof Error ? err.message : "Failed to load") + }) + return () => controller.abort() + }, [currentRefId, schemas]) + + // Imperative state for the render loop. Decoupled from React's render cycle + // so 60fps pan/zoom doesn't trigger re-renders. + const stateRef = useRef({ + cam: { x: 0, y: 0, scale: INITIAL_SCALE } as Cam, + DPR: typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 2) : 1, + w: 0, + h: 0, + mouse: { x: 0, y: 0, down: false, dragStart: null as null | { x: number; y: number; camX: number; camY: number } }, + hover: null as SigEntity | null, + data: null as SigDataset | null, + t0: typeof performance !== "undefined" ? performance.now() : 0, + t: 0, + rafId: 0, + onNeighborClick: null as null | ((e: SigEntity) => void), + onExitZoom: null as null | (() => void), + exitFired: false, + }) + + useEffect(() => { + stateRef.current.data = data + if (data) { + // Re-center camera on the new selected node. If an initial apparent + // radius was provided (the 3D→2D handoff), pick cam.scale so the + // selected entity has that same apparent radius on this canvas. + stateRef.current.cam.x = data.selected.x + stateRef.current.cam.y = data.selected.y + if (initialApparentRadius && initialApparentRadius > 0) { + stateRef.current.cam.scale = initialApparentRadius / data.selected.r + } else { + stateRef.current.cam.scale = INITIAL_SCALE + } + stateRef.current.exitFired = false + } + }, [data, initialApparentRadius]) + + // Esc + close button exit + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onExit() + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [onExit]) + + // Hit-test in screen space against current visible entities + const hitTest = useCallback((sx: number, sy: number): SigEntity | null => { + const S = stateRef.current + if (!S.data) return null + let hit: SigEntity | null = null + for (const e of S.data.flat) { + const sc = worldToScreen(e.x, e.y, S.cam, S.w, S.h) + const r = e.r * S.cam.scale + 6 + const dx = sx - sc.x + const dy = sy - sc.y + if (dx * dx + dy * dy <= r * r) { + hit = e + break + } + } + return hit + }, []) + + const handleNeighborClick = useCallback((e: SigEntity) => { + if (e.id === currentRefId) return + setCurrentRefId(e.id) + }, [currentRefId]) + + useEffect(() => { + stateRef.current.onNeighborClick = handleNeighborClick + stateRef.current.onExitZoom = onExit + }, [handleNeighborClick, onExit]) + + // Render loop + input handling + useEffect(() => { + const stage = stageRef.current + const bgC = bgRef.current + const edgeC = edgeRef.current + const nodeC = nodeRef.current + if (!stage || !bgC || !edgeC || !nodeC) return + const bgCtx = bgC.getContext("2d") + const edgeCtx = edgeC.getContext("2d") + const nodeCtx = nodeC.getContext("2d") + if (!bgCtx || !edgeCtx || !nodeCtx) return + const S = stateRef.current + + function resize() { + const r = stage!.getBoundingClientRect() + S.w = r.width + S.h = r.height + for (const c of [bgC, edgeC, nodeC]) { + if (!c) continue + c.width = Math.floor(r.width * S.DPR) + c.height = Math.floor(r.height * S.DPR) + c.getContext("2d")!.setTransform(S.DPR, 0, 0, S.DPR, 0, 0) + } + } + window.addEventListener("resize", resize) + resize() + + function drawBackground() { + clear(bgCtx!, S.w, S.h) + bgCtx!.fillStyle = C.bg0 + bgCtx!.fillRect(0, 0, S.w, S.h) + // grid + const step = 200 + const sStep = step * S.cam.scale + if (sStep < 10) return + const tl = screenToWorld(0, 0, S.cam, S.w, S.h) + const ox = (Math.floor(tl.x / step) * step - S.cam.x) * S.cam.scale + S.w / 2 + const oy = (Math.floor(tl.y / step) * step - S.cam.y) * S.cam.scale + S.h / 2 + bgCtx!.strokeStyle = `rgba(120, 200, 220, 0.06)` + bgCtx!.lineWidth = 1 + bgCtx!.beginPath() + for (let x = ox; x < S.w + sStep; x += sStep) { + bgCtx!.moveTo(x, 0) + bgCtx!.lineTo(x, S.h) + } + for (let y = oy; y < S.h + sStep; y += sStep) { + bgCtx!.moveTo(0, y) + bgCtx!.lineTo(S.w, y) + } + bgCtx!.stroke() + } + + function drawEdges() { + clear(edgeCtx!, S.w, S.h) + if (!S.data) return + for (const e of S.data.edges) { + const a = worldToScreen(e.from.x, e.from.y, S.cam, S.w, S.h) + const b = worldToScreen(e.to.x, e.to.y, S.cam, S.w, S.h) + const showLabel = S.cam.scale > 0.4 + drawEdge(edgeCtx!, a, b, 1, showLabel ? e.label : undefined) + } + } + + function drawNodes() { + clear(nodeCtx!, S.w, S.h) + if (!S.data) return + for (const e of S.data.flat) { + const sc = worldToScreen(e.x, e.y, S.cam, S.w, S.h) + const appR = e.r * S.cam.scale + const margin = 200 + appR + if (sc.x < -margin || sc.x > S.w + margin) continue + if (sc.y < -margin || sc.y > S.h + margin) continue + if (appR < LOD.MIN_VISIBLE) continue + if (appR < LOD.GLYPH_MIN) { + drawDot(nodeCtx!, sc, e.color, 1) + continue + } + drawLeafGlyph(nodeCtx!, e, sc, appR, { + selected: e.isSelected, + hover: S.hover === e, + dim: 1, + t: S.t, + }) + } + } + + function frame() { + S.t = performance.now() - S.t0 + drawBackground() + drawEdges() + drawNodes() + // Exit-on-zoom-out: once the selected entity becomes too small, trigger + // a clean 2D→3D handoff. Guard with exitFired so we don't fire twice if + // the animation takes a few frames. + if ( + !S.exitFired && + S.data && + S.cam.scale < EXIT_ZOOM_THRESHOLD && + S.onExitZoom + ) { + S.exitFired = true + S.onExitZoom() + } + S.rafId = requestAnimationFrame(frame) + } + S.rafId = requestAnimationFrame(frame) + + // ── input ── + function getMousePos(ev: MouseEvent): { x: number; y: number } { + const r = stage!.getBoundingClientRect() + return { x: ev.clientX - r.left, y: ev.clientY - r.top } + } + + function onMouseDown(ev: MouseEvent) { + const m = getMousePos(ev) + S.mouse.down = true + S.mouse.dragStart = { x: m.x, y: m.y, camX: S.cam.x, camY: S.cam.y } + } + function onMouseMove(ev: MouseEvent) { + const m = getMousePos(ev) + S.mouse.x = m.x + S.mouse.y = m.y + if (S.mouse.down && S.mouse.dragStart) { + const dx = m.x - S.mouse.dragStart.x + const dy = m.y - S.mouse.dragStart.y + S.cam.x = S.mouse.dragStart.camX - dx / S.cam.scale + S.cam.y = S.mouse.dragStart.camY - dy / S.cam.scale + } else { + const hit = hitTest(m.x, m.y) + S.hover = hit + stage!.style.cursor = hit ? "pointer" : "default" + } + } + function onMouseUp(ev: MouseEvent) { + const m = getMousePos(ev) + const wasDragging = + S.mouse.dragStart && + (Math.abs(m.x - S.mouse.dragStart.x) > 3 || + Math.abs(m.y - S.mouse.dragStart.y) > 3) + S.mouse.down = false + S.mouse.dragStart = null + if (!wasDragging) { + const hit = hitTest(m.x, m.y) + if (hit && !hit.isSelected && S.onNeighborClick) { + S.onNeighborClick(hit) + } + } + } + function onWheel(ev: WheelEvent) { + ev.preventDefault() + const m = getMousePos(ev) + // zoom toward the cursor — keeps the world point under the cursor fixed + const worldBefore = screenToWorld(m.x, m.y, S.cam, S.w, S.h) + const factor = Math.exp(-ev.deltaY * 0.0015) + S.cam.scale = Math.max(0.02, Math.min(20, S.cam.scale * factor)) + const worldAfter = screenToWorld(m.x, m.y, S.cam, S.w, S.h) + S.cam.x += worldBefore.x - worldAfter.x + S.cam.y += worldBefore.y - worldAfter.y + } + + stage.addEventListener("mousedown", onMouseDown) + window.addEventListener("mousemove", onMouseMove) + window.addEventListener("mouseup", onMouseUp) + stage.addEventListener("wheel", onWheel, { passive: false }) + + return () => { + cancelAnimationFrame(S.rafId) + window.removeEventListener("resize", resize) + stage.removeEventListener("mousedown", onMouseDown) + window.removeEventListener("mousemove", onMouseMove) + window.removeEventListener("mouseup", onMouseUp) + stage.removeEventListener("wheel", onWheel) + } + }, [hitTest]) + + const breadcrumb = useMemo(() => { + if (!data) return "" + return data.selected.name + }, [data]) + + return ( +
+ + + + +
+ CASE + + {breadcrumb} + + {data && ( + + · {data.flat.length - 1} connected + + )} +
+ + + + {loadError && ( +
+ {loadError} +
+ )} + + {!data && !loadError && ( +
+ loading… +
+ )} +
+ ) +} diff --git a/src/components/case-view/constants.ts b/src/components/case-view/constants.ts new file mode 100644 index 00000000..5bc559ac --- /dev/null +++ b/src/components/case-view/constants.ts @@ -0,0 +1,66 @@ +// LOD thresholds — measured in apparent screen-px of an entity's radius +// (entity.r * cam.scale). Same primitive everywhere, threshold decides which +// rendering variant runs. Ported from graph-viz/src/components/SignalCanvasPage. +export const LOD = { + MIN_VISIBLE: 2, // below this: skip entirely + GLYPH_MIN: 6, // below this: single dot + LABEL_VISIBLE: 12, // when leaf label appears + LEAF_DETAIL: 30, // when type/region subtitle appears + LEAF_DEEP: 60, // when full property card appears +} + +export const C = { + bg0: "#05080c", + bg1: "#0a1016", + ink: "#d7e6ea", + inkDim: "#7a8e96", + inkFaint: "#3d4a52", + accent: "#4ae0d2", + accentLine: "rgba(74, 224, 210, 0.55)", + accentSoft: "rgba(74, 224, 210, 0.15)", + warm: "#f5b65a", + selected: "#ffd11a", + panel: "rgba(10, 16, 22, 0.92)", + panelBorder: "rgba(120, 200, 220, 0.28)", +} + +export const FONT_SANS = '"Space Grotesk", system-ui, sans-serif' +export const FONT_MONO = '"JetBrains Mono", ui-monospace, monospace' + +// Per-type hue mapping. Fall back to accent if a type isn't listed. +export const TYPE_HUES: Record = { + Person: "#7aa8df", + Organization: "#a78bfa", + Location: "#6ad3a4", + Station: "#f5b65a", + Weapon: "#f472b6", + Item: "#5cc9d8", + Transport: "#f59e0b", + Creature: "#fb7185", + Episode: "#4ae0d2", + Chapter: "#4ae0d2", + Clip: "#4ae0d2", + Topic: "#a78bfa", + Tweet: "#5cc9d8", +} + +// Per-type visual radius (world units). Bigger means more prominent. Selected +// gets multiplied by SELECTED_SCALE in the layout pass. +export const KIND_RADIUS: Record = { + Person: 18, + Organization: 22, + Location: 20, + Station: 16, + Weapon: 14, + Item: 14, + Transport: 16, + Creature: 16, + Episode: 22, + Chapter: 18, + Clip: 14, + Topic: 20, + Tweet: 14, +} + +export const DEFAULT_KIND_RADIUS = 16 +export const SELECTED_SCALE = 1.35 diff --git a/src/components/case-view/draw.ts b/src/components/case-view/draw.ts new file mode 100644 index 00000000..b8bf33f2 --- /dev/null +++ b/src/components/case-view/draw.ts @@ -0,0 +1,241 @@ +import type { SigEntity } from "./types" +import { C, FONT_MONO, LOD } from "./constants" +import { hexToRGB } from "./camera" +import { pickString, DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" + +export function clear(ctx: CanvasRenderingContext2D, w: number, h: number) { + ctx.clearRect(0, 0, w, h) +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.lineTo(x + w - r, y) + ctx.quadraticCurveTo(x + w, y, x + w, y + r) + ctx.lineTo(x + w, y + h - r) + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) + ctx.lineTo(x + r, y + h) + ctx.quadraticCurveTo(x, y + h, x, y + h - r) + ctx.lineTo(x, y + r) + ctx.quadraticCurveTo(x, y, x + r, y) +} + +export function drawDot( + ctx: CanvasRenderingContext2D, + sc: { x: number; y: number }, + color: string, + dim: number, +) { + ctx.fillStyle = `rgba(${hexToRGB(color)}, ${0.6 * dim})` + ctx.beginPath() + ctx.arc(sc.x, sc.y, 2.5, 0, Math.PI * 2) + ctx.fill() +} + +interface LeafOpts { + selected: boolean + hover: boolean + dim: number + t: number +} + +export function drawLeafGlyph( + ctx: CanvasRenderingContext2D, + e: SigEntity, + sc: { x: number; y: number }, + appR: number, + opts: LeafOpts, +) { + const { selected, hover, dim, t } = opts + const baseColor = selected ? C.selected : e.color + const rgb = hexToRGB(baseColor) + + const pulse = selected ? 0.55 + 0.45 * Math.sin(t * 0.003) : 0 + const ringR = appR + 6 + pulse * 5 + + if (pulse > 0 || hover || selected) { + const g = ctx.createRadialGradient(sc.x, sc.y, 0, sc.x, sc.y, ringR * 2) + g.addColorStop(0, `rgba(${rgb}, ${0.28 * (0.5 + pulse) * dim})`) + g.addColorStop(1, `rgba(${rgb}, 0)`) + ctx.fillStyle = g + ctx.beginPath() + ctx.arc(sc.x, sc.y, ringR * 2, 0, Math.PI * 2) + ctx.fill() + } + + ctx.strokeStyle = `rgba(${rgb}, ${0.5 * dim})` + ctx.lineWidth = selected ? 2 : 1 + ctx.beginPath() + ctx.arc(sc.x, sc.y, appR + 4, 0, Math.PI * 2) + ctx.stroke() + + ctx.save() + ctx.translate(sc.x, sc.y) + ctx.fillStyle = `rgba(10, 16, 22, 0.95)` + ctx.strokeStyle = `rgba(${rgb}, ${0.95 * dim})` + ctx.lineWidth = selected ? 1.8 : 1.2 + ctx.beginPath() + ctx.arc(0, 0, appR, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + + // small inner dot for selected, so the center reads as a target + if (selected) { + ctx.fillStyle = `rgba(${rgb}, ${0.9 * dim})` + ctx.beginPath() + ctx.arc(0, 0, 2.5, 0, Math.PI * 2) + ctx.fill() + } + ctx.restore() + + const showLabel = appR > LOD.LABEL_VISIBLE || hover || selected + if (showLabel) { + const labelY = sc.y + appR + 14 + ctx.textAlign = "center" + ctx.textBaseline = "top" + ctx.fillStyle = `rgba(215, 230, 234, ${dim * (selected ? 1 : 0.85)})` + ctx.font = `500 11px ${FONT_MONO}` + const text = e.name.length > 32 ? e.name.slice(0, 32) + "…" : e.name + ctx.fillText(text, sc.x, labelY) + if (appR > LOD.LEAF_DETAIL) { + ctx.fillStyle = `rgba(120, 180, 190, ${dim * 0.75})` + ctx.font = `10px ${FONT_MONO}` + ctx.fillText(e.kind.toUpperCase(), sc.x, labelY + 14) + } + } + + if (appR > LOD.LEAF_DEEP) { + drawLeafDeepCard(ctx, e, sc, appR, dim) + } +} + +const CARD_INTERNAL_KEYS = new Set([ + "ref_id", "pubkey", "owner_reference_id", "node_type", + "date_added_to_graph", "status", "project_id", + "name", "title", "description", "text", "transcript", "summary", + "media_url", "link", "image_url", "thumbnail", "source_link", + "mapX", "mapY", "mapZ", +]) + +function pickCardFields(e: SigEntity): { label: string; value: string }[] { + const props = e.node.properties as Record | undefined + if (!props) return [] + const out: { label: string; value: string }[] = [] + for (const key of Object.keys(props)) { + if (CARD_INTERNAL_KEYS.has(key)) continue + const v = props[key] + if (typeof v === "string" && v.length > 0) { + out.push({ label: key, value: v.length > 48 ? v.slice(0, 48) + "…" : v }) + } else if (typeof v === "number") { + out.push({ label: key, value: String(v) }) + } + if (out.length >= 4) break + } + // Always surface a description preview if present + if (out.length < 4) { + const desc = pickString(props, "description") ?? pickString(props, "summary") + if (desc) out.push({ label: "about", value: desc.slice(0, 64) + (desc.length > 64 ? "…" : "") }) + } + // Fall back: if no fields surfaced, show the title-key name + if (out.length === 0) { + for (const key of DISPLAY_KEY_FALLBACKS) { + const v = props[key] + if (typeof v === "string" && v.length > 0) { + out.push({ label: key, value: v.length > 48 ? v.slice(0, 48) + "…" : v }) + break + } + } + } + return out +} + +function drawLeafDeepCard( + ctx: CanvasRenderingContext2D, + e: SigEntity, + sc: { x: number; y: number }, + appR: number, + dim: number, +) { + const fields = pickCardFields(e) + const w = 220 + const lineH = 14 + const headerH = 38 + const bodyH = Math.max(fields.length * lineH + 10, 10) + const h = headerH + bodyH + const x = sc.x + appR + 18 + const y = sc.y - h / 2 + + // connector + ctx.strokeStyle = `rgba(74, 224, 210, ${0.4 * dim})` + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(sc.x + appR + 4, sc.y) + ctx.lineTo(x, y + h / 2) + ctx.stroke() + + ctx.fillStyle = C.panel + ctx.strokeStyle = C.panelBorder + ctx.lineWidth = 1 + roundRect(ctx, x, y, w, h, 4) + ctx.fill() + ctx.stroke() + + ctx.fillStyle = `rgba(74, 224, 210, ${0.92 * dim})` + ctx.font = `600 12px ${FONT_MONO}` + ctx.textAlign = "left" + ctx.textBaseline = "top" + ctx.fillText(e.name.slice(0, 26), x + 10, y + 8) + + ctx.fillStyle = `rgba(120, 180, 190, ${0.85 * dim})` + ctx.font = `10px ${FONT_MONO}` + ctx.fillText(e.kind.toUpperCase(), x + 10, y + 24) + + let cy = y + headerH + ctx.font = `10px ${FONT_MONO}` + for (const f of fields) { + ctx.fillStyle = `rgba(120, 180, 190, ${0.7 * dim})` + ctx.fillText(f.label, x + 10, cy) + ctx.fillStyle = `rgba(215, 230, 234, ${0.92 * dim})` + ctx.fillText(f.value, x + 80, cy) + cy += lineH + } +} + +export function drawEdge( + ctx: CanvasRenderingContext2D, + from: { x: number; y: number }, + to: { x: number; y: number }, + dim: number, + label?: string, +) { + ctx.strokeStyle = `rgba(120, 200, 220, ${0.35 * dim})` + ctx.lineWidth = 1 + ctx.setLineDash([4, 4]) + ctx.beginPath() + ctx.moveTo(from.x, from.y) + ctx.lineTo(to.x, to.y) + ctx.stroke() + ctx.setLineDash([]) + + if (label) { + const mx = (from.x + to.x) / 2 + const my = (from.y + to.y) / 2 + ctx.fillStyle = `rgba(10, 16, 22, ${0.85 * dim})` + const text = label + ctx.font = `9px ${FONT_MONO}` + const w = ctx.measureText(text).width + 10 + roundRect(ctx, mx - w / 2, my - 7, w, 14, 2) + ctx.fill() + ctx.fillStyle = `rgba(120, 200, 220, ${0.9 * dim})` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText(text, mx, my) + } +} diff --git a/src/components/case-view/index.ts b/src/components/case-view/index.ts new file mode 100644 index 00000000..7e4f1d11 --- /dev/null +++ b/src/components/case-view/index.ts @@ -0,0 +1,2 @@ +export { CaseView } from "./case-view" +export type { CaseViewProps } from "./case-view" diff --git a/src/components/case-view/layout.ts b/src/components/case-view/layout.ts new file mode 100644 index 00000000..7a234dad --- /dev/null +++ b/src/components/case-view/layout.ts @@ -0,0 +1,51 @@ +import type { SigEntity } from "./types" + +// Radial 1-hop layout. Selected at origin; neighbors evenly spaced around it +// on a ring whose radius is derived from the packing constraint +// 2πR ≥ 2 · sumR + N · gap → R ≥ (sumR + N·gap/2) / π +// so neighbor radii + a configurable gap always fit. Stable ordering by id so +// re-layouts (e.g. after a click switches the center) don't jitter. +const RING_GAP = 80 + +export function layoutRing(selected: SigEntity, neighbors: SigEntity[]): void { + selected.x = 0 + selected.y = 0 + + const N = neighbors.length + if (N === 0) return + + if (N === 1) { + const c = neighbors[0] + c.x = selected.r + c.r + RING_GAP + c.y = 0 + return + } + + let maxR = 0 + let sumR = 0 + for (const c of neighbors) { + if (c.r > maxR) maxR = c.r + sumR += c.r + } + const Rpack = (sumR + (N * RING_GAP) / 2) / Math.PI + const ringR = Math.max(selected.r + maxR + RING_GAP, Rpack) + + const ordered = neighbors.slice().sort((a, b) => a.id.localeCompare(b.id)) + ordered.forEach((c, i) => { + const angle = (i / N) * Math.PI * 2 - Math.PI / 2 + c.x = Math.cos(angle) * ringR + c.y = Math.sin(angle) * ringR + }) +} + +export function computeWorldBBox(entities: SigEntity[]) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const e of entities) { + if (e.x - e.r < minX) minX = e.x - e.r + if (e.y - e.r < minY) minY = e.y - e.r + if (e.x + e.r > maxX) maxX = e.x + e.r + if (e.y + e.r > maxY) maxY = e.y + e.r + } + if (!isFinite(minX)) return { minX: -200, minY: -200, maxX: 200, maxY: 200 } + return { minX, minY, maxX, maxY } +} diff --git a/src/components/case-view/types.ts b/src/components/case-view/types.ts new file mode 100644 index 00000000..912c92ad --- /dev/null +++ b/src/components/case-view/types.ts @@ -0,0 +1,33 @@ +import type { GraphNode } from "@/lib/graph-api" + +export type Status = "ACTIVE" | "WARN" | "IDLE" + +export interface SigEntity { + id: string + name: string + kind: string + isSelected: boolean + x: number + y: number + r: number + color: string + node: GraphNode +} + +export interface SigEdge { + id: string + fromId: string + toId: string + from: SigEntity + to: SigEntity + label?: string +} + +export interface SigDataset { + selectedId: string + selected: SigEntity + byId: Map + flat: SigEntity[] + edges: SigEdge[] + worldBBox: { minX: number; minY: number; maxX: number; maxY: number } +} diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 40430171..d67eaa59 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Canvas, useFrame, useThree } from "@react-three/fiber" -import { CameraControls } from "@react-three/drei" +import { CameraControls, Html } from "@react-three/drei" import { EffectComposer, Bloom } from "@react-three/postprocessing" import { Vector3 } from "three" import * as THREE from "three" @@ -23,6 +23,7 @@ import { useGraphStore } from "@/stores/graph-store" import { useAppStore } from "@/stores/app-store" import type { SchemaNode } from "@/app/ontology/page" import { HoverPreviewCard } from "./hover-preview-card" +import { CaseView } from "@/components/case-view" import { DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" import { metroSeries } from "@/data/metro" import { @@ -521,6 +522,17 @@ const OVERVIEW_CAM: CamTarget = { lookX: 0, lookY: 0, lookZ: 0, } +// World-units distance from camera to the selected node at which continuous +// zoom flips into the 2D case view. Post-click rest distance is ~46 units +// (cameraHeight from computeCamTarget); the trigger is well below that so +// settling after a click doesn't accidentally fire it. +const CASE_VIEW_TRIGGER_DISTANCE = 8 + +// Camera billboard-scale for selected nodes (from graph-viz-kit depth visuals). +// Used to estimate the apparent on-screen radius at handoff so the 2D canvas +// can mount with cam.scale matching that radius — pixel-continuous handoff. +const SELECTED_NODE_BILLBOARD_SCALE = 0.6 + function smoothstep(x: number) { return x * x * (3 - 2 * x) } @@ -596,6 +608,131 @@ function CameraSync({ return null } +// Watches camera-to-target distance for the selected node. When the user +// keeps dollying past CASE_VIEW_TRIGGER_DISTANCE the case view opens — +// continuous zoom IS the navigation. Also renders the discoverability button +// near the selected node so users who don't know about the gesture have an +// explicit affordance. Both paths call the same onOpen callback. +function CaseViewTrigger({ + graph, + selectedNodeId, + selectedApiNode, + onOpen, + disabled, + camAnim, +}: { + graph: Graph + selectedNodeId: number | null + selectedApiNode: ApiNode | null + onOpen: (node: ApiNode, apparentRadius: number) => void + disabled: boolean + camAnim: React.RefObject<{ progress: number }> +}) { + const camera = useThree((s) => s.camera) + const size = useThree((s) => s.size) + const firedRef = useRef(false) + + useFrame(() => { + if (selectedNodeId === null || !selectedApiNode) { + firedRef.current = false + return + } + const node = graph.nodes[selectedNodeId] + if (!node) return + const p = node.position + const dx = camera.position.x - p.x + const dy = camera.position.y - p.y + const dz = camera.position.z - p.z + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) + // Re-arm once the camera pulls back past 1.5× the threshold. Hysteresis + // keeps the trigger from flapping near the boundary and prevents + // immediate re-fire after the user closes the case view (camera is + // still close to the node until handleCloseCaseView dollies it back). + if (dist > CASE_VIEW_TRIGGER_DISTANCE * 1.5) firedRef.current = false + if (disabled) return + // Skip the click-to-select lerp. computeCamTarget can land the rest + // position below the trigger threshold (Math.max(5, …) for leaf nodes) + // and the lerp would falsely fire mid-flight. Wait until the user is in + // control before considering the gesture intentional. + if (camAnim.current.progress < 1) return + if (dist < CASE_VIEW_TRIGGER_DISTANCE && !firedRef.current) { + firedRef.current = true + const fovRad = (50 / 2) * (Math.PI / 180) + const apparent = + (SELECTED_NODE_BILLBOARD_SCALE * size.height) / + (2 * dist * Math.tan(fovRad)) + onOpen(selectedApiNode, apparent) + } + }) + + if (disabled || selectedNodeId === null || !selectedApiNode) return null + const node = graph.nodes[selectedNodeId] + if (!node) return null + const p = node.position + return ( + +
+ +
+ zoom in +
+
+ + ) +} + // Debug overlay — fixed world reference + per-frame crosshairs for camera and // click-anchor positions. Lets you see whether the selected node, the camera // target, and the camera look-at are converging or diverging across a layout @@ -794,6 +931,14 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas }, [schemas]) const [viewState, setViewState] = useState({ mode: "overview" }) + // Case view overlay state. node = which node is the center of the case + // view; apparentRadius = the on-screen pixel radius the node had in 3D at + // the handoff frame, so the 2D canvas can mount with cam.scale tuned to + // match — pixel-continuous handoff. null means no case view active. + const [caseView, setCaseView] = useState<{ + node: ApiNode + apparentRadius: number + } | null>(null) const [hoveredCardNode, setHoveredCardNode] = useState(null) const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) // Metro overlay focus state — driven by hovering the lines (3D) or the @@ -862,8 +1007,39 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // eslint-disable-next-line react-hooks/set-state-in-effect -- paired with an imperative camera reset; remount would drop GL state setViewState({ mode: "overview" }) setCamTarget(OVERVIEW_CAM) + setCaseView(null) }, [dataVersion, setCamTarget]) + const selectedApiNode = useMemo(() => { + if (viewState.mode !== "subgraph") return null + const refId = indexMap.get(viewState.selectedNodeId) + if (!refId) return null + return nodes.find((n) => n.ref_id === refId) ?? null + }, [viewState, indexMap, nodes]) + + const handleOpenCaseView = useCallback( + (node: ApiNode, apparentRadius: number) => { + setCaseView({ node, apparentRadius }) + }, + [], + ) + + const handleCloseCaseView = useCallback(() => { + setCaseView(null) + // Pull camera back to the selected node's rest distance so the trigger + // re-arms and the user has room to maneuver. Without this they'd land + // right at the threshold and the next mouse-wheel tick would re-fire. + if (viewState.mode === "subgraph") { + setCamTarget( + computeCamTarget( + graph, + viewState.selectedNodeId, + cameraRef.current?.azimuthAngle ?? 0, + ), + ) + } + }, [viewState, graph, setCamTarget]) + const externalHoveredId = sidebarHoveredNode ? (refIdToIndex.get(sidebarHoveredNode.ref_id) ?? null) : null const externalSelectedId = sidebarSelectedNode ? (refIdToIndex.get(sidebarSelectedNode.ref_id) ?? null) : null @@ -1200,6 +1376,16 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas viewState={viewState} onNodeClick={handleNodeClick} /> + + + {caseView && ( +
+ +
+ )} ) } From a5066d150946db2b903a3ecf416bdb922c8f8c08 Mon Sep 17 00:00:00 2001 From: Rassl Date: Thu, 28 May 2026 05:26:10 +0400 Subject: [PATCH 06/19] feat: work in progress --- src/components/case-view/case-view.tsx | 173 +++++++++-- src/components/case-view/constants.ts | 35 ++- src/components/case-view/draw.ts | 360 ++++++++++++++++++++--- src/components/case-view/layout.ts | 59 +++- src/components/feed/feed-view.tsx | 22 +- src/components/universe/graph-canvas.tsx | 51 ++-- 6 files changed, 585 insertions(+), 115 deletions(-) diff --git a/src/components/case-view/case-view.tsx b/src/components/case-view/case-view.tsx index 5da2cd5f..a86b05cb 100644 --- a/src/components/case-view/case-view.tsx +++ b/src/components/case-view/case-view.tsx @@ -13,6 +13,7 @@ import { drawDot, drawLeafGlyph, drawEdge, + getNodeCardBounds, } from "./draw" import type { SigDataset, SigEntity } from "./types" @@ -60,6 +61,16 @@ export interface CaseViewProps { onExit: () => void } +// Duration of the cross-fade between the 3D canvas and the 2D case view, in +// ms. Used both on open (after data arrives) and on close (before onExit). +const FADE_MS = 300 +// Duration of the post-landing zoom-out: the selected node lands at the same +// pixel size it had in 3D, then eases out to REST_SCALE so neighbors come +// into view. Tuned to feel like a continuation of the camera dolly, not a +// separate animation. +const REST_SCALE_ANIM_MS = 600 +const REST_SCALE = 1.0 + export function CaseView({ initialNode, schemas, @@ -69,6 +80,10 @@ export function CaseView({ const [currentRefId, setCurrentRefId] = useState(initialNode.ref_id) const [data, setData] = useState(null) const [loadError, setLoadError] = useState(null) + // Drives the outer-div CSS opacity transition. Stays 0 until data lands, + // then ramps to 1 (open). Flips back to 0 on close, and after the + // transition completes we call the parent's onExit to actually unmount. + const [visible, setVisible] = useState(false) const stageRef = useRef(null) const bgRef = useRef(null) const edgeRef = useRef(null) @@ -116,6 +131,11 @@ export function CaseView({ onNeighborClick: null as null | ((e: SigEntity) => void), onExitZoom: null as null | (() => void), exitFired: false, + // While the auto-fit animation is running, the cam.scale is being + // driven programmatically. Suppress the zoom-out-to-exit trigger so + // high-degree centers (which fit at low scale) don't bounce the user + // back to 3D the moment they open the case view. + autoFitInProgress: false, }) useEffect(() => { @@ -135,31 +155,102 @@ export function CaseView({ } }, [data, initialApparentRadius]) + // Fade in once data is ready — keeps the case view transparent (so the 3D + // scene shows through) during the fetch, eliminating the "loading…" flash. + // Errors also flip visible so the failure message isn't hidden by opacity:0. + useEffect(() => { + if (!data && !loadError) return + // rAF lets the browser commit the opacity:0 initial frame before + // flipping to 1, so the CSS transition actually animates. + const id = requestAnimationFrame(() => setVisible(true)) + return () => cancelAnimationFrame(id) + }, [data, loadError]) + + // After landing pixel-continuous on the selected node, ease cam.scale to + // a "fit" zoom — derived from the layout's world bounding box so the + // outermost ring sits comfortably inside the viewport with margin. + // Auto-fit suppresses the zoom-out-to-exit trigger so high-degree + // centers (which fit at low scale) don't bounce the user back to 3D the + // moment they open the case view. + useEffect(() => { + if (!data) return + const S = stateRef.current + if (S.w === 0 || S.h === 0) return + const start = S.cam.scale + const bb = data.worldBBox + const worldW = Math.max(bb.maxX - bb.minX, 1) + const worldH = Math.max(bb.maxY - bb.minY, 1) + const margin = 80 + const fitScale = Math.min( + (S.w - margin * 2) / worldW, + (S.h - margin * 2) / worldH, + ) + // Clamp above the exit threshold so the animation can't bounce us back + // to 3D mid-fit. Clamp below 2× rest scale so low-degree centers don't + // zoom in absurdly. + const target = Math.max( + EXIT_ZOOM_THRESHOLD * 1.5, + Math.min(fitScale, Math.max(start, REST_SCALE * 2)), + ) + const startTime = performance.now() + let raf = 0 + S.autoFitInProgress = true + function tick() { + const t = Math.min(1, (performance.now() - startTime) / REST_SCALE_ANIM_MS) + const eased = 1 - Math.pow(1 - t, 3) + S.cam.scale = start + (target - start) * eased + if (t < 1) { + raf = requestAnimationFrame(tick) + } else { + S.autoFitInProgress = false + } + } + raf = requestAnimationFrame(tick) + return () => { + cancelAnimationFrame(raf) + S.autoFitInProgress = false + } + }, [data]) + + // Triggers the fade-out, then calls the parent's onExit once the + // transition has finished playing. Replaces direct onExit() everywhere so + // every close path (Esc / X button / zoom-out trigger) animates. + const requestExit = useCallback(() => { + setVisible(false) + setTimeout(onExit, FADE_MS) + }, [onExit]) + // Esc + close button exit useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onExit() + if (e.key === "Escape") requestExit() } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) - }, [onExit]) + }, [requestExit]) - // Hit-test in screen space against current visible entities + // Hit-test in screen space. At high LOD the node renders as a content + // card (drawNodeCard), so the test uses its rectangular bounds; + // otherwise it falls back to the circular radius the glyph occupies. const hitTest = useCallback((sx: number, sy: number): SigEntity | null => { const S = stateRef.current if (!S.data) return null - let hit: SigEntity | null = null for (const e of S.data.flat) { const sc = worldToScreen(e.x, e.y, S.cam, S.w, S.h) - const r = e.r * S.cam.scale + 6 - const dx = sx - sc.x - const dy = sy - sc.y - if (dx * dx + dy * dy <= r * r) { - hit = e - break + const appR = e.r * S.cam.scale + if (appR > LOD.CARD_VISIBLE) { + const b = getNodeCardBounds(e, sc, appR) + if (sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h) { + return e + } + } else { + const r = appR + 6 + const dx = sx - sc.x + const dy = sy - sc.y + if (dx * dx + dy * dy <= r * r) return e } } - return hit + return null }, []) const handleNeighborClick = useCallback((e: SigEntity) => { @@ -169,8 +260,8 @@ export function CaseView({ useEffect(() => { stateRef.current.onNeighborClick = handleNeighborClick - stateRef.current.onExitZoom = onExit - }, [handleNeighborClick, onExit]) + stateRef.current.onExitZoom = requestExit + }, [handleNeighborClick, requestExit]) // Render loop + input handling useEffect(() => { @@ -227,11 +318,44 @@ export function CaseView({ function drawEdges() { clear(edgeCtx!, S.w, S.h) if (!S.data) return - for (const e of S.data.edges) { + const selectedId = S.data.selectedId + // 1) Only draw edges that touch the selected node — sibling-to-sibling + // edges from the API turn the case board into a hairball. + const drawable = S.data.edges.filter( + (e) => e.fromId === selectedId || e.toId === selectedId, + ) + // 2) Label dedup: show each edge-type label at most once, on the + // edge whose midpoint is closest to screen-center. + const showLabelsAtAll = S.cam.scale > 0.4 + const labelEdgeId = new Set() + if (showLabelsAtAll) { + const byType = new Map() + for (const e of drawable) { + const arr = byType.get(e.label ?? "") ?? [] + arr.push(e) + byType.set(e.label ?? "", arr) + } + for (const [, arr] of byType) { + let bestId = arr[0].id + let bestDx = Infinity + for (const e of arr) { + const ax = (e.from.x - S.cam.x) * S.cam.scale + S.w / 2 + const bx = (e.to.x - S.cam.x) * S.cam.scale + S.w / 2 + const mx = (ax + bx) / 2 + const dx = Math.abs(mx - S.w / 2) + if (dx < bestDx) { + bestDx = dx + bestId = e.id + } + } + labelEdgeId.add(bestId) + } + } + for (const e of drawable) { const a = worldToScreen(e.from.x, e.from.y, S.cam, S.w, S.h) const b = worldToScreen(e.to.x, e.to.y, S.cam, S.w, S.h) - const showLabel = S.cam.scale > 0.4 - drawEdge(edgeCtx!, a, b, 1, showLabel ? e.label : undefined) + const label = labelEdgeId.has(e.id) ? e.label : undefined + drawEdge(edgeCtx!, a, b, 1, label) } } @@ -263,11 +387,14 @@ export function CaseView({ drawBackground() drawEdges() drawNodes() - // Exit-on-zoom-out: once the selected entity becomes too small, trigger - // a clean 2D→3D handoff. Guard with exitFired so we don't fire twice if - // the animation takes a few frames. + // Exit-on-zoom-out: once the user pulls the camera back past the + // threshold, trigger a clean 2D→3D handoff. Suppressed while the + // auto-fit animation is driving cam.scale — otherwise high-degree + // centers would auto-fit through the threshold and bounce out + // before the user has even seen the layout. if ( !S.exitFired && + !S.autoFitInProgress && S.data && S.cam.scale < EXIT_ZOOM_THRESHOLD && S.onExitZoom @@ -356,7 +483,11 @@ export function CaseView({
+ )} + {metroEnabled && ( )} - {caseView && ( - // z-index has to outrank the react-three Html portals from the - // graph library, which default to 16777271. Otherwise 3D node - // labels bleed through the case view both during the fade and - // once it's fully opaque. -
- -
- )}
) } From 9a8441380124e696d74915bab2939156fb467d69 Mon Sep 17 00:00:00 2001 From: Rassl Date: Sun, 31 May 2026 22:43:41 +0400 Subject: [PATCH 08/19] feat: case-board group overlay with semantic zoom Add case-group, group-layout, group-morph, card-style, and board-scroll-lock modules; wire grouping into the case-board animator and graph-canvas. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../case-board/board-scroll-lock.ts | 6 + src/components/case-board/card-style.ts | 32 ++ .../case-board/case-board-animator.tsx | 89 +++- src/components/case-board/case-card.tsx | 29 +- src/components/case-board/case-group.tsx | 268 ++++++++++ src/components/case-board/group-layout.ts | 241 +++++++++ src/components/case-board/group-morph.tsx | 75 +++ src/components/case-board/index.ts | 4 + src/components/case-board/layout.ts | 57 ++ src/components/case-board/node-morph.tsx | 28 +- src/components/universe/graph-canvas.tsx | 492 +++++++++++++----- 11 files changed, 1154 insertions(+), 167 deletions(-) create mode 100644 src/components/case-board/board-scroll-lock.ts create mode 100644 src/components/case-board/card-style.ts create mode 100644 src/components/case-board/case-group.tsx create mode 100644 src/components/case-board/group-layout.ts create mode 100644 src/components/case-board/group-morph.tsx diff --git a/src/components/case-board/board-scroll-lock.ts b/src/components/case-board/board-scroll-lock.ts new file mode 100644 index 00000000..a200de38 --- /dev/null +++ b/src/components/case-board/board-scroll-lock.ts @@ -0,0 +1,6 @@ +// Tiny shared flag so a scrollable group list can suppress the board's +// wheel-zoom while the pointer is over it — otherwise wheeling to scroll the +// list would also zoom the whole board. Set by CaseGroup on pointer enter / +// leave; read by the board's wheel handler. Single board instance, so a module +// singleton is enough (no context plumbing through the drei portal). +export const boardScrollLock = { locked: false } diff --git a/src/components/case-board/card-style.ts b/src/components/case-board/card-style.ts new file mode 100644 index 00000000..de7f3f0d --- /dev/null +++ b/src/components/case-board/card-style.ts @@ -0,0 +1,32 @@ +// Shared visual tokens for the case board (cards + group containers). Kept in +// one place so CaseCard and CaseGroup stay in sync. Accents are saturated +// enough to read as neon chrome against the very dark fills. + +export const TYPE_ACCENT: Record = { + Person: "#5cc9d8", + Organization: "#a78bfa", + Location: "#6ee7b7", + Station: "#fbbf24", + Weapon: "#f87171", + Item: "#5cc9d8", + Transport: "#fb923c", + Creature: "#f9a8d4", + Episode: "#93c5fd", + Chapter: "#93c5fd", + Clip: "#93c5fd", + Topic: "#a78bfa", + Tweet: "#5cc9d8", + Claim: "#fcd34d", +} + +export const DEFAULT_ACCENT = "#94a3b8" + +export const INK_PRIMARY = "#e8edf2" +export const INK_BODY = "#c9d1d9" +export const INK_DIM = "#6b7280" +export const CARD_BG = "#0d1218" +export const FIELD_BG = "#070b11" + +export function accentFor(type: string | null | undefined): string { + return (type && TYPE_ACCENT[type]) || DEFAULT_ACCENT +} diff --git a/src/components/case-board/case-board-animator.tsx b/src/components/case-board/case-board-animator.tsx index 31c56e66..e4c902e2 100644 --- a/src/components/case-board/case-board-animator.tsx +++ b/src/components/case-board/case-board-animator.tsx @@ -2,12 +2,22 @@ import { useEffect, useRef } from "react" import { useFrame } from "@react-three/fiber" +import { Vector3 } from "three" import type CameraControlsImpl from "camera-controls" import { useCaseBoardStore } from "./case-board-store" -// Total morph duration in seconds — the time to ease from progress 0→1 -// (open) or 1→0 (close). 1.2s feels deliberate without dragging. +// Total morph duration in seconds — the time to ease morphProgress 0→1 (open) +// or 1→0 (close). 1.2s feels deliberate without dragging. const MORPH_DURATION_S = 1.2 +// Camera fly-to-board-pose duration. +const CAM_MOVE_S = 0.9 + +// Board camera pose, as an offset from the focal node. MUST match +// CASE_BOARD_CAM_OFFSET in graph-canvas (which derives the right/up basis the +// neighbor cards are laid out in) — otherwise cards project to the wrong place. +const CAM_DX = 28 +const CAM_DY = 14 +const CAM_DZ = 11.2 function smoothstep(x: number) { return x * x * (3 - 2 * x) @@ -15,36 +25,73 @@ function smoothstep(x: number) { interface CaseBoardAnimatorProps { // World position of the focal node when the morph is opening — drives the - // camera move. Null when the case board isn't active. + // camera move. Null while the graph is still computing the node position. focalWorld: [number, number, number] | null cameraRef: React.RefObject } -// Drives morphProgress toward morphTarget each frame, and triggers the -// one-shot camera move to a case-board viewing angle when the morph opens. -// Lives inside the R3F Canvas so it can read state.camera via useFrame. +// Drives morphProgress toward morphTarget each frame, and continuously steers +// the camera to the case-board pose while the board is open. +// +// The camera move is enforced EVERY FRAME (not a one-shot setLookAt), eased +// from wherever the camera was when the board opened to the board pose, then +// held there. A one-shot move raced the select fly-in / CameraControls and +// lost — leaving the camera stuck close to the focal, which made the focal +// card huge and the neighbor cards tiny + clustered (they're laid out assuming +// the camera is at the board pose). Driving it each frame can't be lost to a +// race. Camera actions are locked to NONE while open, so nothing fights this. export function CaseBoardAnimator({ focalWorld, cameraRef }: CaseBoardAnimatorProps) { const target = useCaseBoardStore((s) => s.morphTarget) const setProgress = useCaseBoardStore((s) => s.setProgress) const linearRef = useRef(0) - // Whenever target flips to 1 with a focal point, ease the camera to a - // case-board view: slightly elevated, off to one side, looking at the focal. - // The 60°-ish angle reads as "front of the node" without being dead-flat. - // Close direction is handled by the parent's existing setCamTarget flow. + // Camera fly-in state for the current open. + const camProgRef = useRef(1) + const camStartPosRef = useRef<[number, number, number] | null>(null) + const camStartLookRef = useRef<[number, number, number] | null>(null) + const armedRef = useRef(false) + const tmpPos = useRef(new Vector3()) + const tmpLook = useRef(new Vector3()) + + // Re-arm on close so the next open re-captures a fresh start pose. useEffect(() => { - if (target <= 0.001) return - if (!focalWorld || !cameraRef.current) return - const [fx, fy, fz] = focalWorld - const dist = 28 - const elevation = 14 - const camX = fx + dist - const camY = fy + elevation - const camZ = fz + dist * 0.4 - cameraRef.current.setLookAt(camX, camY, camZ, fx, fy, fz, true) - }, [target, focalWorld, cameraRef]) + if (target <= 0.001) armedRef.current = false + }, [target]) useFrame((_, delta) => { + const cam = cameraRef.current + + // --- Camera: steer to the board pose while open --- + if (cam && target > 0.001 && focalWorld) { + if (!armedRef.current) { + // Capture the live camera pose as the animation start. + const p = cam.getPosition(tmpPos.current) + const l = cam.getTarget(tmpLook.current) + camStartPosRef.current = [p.x, p.y, p.z] + camStartLookRef.current = [l.x, l.y, l.z] + camProgRef.current = 0 + armedRef.current = true + } + camProgRef.current = Math.min(1, camProgRef.current + delta / CAM_MOVE_S) + const e = smoothstep(camProgRef.current) + const [fx, fy, fz] = focalWorld + const sp = camStartPosRef.current! + const sl = camStartLookRef.current! + const destPosX = fx + CAM_DX + const destPosY = fy + CAM_DY + const destPosZ = fz + CAM_DZ + cam.setLookAt( + sp[0] + (destPosX - sp[0]) * e, + sp[1] + (destPosY - sp[1]) * e, + sp[2] + (destPosZ - sp[2]) * e, + sl[0] + (fx - sl[0]) * e, + sl[1] + (fy - sl[1]) * e, + sl[2] + (fz - sl[2]) * e, + false, + ) + } + + // --- Morph progress --- const cur = linearRef.current if (Math.abs(cur - target) < 0.0005) { if (cur !== target) { @@ -59,8 +106,6 @@ export function CaseBoardAnimator({ focalWorld, cameraRef }: CaseBoardAnimatorPr if (dir > 0 && next > target) next = target if (dir < 0 && next < target) next = target linearRef.current = next - // Smoothstep applied at read so consumers (CaseCard opacity, future - // sphere fade) see an eased curve in both directions. setProgress(smoothstep(next)) }) diff --git a/src/components/case-board/case-card.tsx b/src/components/case-board/case-card.tsx index 83f834d5..e405afb9 100644 --- a/src/components/case-board/case-card.tsx +++ b/src/components/case-board/case-card.tsx @@ -79,12 +79,19 @@ function pickDescription(node: GraphNode): string | null { ) } +// Hard cap on the title string. Some nodes have no name/title and fall all +// the way through DISPLAY_KEY_FALLBACKS to `text`/`content`, which can be a +// whole paragraph — without a cap that renders as a giant wall of text. +const TITLE_MAX = 80 + function pickTitle(node: GraphNode): string { const props = node.properties as Record | undefined if (!props) return node.ref_id for (const key of DISPLAY_KEY_FALLBACKS) { const v = props[key] - if (typeof v === "string" && v.length > 0) return v + if (typeof v === "string" && v.length > 0) { + return v.length > TITLE_MAX ? v.slice(0, TITLE_MAX).trimEnd() + "…" : v + } } return node.ref_id } @@ -102,13 +109,18 @@ export function CaseCard({ node, variant, morphProgress, onClick }: CaseCardProp const type = node.node_type || "" const accent = TYPE_ACCENT[type] ?? DEFAULT_ACCENT const title = pickTitle(node) - const description = variant === "selected" ? pickDescription(node) : null - const fields = pickFields(node, variant === "selected" ? 4 : 2) + const isSelected = variant === "selected" + // Neighbor cards carry real detail now — hero image + a few fields — since + // sparse relationships render as individual cards (dense ones still collapse + // into a group). Description stays focal-only so the centerpiece remains the + // most detailed card. + const description = isSelected ? pickDescription(node) : null + const fields = pickFields(node, isSelected ? 4 : 3) const thumbnail = resolveNodeThumbnail(node) const opacity = Math.max(0, Math.min(1, morphProgress)) - const widthPx = variant === "selected" ? 280 : 230 - const heroHeight = variant === "selected" ? 170 : 130 + const widthPx = isSelected ? 300 : 240 + const heroHeight = isSelected ? 170 : 132 return (
0 ? 10 : 0, + // Never let a long fallback title (e.g. a node with only `text`) + // grow into a tall column — clamp to 2 lines with ellipsis. + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + overflow: "hidden", + wordBreak: "break-word", }} > {title} diff --git a/src/components/case-board/case-group.tsx b/src/components/case-board/case-group.tsx new file mode 100644 index 00000000..120790c0 --- /dev/null +++ b/src/components/case-board/case-group.tsx @@ -0,0 +1,268 @@ +"use client" + +import type { GraphNode } from "@/lib/graph-api" +import { DISPLAY_KEY_FALLBACKS, capTitle, resolveNodeThumbnail } from "@/lib/node-display" +import { accentFor, INK_PRIMARY, INK_DIM, CARD_BG, FIELD_BG } from "./card-style" +import { boardScrollLock } from "./board-scroll-lock" + +function titleOf(node: GraphNode): string { + const props = node.properties as Record | undefined + if (props) { + for (const k of DISPLAY_KEY_FALLBACKS) { + const v = props[k] + if (typeof v === "string" && v.length > 0) return capTitle(v, 48) + } + } + return node.ref_id +} + +// Short trailing meta for a member row — a timestamp / duration / date if the +// node carries one, else nothing. Keeps the list scannable like the reference. +const META_KEYS = ["timestamp", "start", "start_time", "time", "duration", "date", "year"] +function metaOf(node: GraphNode): string | null { + const props = node.properties as Record | undefined + if (!props) return null + for (const k of META_KEYS) { + const v = props[k] + if (typeof v === "string" && v.length > 0 && v.length <= 12) return v + if (typeof v === "number") return String(v) + } + return null +} + +const GROUP_WIDTH = 256 +// Card shows ~7 rows then scrolls internally — keeps a 50-member group from +// becoming a giant card while every member stays reachable. +const LIST_MAX_HEIGHT = 360 + +export interface CaseGroupProps { + // node_type — drives the label + accent. + type: string + members: GraphNode[] + // Relationship to the focal (dominant edge_type) — shown in the header. + edgeLabel?: string + // Whether the body (member list) is shown. Default open; the header toggle + // collapses to the header only. + expanded: boolean + morphProgress: number + onToggle: () => void + onMemberClick: (refId: string) => void +} + +// A labeled group container: header (type · count + collapse toggle) over a +// vertical list of member rows. Members past ROW_CAP collapse into a "+N more" +// footer so tall types (e.g. 10 topics) don't run off the board. +export function CaseGroup({ + type, + members, + edgeLabel, + expanded, + morphProgress, + onToggle, + onMemberClick, +}: CaseGroupProps) { + const accent = accentFor(type) + const opacity = Math.max(0, Math.min(1, morphProgress)) + const count = members.length + + return ( +
+ {/* Header */} +
+ + {type || "node"} + + + {count} + + {edgeLabel && ( + + {edgeLabel} + + )} + + {expanded ? "−" : "+"} + +
+ + {/* Body */} + {expanded && ( +
{ + boardScrollLock.locked = + e.currentTarget.scrollHeight > e.currentTarget.clientHeight + }} + onPointerLeave={() => { + boardScrollLock.locked = false + }} + onWheel={(e) => { + if (e.currentTarget.scrollHeight > e.currentTarget.clientHeight) { + e.stopPropagation() + } + }} + style={{ + padding: 6, + display: "grid", + gap: 5, + maxHeight: LIST_MAX_HEIGHT, + overflowY: "auto", + // Pin X to hidden — leaving it default makes CSS promote overflow-x + // to auto whenever overflow-y is auto, which shows a stray + // horizontal scrollbar on the slightest content overflow. + overflowX: "hidden", + }} + > + {members.map((m) => ( + onMemberClick(m.ref_id)} + /> + ))} +
+ )} +
+ ) +} + +function MemberRow({ + node, + accent, + onClick, +}: { + node: GraphNode + accent: string + onClick: () => void +}) { + const thumb = resolveNodeThumbnail(node) + const title = titleOf(node) + const meta = metaOf(node) + return ( +
+ {/* Leading badge — thumbnail if present, else first letter. */} +
+ {!thumb && (title[0]?.toUpperCase() || "•")} +
+
+ {title} +
+ {meta && ( +
+ {meta} +
+ )} +
+ ) +} diff --git a/src/components/case-board/group-layout.ts b/src/components/case-board/group-layout.ts new file mode 100644 index 00000000..933726c7 --- /dev/null +++ b/src/components/case-board/group-layout.ts @@ -0,0 +1,241 @@ +// Radial placement for case-board groups (EVE-style hub & spokes). The focal +// node sits at the origin; each group gets an evenly spaced angular slot on a +// ring around it. Deterministic — seeded by the focal refId so re-opening the +// same node always arranges the groups the same way. + +export type Pos2D = { x: number; y: number } + +// One case-board group: neighbors of the focal sharing a node_type. `key` is a +// stable id used for layout + connector projection; `edgeLabel` is the dominant +// relationship to the focal, shown on the bundled connector + group header. +export interface CaseGroupDef { + key: string + type: string + memberRefIds: string[] + edgeLabel: string +} + +export interface GroupLayoutInput { + // Group identifiers, in stable order. One ring slot is produced per key. + groupKeys: string[] + // Seed string — pass the focal refId so the layout is stable across re-opens. + seed: string + // Ring radius in normalized units (focal at origin). Caller multiplies by + // world units to scale the whole board. Default 1. + radius?: number + // Angle (radians) of the first slot. Default -90° = straight up. + startAngle?: number +} + +export function computeGroupLayout({ + groupKeys, + seed, + radius = 1, + startAngle = -Math.PI / 2, +}: GroupLayoutInput): Map { + const n = groupKeys.length + const map = new Map() + if (n === 0) return map + + // Deterministic seeded LCG for a small organic jitter on each angle. + let s = 0 + for (let i = 0; i < seed.length; i++) s = (s * 31 + seed.charCodeAt(i)) | 0 + s = (s >>> 0) || 1 + const rand = () => { + s = (s * 1664525 + 1013904223) | 0 + return (s >>> 0) / 0xffffffff + } + + // A single group reads best parked above the focal rather than dead-center. + if (n === 1) { + map.set(groupKeys[0], { + x: Math.cos(startAngle) * radius, + y: Math.sin(startAngle) * radius, + }) + return map + } + + for (let i = 0; i < n; i++) { + const angle = startAngle + (i / n) * Math.PI * 2 + (rand() - 0.5) * 0.12 + map.set(groupKeys[i], { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + }) + } + return map +} + +// Column layout — focal in the center, groups stacked vertically in a left and +// a right column (like the reference board). Positions are in "spread units" +// (caller multiplies by CASE_BOARD_SPREAD): 1.0 ≈ the column offset distance. +// Each group's vertical footprint is estimated from its member count so the +// stack packs tightly without overlap. Members beyond ROW_CAP don't add height +// (the container scrolls / shows "+N more"). +export interface ColumnLayoutInput { + groups: { key: string; memberCount: number }[] + // Horizontal distance of each column from the focal, in spread units. + columnX?: number +} + +// Tuned against the resting zoom (≈ px-per-spread-unit). Bump ROW/HEADER if +// groups overlap vertically; bump columnX if columns crowd the focal card. +const HEADER_UNITS = 0.1 +const ROW_UNITS = 0.085 +const GAP_UNITS = 0.12 +// The card scrolls internally past this many rows, so its on-screen height — +// and thus its layout slot — is capped here (matches LIST_MAX_HEIGHT). +const VISIBLE_ROWS = 7 + +export function computeColumnLayout({ + groups, + columnX = 0.95, +}: ColumnLayoutInput): Map { + const map = new Map() + if (groups.length === 0) return map + + // Slot height tracks the visible (capped) row count — tall groups scroll + // internally rather than pushing the column open. + const heightOf = (count: number) => + HEADER_UNITS + Math.min(count, VISIBLE_ROWS) * ROW_UNITS + + // Greedily balance groups (tallest first) across two columns so neither + // side runs much longer than the other. + const sorted = [...groups].sort((a, b) => b.memberCount - a.memberCount) + const columns: { x: number; items: { key: string; h: number }[]; total: number }[] = [ + { x: columnX, items: [], total: 0 }, // right + { x: -columnX, items: [], total: 0 }, // left + ] + for (const g of sorted) { + const h = heightOf(g.memberCount) + const col = columns[0].total <= columns[1].total ? columns[0] : columns[1] + col.items.push({ key: g.key, h }) + col.total += h + GAP_UNITS + } + + for (const col of columns) { + const stackHeight = col.total - GAP_UNITS + // y axis points up on screen, so start at the top (+half) and walk down. + let y = stackHeight / 2 + for (const item of col.items) { + map.set(item.key, { x: col.x, y: y - item.h / 2 }) + y -= item.h + GAP_UNITS + } + } + return map +} + +// Balanced 2D packing — items distributed around the focal on all sides +// (top/bottom/left/right) with no overlap, like the reference board. +// +// Collision is RECTANGLE-aware (AABB), not circular. Cards are boxes with very +// different aspect ratios — a Person card with a description is tall, a group +// card is wide and short — and a single collision radius can't represent that, +// so tall cards overlapped their neighbors. Each item carries half-width (hw) +// and half-height (hh) in spread units; overlaps are resolved by the minimum +// translation along the least-penetrated axis. +export interface BalancedItem { + id: string + hw: number + hh: number +} + +export interface BalancedLayoutInput { + items: BalancedItem[] + // Half-extents of the focal card (items stay outside this box). + focalHalf: { hw: number; hh: number } + seed: string + // Extra breathing room between boxes, in spread units. + gap?: number +} + +// Resolve an AABB overlap between boxes centered at a and b with the given +// half-extents + gap. Returns the push to apply to b (negate for a), or null +// if they don't overlap. Pushes along the axis of least penetration so cards +// slide apart the short way instead of jumping. +function aabbPush( + ax: number, ay: number, ahw: number, ahh: number, + bx: number, by: number, bhw: number, bhh: number, + gap: number, +): { x: number; y: number } | null { + const dx = bx - ax + const dy = by - ay + const ox = ahw + bhw + gap - Math.abs(dx) // x overlap + const oy = ahh + bhh + gap - Math.abs(dy) // y overlap + if (ox <= 0 || oy <= 0) return null + if (ox < oy) { + const dir = dx === 0 ? 1 : Math.sign(dx) + return { x: dir * ox, y: 0 } + } + const dir = dy === 0 ? 1 : Math.sign(dy) + return { x: 0, y: dir * oy } +} + +export function computeBalancedLayout({ + items, + focalHalf, + seed, + gap = 0.06, +}: BalancedLayoutInput): Map { + const n = items.length + const map = new Map() + if (n === 0) return map + + let s = 0 + for (let i = 0; i < seed.length; i++) s = (s * 31 + seed.charCodeAt(i)) | 0 + s = (s >>> 0) || 1 + const rand = () => { + s = (s * 1664525 + 1013904223) | 0 + return (s >>> 0) / 0xffffffff + } + + // Even angular start, seeded radius from each item's diagonal so the first + // frame already roughly surrounds the focal. + const pos = items.map((it, i) => { + const ang = (i / n) * Math.PI * 2 + (rand() - 0.5) * 0.2 + const reach = + Math.max(focalHalf.hw, focalHalf.hh) + Math.max(it.hw, it.hh) + gap + 0.1 + return { x: Math.cos(ang) * reach, y: Math.sin(ang) * reach } + }) + + for (let iter = 0; iter < 500; iter++) { + // Gentle inward pull so items hug the focal instead of drifting out. + for (const p of pos) { + p.x *= 0.985 + p.y *= 0.985 + } + // Keep every item's box clear of the focal box (only the item moves). + for (let i = 0; i < n; i++) { + const p = pos[i] + const push = aabbPush( + 0, 0, focalHalf.hw, focalHalf.hh, + p.x, p.y, items[i].hw, items[i].hh, + gap, + ) + if (push) { + p.x += push.x + p.y += push.y + } + } + // Separate overlapping item pairs (split the push between both). + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const a = pos[i] + const b = pos[j] + const push = aabbPush( + a.x, a.y, items[i].hw, items[i].hh, + b.x, b.y, items[j].hw, items[j].hh, + gap, + ) + if (push) { + a.x -= push.x / 2 + a.y -= push.y / 2 + b.x += push.x / 2 + b.y += push.y / 2 + } + } + } + } + + for (let i = 0; i < n; i++) map.set(items[i].id, pos[i]) + return map +} diff --git a/src/components/case-board/group-morph.tsx b/src/components/case-board/group-morph.tsx new file mode 100644 index 00000000..2bc54dc0 --- /dev/null +++ b/src/components/case-board/group-morph.tsx @@ -0,0 +1,75 @@ +"use client" + +import { Html } from "@react-three/drei" +import type { GraphNode } from "@/lib/graph-api" +import { CaseGroup } from "./case-group" + +interface GroupMorphProps { + type: string + members: GraphNode[] + edgeLabel?: string + expanded: boolean + onToggle: () => void + onMemberClick: (refId: string) => void + // Pre-morph world position (focal point — groups fan out from the focal). + originPosition: [number, number, number] + // Resting world position on the case-board ring. + targetPosition: [number, number, number] + morphProgress: number + portal?: React.RefObject + // Registers the container DOM root so the connector overlay can attach + // edges to the real card border. + registerEl?: (el: HTMLElement | null) => void +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} + +// drei wrapper for a group container — same lerp / portal / z-index +// pattern as NodeMorph, but renders a CaseGroup at the group's world anchor. +export function GroupMorph({ + type, + members, + edgeLabel, + expanded, + onToggle, + onMemberClick, + originPosition, + targetPosition, + morphProgress, + portal, + registerEl, +}: GroupMorphProps) { + if (morphProgress <= 0.001) return null + const t = Math.max(0, Math.min(1, morphProgress)) + const pos: [number, number, number] = [ + lerp(originPosition[0], targetPosition[0], t), + lerp(originPosition[1], targetPosition[1], t), + lerp(originPosition[2], targetPosition[2], t), + ] + return ( + | undefined} + position={pos} + center + // No distanceFactor — see NodeMorph: cards are a 2D DOM overlay sized by + // CSS and only positioned by the projection. Avoids the stale-canvas-size + // scaling bug; zoom is the board layer's CSS transform. + zIndexRange={[16777500, 16777400]} + style={{ pointerEvents: "auto" }} + > +
+ +
+ + ) +} diff --git a/src/components/case-board/index.ts b/src/components/case-board/index.ts index 87de55e4..2ca0a57c 100644 --- a/src/components/case-board/index.ts +++ b/src/components/case-board/index.ts @@ -4,3 +4,7 @@ export { CaseBoardAnimator } from "./case-board-animator" export { useCaseBoardStore } from "./case-board-store" export { computeCaseBoardLayout } from "./layout" export type { Pos2D } from "./layout" +export { CaseGroup } from "./case-group" +export { GroupMorph } from "./group-morph" +export { computeGroupLayout, computeColumnLayout, computeBalancedLayout } from "./group-layout" +export type { CaseGroupDef } from "./group-layout" diff --git a/src/components/case-board/layout.ts b/src/components/case-board/layout.ts index 26506cb8..5ad184bb 100644 --- a/src/components/case-board/layout.ts +++ b/src/components/case-board/layout.ts @@ -17,6 +17,11 @@ export interface ForceLayoutInput { anchorId: string | null // Seed string for the RNG — pass the focal refId so layouts are stable. seed: string + // Minimum center-to-center separation, in normalized units (farthest + // neighbor ≈ 1 before collision). The force sim treats nodes as points, so + // without this the fixed-size cards stack. Caller sets this from the card + // footprint relative to the world spread. Default 0 = no collision pass. + minSep?: number } export function computeCaseBoardLayout({ @@ -24,6 +29,7 @@ export function computeCaseBoardLayout({ edges, anchorId, seed, + minSep = 0, }: ForceLayoutInput): Map { const n = nodes.length const pos = new Map() @@ -129,5 +135,56 @@ export function computeCaseBoardLayout({ } } + // Collision relaxation in normalized space: push apart any pair closer than + // minSep. Runs after the force sim so it only resolves residual overlap + // without undoing the edge-driven clustering. The anchor is pinned; a + // neighbor sitting on top of it is shoved straight out. A handful of passes + // is enough since each pass moves overlapping pairs halfway apart. + if (minSep > 0 && n > 1) { + for (let pass = 0; pass < 60; pass++) { + let moved = false + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const idA = nodes[i] + const idB = nodes[j] + const a = pos.get(idA)! + const b = pos.get(idB)! + let dx = b.x - a.x + let dy = b.y - a.y + let d = Math.sqrt(dx * dx + dy * dy) + if (d >= minSep) continue + // Degenerate overlap (same point) — pick a deterministic direction + // from the index so seeded re-opens stay stable. + if (d < 1e-4) { + const ang = (i * 2.3999632 + j) % (Math.PI * 2) + dx = Math.cos(ang) + dy = Math.sin(ang) + d = 1e-4 + } + const push = (minSep - d) / 2 + const ux = (dx / d) * push + const uy = (dy / d) * push + const aPinned = idA === anchorId + const bPinned = idB === anchorId + if (aPinned) { + // Only B moves, by the full overlap. + b.x += ux * 2 + b.y += uy * 2 + } else if (bPinned) { + a.x -= ux * 2 + a.y -= uy * 2 + } else { + a.x -= ux + a.y -= uy + b.x += ux + b.y += uy + } + moved = true + } + } + if (!moved) break + } + } + return pos } diff --git a/src/components/case-board/node-morph.tsx b/src/components/case-board/node-morph.tsx index 216d01c1..d8f929d0 100644 --- a/src/components/case-board/node-morph.tsx +++ b/src/components/case-board/node-morph.tsx @@ -20,6 +20,9 @@ interface NodeMorphProps { // div that the parent transforms for pan / zoom — keeps board movement // independent of the 3D camera. portal?: React.RefObject + // Registers the card's DOM root so the connector overlay can measure its + // real on-screen rectangle and attach edges to the actual card border. + registerEl?: (el: HTMLElement | null) => void } function lerp(a: number, b: number, t: number) { @@ -38,6 +41,7 @@ export function NodeMorph({ morphProgress, onClick, portal, + registerEl, }: NodeMorphProps) { if (morphProgress <= 0.001) return null const t = Math.max(0, Math.min(1, morphProgress)) @@ -54,22 +58,26 @@ export function NodeMorph({ portal={portal as React.RefObject | undefined} position={pos} center - // Higher distanceFactor = bigger cards at the case-board camera distance - // (~33 units). Cards stay readable; user wheel-zoom grows/shrinks them - // naturally. - distanceFactor={30} + // No distanceFactor: the camera is locked at the board pose, so cards are + // a 2D DOM overlay — they render at their natural CSS size and are only + // POSITIONED by the 3D projection. distanceFactor coupled size to the + // renderer's canvas size, which is briefly stale on open and only + // refreshes on a real window resize — that was the "focal too big until I + // resize" bug. Board zoom is handled by the board layer's CSS transform. // Must outrank both drei's default Html zIndexRange (used by GraphView's // node + edge labels at ~16.77M) and the cream backdrop. Kept just // below the close button so chrome wins. zIndexRange={[16777500, 16777400]} style={{ pointerEvents: "auto" }} > - +
+ +
) } diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 59f5df65..65e4fa1c 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -27,7 +27,8 @@ import { NodeMorph, CaseBoardAnimator, useCaseBoardStore, - computeCaseBoardLayout, + GroupMorph, + computeBalancedLayout, } from "@/components/case-board" import { DISPLAY_KEY_FALLBACKS } from "@/lib/node-display" import { metroSeries } from "@/data/metro" @@ -583,10 +584,22 @@ function CameraSync({ look: [number, number, number] }> }) { + // While the case board is opening/open, the CaseBoardAnimator owns the + // camera (it pulls back to the board pose). CameraSync must yield — otherwise + // its in-flight select fly-in keeps overriding the board move every frame, + // leaving the camera stuck close to the focal (huge focal card, tiny faraway + // group cards). This was the "first open looks broken, reopen fixes it" bug: + // on reopen the fly-in had already settled so there was nothing to fight. + const morphActive = useCaseBoardStore((s) => s.morphTarget > 0.001) useFrame((_, delta) => { const cam = camRef.current if (!cam) return const state = targetRef.current + if (morphActive) { + // Mark the fly-in done so it doesn't resume when the board closes. + state.progress = 1 + return + } // Only drive the camera while a transition is in flight. Once it settles, // hand control back to CameraControls so the user can orbit/pan/zoom. if (state.progress >= 1) return @@ -620,6 +633,7 @@ function CaseViewTrigger({ onOpen, disabled, camAnim, + suppressedRef, }: { graph: Graph selectedNodeId: number | null @@ -627,13 +641,24 @@ function CaseViewTrigger({ onOpen: (node: ApiNode) => void disabled: boolean camAnim: React.RefObject<{ progress: number }> + // Set true by handleCloseCaseBoard so closing doesn't immediately re-fire: + // after close, computeCamTarget can park the camera inside the trigger + // distance (leaf nodes rest at ~5 < 8), which would auto-reopen. Cleared + // when the camera genuinely pulls back out past the re-arm threshold. + suppressedRef: React.RefObject }) { const camera = useThree((s) => s.camera) - const firedRef = useRef(false) + // Start DISARMED so selecting a node (whose click-to-select camera move can + // land inside the trigger distance for close-resting/leaf nodes) doesn't + // auto-open the board. It only arms once the camera has settled far out, so + // opening requires a deliberate dolly-in (or the zoom-in button). + const firedRef = useRef(true) useFrame(() => { if (selectedNodeId === null || !selectedApiNode) { - firedRef.current = false + // Disarm when nothing is selected, so the next selection's click-to- + // select camera move can't leave the trigger armed and auto-open. + firedRef.current = true return } const node = graph.nodes[selectedNodeId] @@ -643,18 +668,23 @@ function CaseViewTrigger({ const dy = camera.position.y - p.y const dz = camera.position.z - p.z const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) - // Re-arm once the camera pulls back past 1.5× the threshold. Hysteresis - // keeps the trigger from flapping near the boundary and prevents - // immediate re-fire after the user closes the case view (camera is - // still close to the node until handleCloseCaseView dollies it back). - if (dist > CASE_VIEW_TRIGGER_DISTANCE * 1.5) firedRef.current = false + // Arm only when the camera has SETTLED (progress >= 1) beyond the trigger + // distance — never mid-animation. This is what stops a click-to-select + // move (which sweeps in from far out) from arming and then auto-firing the + // instant it lands on a close-resting / leaf node. + if (camAnim.current.progress >= 1 && dist > CASE_VIEW_TRIGGER_DISTANCE) { + firedRef.current = false + // Camera settled back out — a manual close is re-armed, so a deliberate + // dolly-in can reopen the board again. + suppressedRef.current = false + } if (disabled) return // Skip the click-to-select lerp. computeCamTarget can land the rest // position below the trigger threshold (Math.max(5, …) for leaf nodes) // and the lerp would falsely fire mid-flight. Wait until the user is in // control before considering the gesture intentional. if (camAnim.current.progress < 1) return - if (dist < CASE_VIEW_TRIGGER_DISTANCE && !firedRef.current) { + if (dist < CASE_VIEW_TRIGGER_DISTANCE && !firedRef.current && !suppressedRef.current) { firedRef.current = true onOpen(selectedApiNode) } @@ -845,10 +875,63 @@ function DebugMarkers({ // camera) and by CaseBoardMorphLayer (to compute the plane the neighbor ring // sits in). Keep them in sync so cards face the camera at full morph. export const CASE_BOARD_CAM_OFFSET = new Vector3(28, 14, 11.2) -// World-units multiplier for the normalized force-layout positions. The -// farthest neighbor ends up at SPREAD units from the focal — tune so the -// network fills the viewport at the resting camera distance. -const CASE_BOARD_SPREAD = 12 +// World-units multiplier for the normalized layout positions — the radius of +// the group ring around the focal. Tune so groups clear the focal card and +// fill the viewport at the resting camera distance. +const CASE_BOARD_SPREAD = 9 + +// Groups with this many members or fewer render as individual cards instead of +// a group container. 1 = don't wrap a lone node in a group; bump higher to also +// un-group small clusters. +const HYBRID_THRESHOLD = 1 +// Approximate on-screen pixels per 1 layout (spread) unit at the resting board +// pose. Cards are sized in px; the layout works in spread units — this converts +// between them so the rectangle collision matches what's actually rendered. +// Lower = cards occupy MORE spread units = more spacing between them. +const BOARD_PX_PER_UNIT = 150 + +// A single-neighbor card or a collapsed group card placed on the board. +type BoardItem = + | { kind: "node"; id: string; type: string; edgeLabel: string; node: ApiNode } + | { kind: "group"; id: string; type: string; edgeLabel: string; members: ApiNode[] } + +// Real card footprint in PIXELS — half-width/half-height — kept in sync with +// the actual CaseCard / CaseGroup CSS dimensions. Estimating height honestly is +// what prevents tall cards (e.g. a Person card with a description) from +// overlapping their neighbors. Circular collision can't capture aspect ratio. +function boardItemBoxPx(item: BoardItem): { w: number; h: number } { + if (item.kind === "node") { + // Neighbor CaseCard: width 240; height = hero(132) + padding + pill + title + // (+ up to 3 field rows ~46px each). Estimate from the fields present. + const props = item.node.properties as Record | undefined + let fieldRows = 0 + if (props) { + for (const k of Object.keys(props)) { + if (fieldRows >= 3) break + const v = props[k] + if ((typeof v === "string" && v.length > 0) || typeof v === "number") fieldRows++ + } + } + const h = 132 + 64 + fieldRows * 50 + return { w: 240, h } + } + // Group CaseGroup: width 256; header ~40 + rows (capped at 7) ~44px each, + // clamped to the LIST_MAX_HEIGHT scroll cap. + const rows = item.members.length + const bodyH = Math.min(rows, 7) * 44 + return { w: 256, h: 40 + Math.min(bodyH, 360) } +} + +function pxToHalfUnits(box: { w: number; h: number }): { hw: number; hh: number } { + return { + hw: box.w / 2 / BOARD_PX_PER_UNIT, + hh: box.h / 2 / BOARD_PX_PER_UNIT, + } +} + +// Focal CaseCard footprint (width 300; hero 170 + body with description + +// up to 4 fields). Generous height so neighbors keep clear of it. +const BOARD_FOCAL_BOX_PX = { w: 300, h: 470 } // Peak opacity of the cream backdrop. Below 1 lets a hint of the 3D scene // bleed through so the board reads as "on top of the world" rather than a @@ -919,26 +1002,24 @@ function CaseBoardMorphLayer({ graph, refIdToIndex, nodes, - edges, selectedRefId, morphProgress, cameraRef, projectionsRef, cardPortalRef, - visibleEdges, - neighborRefIds, + cardElsRef, + items, }: { graph: Graph refIdToIndex: Map nodes: ApiNode[] - edges: ApiEdge[] selectedRefId: string morphProgress: number cameraRef: React.RefObject projectionsRef: React.RefObject cardPortalRef: React.RefObject - visibleEdges: { a: string; b: string; label: string }[] - neighborRefIds: string[] + cardElsRef: React.RefObject> + items: BoardItem[] }) { const selectedIdx = refIdToIndex.get(selectedRefId) const selectedNode = nodes.find((n) => n.ref_id === selectedRefId) ?? null @@ -949,77 +1030,71 @@ function CaseBoardMorphLayer({ return [p.x, p.y, p.z] }, [graph, selectedIdx]) - // Force-directed 2D layout for the case-board. Focal anchored at origin; - // edges between neighbors pull related nodes together, repulsion spreads - // everything out. Seeded by the focal refId so re-opens are stable. - const layout2d = useMemo(() => { - if (!focalWorld) return new Map() - return computeCaseBoardLayout({ - nodes: [selectedRefId, ...neighborRefIds], - edges: visibleEdges.map((e) => ({ a: e.a, b: e.b })), - anchorId: selectedRefId, - seed: selectedRefId, - }) - }, [focalWorld, selectedRefId, neighborRefIds, visibleEdges]) - - // Map normalized 2D layout into world-space targets on the plane - // perpendicular to the case-board camera direction. Multiplied by - // CASE_BOARD_SPREAD so the laid-out network fills the viewport at the - // resting camera distance. - const neighborTargets = useMemo(() => { - if (!focalWorld) { - return [] as { - node: ApiNode - origin: [number, number, number] - target: [number, number, number] - }[] + // World-space anchor for each GROUP: focal at center, groups on a ring + // around it (radial hub & spokes). Same camera-facing plane mapping as the + // focal — right/up basis derived from the case-board camera offset. + const itemTargets = useMemo(() => { + type Entry = { + item: BoardItem + origin: [number, number, number] + target: [number, number, number] } + if (!focalWorld) return [] as Entry[] const focal = new Vector3(focalWorld[0], focalWorld[1], focalWorld[2]) const camPos = focal.clone().add(CASE_BOARD_CAM_OFFSET) const forward = focal.clone().sub(camPos).normalize() const worldUp = new Vector3(0, 1, 0) const right = new Vector3().crossVectors(worldUp, forward).normalize() const up = new Vector3().crossVectors(forward, right).normalize() - const entries: { - node: ApiNode - origin: [number, number, number] - target: [number, number, number] - }[] = [] - for (const refId of neighborRefIds) { - const idx = refIdToIndex.get(refId) - if (idx === undefined) continue - const apiNode = nodes.find((n) => n.ref_id === refId) - if (!apiNode) continue - const p = graph.nodes[idx]?.position - if (!p) continue - const layoutPos = layout2d.get(refId) ?? { x: 0, y: 0 } + const placement = computeBalancedLayout({ + items: items.map((it) => ({ id: it.id, ...pxToHalfUnits(boardItemBoxPx(it)) })), + focalHalf: pxToHalfUnits(BOARD_FOCAL_BOX_PX), + seed: selectedRefId, + }) + const entries: Entry[] = [] + for (const item of items) { + const pos = placement.get(item.id) ?? { x: 0, y: 0 } const offset = right .clone() - .multiplyScalar(layoutPos.x * CASE_BOARD_SPREAD) - .add(up.clone().multiplyScalar(layoutPos.y * CASE_BOARD_SPREAD)) + .multiplyScalar(pos.x * CASE_BOARD_SPREAD) + .add(up.clone().multiplyScalar(pos.y * CASE_BOARD_SPREAD)) const target = focal.clone().add(offset) entries.push({ - node: apiNode, - origin: [p.x, p.y, p.z], + item, + origin: focalWorld, target: [target.x, target.y, target.z], }) } return entries - }, [focalWorld, neighborRefIds, refIdToIndex, nodes, graph, layout2d]) + }, [focalWorld, items, selectedRefId]) - // All projection inputs in one list — focal first, then each neighbor. - // The SVG looks up positions by refId when drawing per-edge connectors, - // so we need both endpoints in the map. + // Projection inputs — focal + each group anchor (id = group key) so the + // connectors SVG can draw focal → group beziers each frame. const projectionInput = useMemo(() => { if (!focalWorld) return [] const list: { id: string; origin: [number, number, number]; target: [number, number, number] }[] = [ { id: selectedRefId, origin: focalWorld, target: focalWorld }, ] - for (const e of neighborTargets) { - list.push({ id: e.node.ref_id, origin: e.origin, target: e.target }) + for (const e of itemTargets) { + list.push({ id: e.item.id, origin: e.origin, target: e.target }) } return list - }, [focalWorld, selectedRefId, neighborTargets]) + }, [focalWorld, selectedRefId, itemTargets]) + + // Which groups are expanded (showing the full list vs the compressed pile). + // Local to the open session — resets on close since the layer unmounts. + // Groups default to expanded (member list shown); the header toggle collapses + // to the header only. Tracking the collapsed set keeps "expanded" the default + // without seeding state from the (changing) group list. + const [collapsedKeys, setCollapsedKeys] = useState>(() => new Set()) + const toggleGroup = useCallback((key: string) => { + setCollapsedKeys((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) return ( <> @@ -1037,20 +1112,51 @@ function CaseBoardMorphLayer({ variant="selected" morphProgress={morphProgress} portal={cardPortalRef} + registerEl={(el) => { + const m = cardElsRef.current + if (el) m.set(selectedRefId, el) + else m.delete(selectedRefId) + }} /> )} - {neighborTargets.map(({ node, origin, target }) => ( - useCaseBoardStore.getState().open(node.ref_id)} - portal={cardPortalRef} - /> - ))} + {itemTargets.map(({ item, origin, target }) => + item.kind === "node" ? ( + useCaseBoardStore.getState().open(item.id)} + portal={cardPortalRef} + registerEl={(el) => { + const m = cardElsRef.current + if (el) m.set(item.id, el) + else m.delete(item.id) + }} + /> + ) : ( + toggleGroup(item.id)} + onMemberClick={(refId) => useCaseBoardStore.getState().open(refId)} + originPosition={origin} + targetPosition={target} + morphProgress={morphProgress} + portal={cardPortalRef} + registerEl={(el) => { + const m = cardElsRef.current + if (el) m.set(item.id, el) + else m.delete(item.id) + }} + /> + ), + )} ) } @@ -1066,11 +1172,11 @@ const CONNECTOR_LABEL_BG = "#0a0e15" const CONNECTOR_LABEL_TEXT = "rgba(180, 210, 240, 0.85)" function CaseBoardConnectorsSvg({ - projectionsRef, + cardElsRef, edges, morphProgress, }: { - projectionsRef: React.RefObject + cardElsRef: React.RefObject> // One per visible-pair edge. id is a stable key (e.g. `${a}|${b}|${label}`) // so React can keep DOM stable across renders. a/b are refIds. edges: { id: string; a: string; b: string; label: string }[] @@ -1088,46 +1194,84 @@ function CaseBoardConnectorsSvg({ useEffect(() => { let raf = 0 function tick() { - const positions = projectionsRef.current?.positions - if (positions) { + const els = cardElsRef.current + if (els) { for (const e of edges) { - const pa = positions.get(e.a) - const pb = positions.get(e.b) - if (!pa || !pb) continue + const ea = els.get(e.a) + const eb = els.get(e.b) + if (!ea || !eb) continue + // Measure the real on-screen card rectangles (viewport px). This is + // the only space that matches what's rendered — drei's distanceFactor + // and the board's CSS zoom both feed into getBoundingClientRect. + const ra = ea.getBoundingClientRect() + const rb = eb.getBoundingClientRect() + const pa = { x: ra.left + ra.width / 2, y: ra.top + ra.height / 2 } + const pb = { x: rb.left + rb.width / 2, y: rb.top + rb.height / 2 } + const halfA = { x: ra.width / 2, y: ra.height / 2 } + const halfB = { x: rb.width / 2, y: rb.height / 2 } const dx = pb.x - pa.x const dy = pb.y - pa.y - const len = Math.sqrt(dx * dx + dy * dy) || 1 - // Subtle perpendicular bow so connectors that share endpoints - // don't overlap. Sign by edge id hash so adjacent edges bow - // opposite directions. - let hash = 0 - for (let i = 0; i < e.id.length; i++) hash = (hash * 31 + e.id.charCodeAt(i)) | 0 - const sign = hash & 1 ? 1 : -1 - const bow = Math.max(4, Math.min(22, len * 0.06)) * sign - const mx = (pa.x + pb.x) / 2 - (dy / len) * bow - const my = (pa.y + pb.y) / 2 + (dx / len) * bow + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const nx = dx / dist + const ny = dy / dist + // Clip each endpoint to its card rectangle so the line spans only + // the gap between cards instead of tunnelling under them. A is the + // focal card (big), B a group container (smaller) — every edge here + // runs focal → group, so the roles are fixed. + const clip = (hx: number, hy: number) => + Math.min( + hx / Math.max(Math.abs(nx), 1e-3), + hy / Math.max(Math.abs(ny), 1e-3), + ) + let insetA = clip(halfA.x, halfA.y) + let insetB = clip(halfB.x, halfB.y) + const room = dist - 10 + if (room <= 0) { + insetA = 0 + insetB = 0 + } else if (insetA + insetB > room) { + const s = room / (insetA + insetB) + insetA *= s + insetB *= s + } + const ax = pa.x + nx * insetA + const ay = pa.y + ny * insetA + const bx = pb.x - nx * insetB + const by = pb.y - ny * insetB + + // Smooth cubic bezier with horizontal tangents — control points + // extend sideways from each endpoint toward the other, so the line + // leaves the focal and enters the card cleanly (React-Flow style). + const cdx = bx - ax + const dirX = cdx >= 0 ? 1 : -1 + const ctrl = Math.max(40, Math.abs(cdx) * 0.5) + const c1x = ax + ctrl * dirX + const c1y = ay + const c2x = bx - ctrl * dirX + const c2y = by const path = pathRefs.current.get(e.id) if (path) { path.setAttribute( "d", - `M ${pa.x.toFixed(1)} ${pa.y.toFixed(1)} Q ${mx.toFixed(1)} ${my.toFixed(1)} ${pb.x.toFixed(1)} ${pb.y.toFixed(1)}`, + `M ${ax.toFixed(1)} ${ay.toFixed(1)} C ${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`, ) } const da = dotARefs.current.get(e.id) if (da) { - da.setAttribute("cx", pa.x.toFixed(1)) - da.setAttribute("cy", pa.y.toFixed(1)) + da.setAttribute("cx", ax.toFixed(1)) + da.setAttribute("cy", ay.toFixed(1)) } const db = dotBRefs.current.get(e.id) if (db) { - db.setAttribute("cx", pb.x.toFixed(1)) - db.setAttribute("cy", pb.y.toFixed(1)) + db.setAttribute("cx", bx.toFixed(1)) + db.setAttribute("cy", by.toFixed(1)) } const labelG = labelGRefs.current.get(e.id) if (labelG) { - const lx = (pa.x + 2 * mx + pb.x) / 4 - const ly = (pa.y + 2 * my + pb.y) / 4 + // Cubic midpoint (t = 0.5): (A + 3·C1 + 3·C2 + B) / 8. + const lx = (ax + 3 * c1x + 3 * c2x + bx) / 8 + const ly = (ay + 3 * c1y + 3 * c2y + by) / 8 labelG.setAttribute("transform", `translate(${lx.toFixed(1)}, ${ly.toFixed(1)})`) } } @@ -1136,12 +1280,12 @@ function CaseBoardConnectorsSvg({ } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) - }, [projectionsRef, edges]) + }, [cardElsRef, edges]) return ( ({ positions: new Map() }) + // Maps focal refId + each group key to its rendered card DOM element, so the + // connector overlay can measure real card rectangles and attach edges to the + // actual borders (works at any zoom — getBoundingClientRect includes it). + const cardElsRef = useRef>(new Map()) // Board pan + zoom — applied as a CSS transform on the layer that hosts // the Html cards + SVG connectors. Lives entirely in DOM so the 3D // camera stays locked and the underlying scene doesn't move at all. const boardLayerRef = useRef(null) - const [boardPan, setBoardPan] = useState({ x: 0, y: 0 }) - const [boardZoom, setBoardZoom] = useState(1) - // Mirror pan/zoom in refs so the wheel handler (which can fire faster than - // React commits, especially on trackpads) always reads the latest values - // instead of stale closure state. const boardPanRef = useRef({ x: 0, y: 0 }) const boardZoomRef = useRef(1) + // Apply pan/zoom imperatively to the board layer's transform — NOT via React + // state. Driving it through setState re-rendered the entire GraphCanvas tree + // (heavy, especially with the metro overlay's extra nodes) on every drag-move + // / wheel tick — that was the lag, the dead drag, and the stale-until-resize + // layout. The connector overlay already tracks via rAF + measured rects, so + // the DOM transform is fine as the single source of truth. + const applyBoardTransform = useCallback(() => { + const el = boardLayerRef.current + if (!el) return + const p = boardPanRef.current + el.style.transform = `translate(${p.x}px, ${p.y}px) scale(${boardZoomRef.current})` + }, []) const setBoard = useCallback( (pan: { x: number; y: number }, zoom: number) => { boardPanRef.current = pan boardZoomRef.current = zoom - setBoardPan(pan) - setBoardZoom(zoom) + applyBoardTransform() }, - [], + [applyBoardTransform], ) const dragStateRef = useRef<{ startX: number @@ -1371,10 +1525,9 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // open always starts centered. Without this, the board would remember // the pan/zoom from the last session. useEffect(() => { - if (!morphOpen) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on external (store) close path; this is the boundary between morphOpen subscription and local board state - setBoard({ x: 0, y: 0 }, 1) - } + // Reset to identity whenever the board opens or closes so each open starts + // centered at scale 1. Pure imperative now — no setState, no re-render. + setBoard({ x: 0, y: 0 }, 1) }, [morphOpen, setBoard]) const handleBoardMouseDown = useCallback( @@ -1421,6 +1574,20 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const handleBoardWheel = useCallback( (e: React.WheelEvent) => { + // If the wheel is over a scrollable group list, let it scroll its rows + // natively instead of zooming the board. Checked per-event from the + // target so there's no persistent flag that can get stuck and + // permanently disable zoom. + let scrollEl: HTMLElement | null = e.target as HTMLElement | null + while (scrollEl && scrollEl !== e.currentTarget) { + if ( + scrollEl.scrollHeight > scrollEl.clientHeight + 1 && + getComputedStyle(scrollEl).overflowY !== "visible" + ) { + return + } + scrollEl = scrollEl.parentElement + } // Miro-style zoom: the step scales with the actual wheel delta (so a // trackpad's many small events stay gentle and a mouse notch is one // smooth bump), and the zoom anchors on the cursor instead of the @@ -1492,6 +1659,64 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas } return out }, [morphSelectedRefId, morphNeighborIds, effectiveEdges]) + + // Group the focal's 1-hop neighbors by node_type into case-board groups. + // The dominant relationship (edge_type) to the focal becomes the group's + // connector label + header subtitle. + const boardItems = useMemo(() => { + if (!morphSelectedRefId) return [] + const relFor = new Map() + for (const e of morphVisibleEdges) { + let nb: string | null = null + if (e.a === morphSelectedRefId) nb = e.b + else if (e.b === morphSelectedRefId) nb = e.a + if (nb && !relFor.has(nb)) relFor.set(nb, e.label ?? "") + } + // Group by (node_type + relationship) so members of a group genuinely share + // the same edge to the focal. Grouping by type alone mislabels members — a + // spouse and a child both end up under whichever relationship is dominant. + const byKey = new Map() + const order: string[] = [] + for (const refId of morphNeighborIds) { + const node = effectiveNodes.find((n) => n.ref_id === refId) + if (!node) continue + const type = node.node_type || "Node" + const rel = relFor.get(refId) ?? "" + const key = `${type}|${rel}` + let g = byKey.get(key) + if (!g) { + g = { type, rel, members: [] } + byKey.set(key, g) + order.push(key) + } + g.members.push(node) + } + // Hybrid: sparse groups → individual cards; dense ones → one group card. + const items: BoardItem[] = [] + for (const key of order) { + const g = byKey.get(key)! + if (g.members.length <= HYBRID_THRESHOLD) { + for (const node of g.members) { + items.push({ kind: "node", id: node.ref_id, type: g.type, edgeLabel: g.rel, node }) + } + } else { + items.push({ kind: "group", id: `grp:${key}`, type: g.type, edgeLabel: g.rel, members: g.members }) + } + } + return items + }, [morphSelectedRefId, morphNeighborIds, morphVisibleEdges, effectiveNodes]) + + // One connector per board item: focal → item. + const boardConnectorEdges = useMemo( + () => + boardItems.map((it) => ({ + id: it.id, + a: morphSelectedRefId ?? "", + b: it.id, + label: it.edgeLabel || "linked to", + })), + [boardItems, morphSelectedRefId], + ) const [hoveredCardNode, setHoveredCardNode] = useState(null) const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) // Metro overlay focus state — driven by hovering the lines (3D) or the @@ -1584,7 +1809,14 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // Closes the in-3D case board: drops morph state and pulls the camera back // to the selected node's rest distance so the trigger re-arms and the user // has room to maneuver before the next dolly-in. + // Disarm the dolly-in auto-trigger across a manual close. computeCamTarget + // below parks the camera at the node's rest distance, which for leaf nodes + // is inside CASE_VIEW_TRIGGER_DISTANCE — without this guard the trigger + // would see "camera is close" and reopen the board immediately. Cleared in + // CaseViewTrigger once the camera pulls back out past the re-arm threshold. + const caseTriggerSuppressedRef = useRef(false) const handleCloseCaseBoard = useCallback(() => { + caseTriggerSuppressedRef.current = true useCaseBoardStore.getState().close() if (viewState.mode === "subgraph") { setCamTarget( @@ -1982,20 +2214,20 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas onOpen={handleOpenCaseView} disabled={morphOpen} camAnim={camAnim} + suppressedRef={caseTriggerSuppressedRef} /> {morphOpen && morphSelectedRefId && ( )} - {morphOpen && ( - - )}
+ {morphOpen && ( + + )} {morphOpen && ( -
- zoom in -
+
{ + e.stopPropagation() + start() + }} + onPointerUp={(e) => { + e.stopPropagation() + stop() + }} + onPointerLeave={stop} + onPointerCancel={stop} + title="Hold to open case view" + style={{ + width: SIZE, + height: SIZE, + borderRadius: "50%", + pointerEvents: "auto", + cursor: "pointer", + touchAction: "none", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {/* rotate -90° so the ring fills from the top, clockwise */} + + {/* Faint idle ring — marks the node as "hold to open". */} + + {/* Progress arc — fills as the user holds. */} + +
) @@ -880,20 +434,24 @@ function DebugMarkers({ // camera) and by CaseBoardMorphLayer (to compute the plane the neighbor ring // sits in). Keep them in sync so cards face the camera at full morph. export const CASE_BOARD_CAM_OFFSET = new Vector3(28, 14, 11.2) -// World-units multiplier for the normalized layout positions — the radius of -// the group ring around the focal. Tune so groups clear the focal card and -// fill the viewport at the resting camera distance. -const CASE_BOARD_SPREAD = 9 +// Board camera fov (matches the camera) and its resting distance from +// the focal (the length of the offset above). Together with the viewport +// height these give the world-units-per-screen-pixel scale at the board pose, +// so the px-space card layout can be converted to world positions that match +// exactly what's rendered — see CaseBoardMorphLayer. +const BOARD_FOV = 50 +const BOARD_CAM_DISTANCE = CASE_BOARD_CAM_OFFSET.length() +// Breathing room between cards, in SCREEN PIXELS (the space the layout works +// in) — applied uniformly on every side now that the packer uses measured card +// sizes. Sized so the relationship pill can sit near the SOURCE end of the edge +// (≈48–72px wide) and still leave a visible dashed line running on to the +// target, instead of the pill covering the whole edge. Single density knob. +const BOARD_GAP_PX = 130 // Groups with this many members or fewer render as individual cards instead of // a group container. 1 = don't wrap a lone node in a group; bump higher to also // un-group small clusters. const HYBRID_THRESHOLD = 1 -// Approximate on-screen pixels per 1 layout (spread) unit at the resting board -// pose. Cards are sized in px; the layout works in spread units — this converts -// between them so the rectangle collision matches what's actually rendered. -// Lower = cards occupy MORE spread units = more spacing between them. -const BOARD_PX_PER_UNIT = 150 // A single-neighbor card or a collapsed group card placed on the board. type BoardItem = @@ -927,13 +485,6 @@ function boardItemBoxPx(item: BoardItem): { w: number; h: number } { return { w: 256, h: 40 + Math.min(bodyH, 360) } } -function pxToHalfUnits(box: { w: number; h: number }): { hw: number; hh: number } { - return { - hw: box.w / 2 / BOARD_PX_PER_UNIT, - hh: box.h / 2 / BOARD_PX_PER_UNIT, - } -} - // Focal CaseCard footprint (width 300; hero 170 + body with description + // up to 4 fields). Generous height so neighbors keep clear of it. const BOARD_FOCAL_BOX_PX = { w: 300, h: 470 } @@ -948,9 +499,10 @@ export const CASE_BOARD_BACKDROP_OPACITY = 0.92 // occlude or sit above GraphView's labels needs values past 16.77M. export const CASE_BOARD_Z = { backdrop: 16777300, // cream paper above 3D labels - connectors: 16777350, // SVG between cream + cards + connectors: 16777350, // SVG lines + dots: between cream + cards (tunnel under) cardFar: 16777400, cardNear: 16777500, + connectorLabels: 16777700, // edge pills: ABOVE cards so they never clip button: 16778000, } @@ -1028,6 +580,8 @@ function CaseBoardMorphLayer({ }) { const selectedIdx = refIdToIndex.get(selectedRefId) const selectedNode = nodes.find((n) => n.ref_id === selectedRefId) ?? null + // Viewport height drives the px→world conversion for the card layout below. + const viewportHeight = useThree((s) => s.size.height) const focalWorld = useMemo<[number, number, number] | null>(() => { if (selectedIdx === undefined) return null const p = graph.nodes[selectedIdx]?.position @@ -1035,6 +589,71 @@ function CaseBoardMorphLayer({ return [p.x, p.y, p.z] }, [graph, selectedIdx]) + // Real on-screen card sizes (CSS px). offsetWidth/Height are LAYOUT sizes, so + // they ignore the board layer's scale transform — exactly the px footprint the + // packer needs. The layout below uses these instead of the boardItemBoxPx + // estimates, which is what makes the edge-to-edge gap uniform on every side + // (estimates mis-guessed card height, so top/bottom got more room than + // left/right). A ResizeObserver re-measures when content changes (e.g. a card + // switching LOD tier), so the layout re-packs to stay even. + const [cardSizes, setCardSizes] = useState>( + () => new Map(), + ) + const sizeObserverRef = useRef(null) + useEffect(() => { + const ro = new ResizeObserver((entries) => { + setCardSizes((prev) => { + let next = prev + for (const e of entries) { + const el = e.target as HTMLElement + const id = el.dataset.cardId + if (!id) continue + const w = el.offsetWidth + const h = el.offsetHeight + if (!w && !h) continue + const cur = prev.get(id) + if (!cur || cur.w !== w || cur.h !== h) { + if (next === prev) next = new Map(prev) + next.set(id, { w, h }) + } + } + return next + }) + }) + sizeObserverRef.current = ro + return () => ro.disconnect() + }, []) + // Registers a card's DOM root: tracks it for the connector overlay, observes + // it for size changes, and seeds an immediate measurement so the first layout + // pass isn't stuck on the estimate. + const registerCard = useCallback( + (id: string, el: HTMLElement | null) => { + const m = cardElsRef.current + const ro = sizeObserverRef.current + if (el) { + el.dataset.cardId = id + m.set(id, el) + ro?.observe(el) + const w = el.offsetWidth + const h = el.offsetHeight + if (w || h) { + setCardSizes((prev) => { + const cur = prev.get(id) + if (cur && cur.w === w && cur.h === h) return prev + const next = new Map(prev) + next.set(id, { w, h }) + return next + }) + } + } else { + const old = m.get(id) + if (old) ro?.unobserve(old) + m.delete(id) + } + }, + [cardElsRef], + ) + // World-space anchor for each GROUP: focal at center, groups on a ring // around it (radial hub & spokes). Same camera-facing plane mapping as the // focal — right/up basis derived from the case-board camera offset. @@ -1051,18 +670,38 @@ function CaseBoardMorphLayer({ const worldUp = new Vector3(0, 1, 0) const right = new Vector3().crossVectors(worldUp, forward).normalize() const up = new Vector3().crossVectors(forward, right).normalize() + // Pack the cards in SCREEN PIXELS — they render at a fixed CSS px size + // (NodeMorph has no distanceFactor), so collision in px space matches what + // the user actually sees. Spacing is then a fixed px gap regardless of how + // many cards there are; the AABB solver only pushes the cluster wider when + // cards genuinely can't fit, which is the "tight when few, spread when + // many" behaviour we want. + // Prefer the measured size; fall back to the estimate only until the card + // has rendered once (first frame on open). + const halfOf = (id: string, est: { w: number; h: number }) => { + const s = cardSizes.get(id) ?? est + return { hw: s.w / 2, hh: s.h / 2 } + } const placement = computeBalancedLayout({ - items: items.map((it) => ({ id: it.id, ...pxToHalfUnits(boardItemBoxPx(it)) })), - focalHalf: pxToHalfUnits(BOARD_FOCAL_BOX_PX), + items: items.map((it) => ({ id: it.id, ...halfOf(it.id, boardItemBoxPx(it)) })), + focalHalf: halfOf(selectedRefId, BOARD_FOCAL_BOX_PX), seed: selectedRefId, + gap: BOARD_GAP_PX, }) + // World units per on-screen pixel at the (fixed) board pose. The perspective + // camera shows 2·d·tan(fov/2) world units of height across the viewport, so + // dividing by the pixel height gives the scale that maps the px layout to + // world offsets matching the rendered card sizes — at any viewport size. + const fovRad = (BOARD_FOV / 2) * (Math.PI / 180) + const worldPerPx = + (2 * BOARD_CAM_DISTANCE * Math.tan(fovRad)) / Math.max(1, viewportHeight) const entries: Entry[] = [] for (const item of items) { const pos = placement.get(item.id) ?? { x: 0, y: 0 } const offset = right .clone() - .multiplyScalar(pos.x * CASE_BOARD_SPREAD) - .add(up.clone().multiplyScalar(pos.y * CASE_BOARD_SPREAD)) + .multiplyScalar(pos.x * worldPerPx) + .add(up.clone().multiplyScalar(pos.y * worldPerPx)) const target = focal.clone().add(offset) entries.push({ item, @@ -1071,7 +710,7 @@ function CaseBoardMorphLayer({ }) } return entries - }, [focalWorld, items, selectedRefId]) + }, [focalWorld, items, selectedRefId, viewportHeight, cardSizes]) // Projection inputs — focal + each group anchor (id = group key) so the // connectors SVG can draw focal → group beziers each frame. @@ -1111,23 +750,21 @@ function CaseBoardMorphLayer({ /> {selectedNode && focalWorld && ( { - const m = cardElsRef.current - if (el) m.set(selectedRefId, el) - else m.delete(selectedRefId) - }} + registerEl={(el) => registerCard(selectedRefId, el)} /> )} {itemTargets.map(({ item, origin, target }) => item.kind === "node" ? ( useCaseBoardStore.getState().open(item.id)} portal={cardPortalRef} - registerEl={(el) => { - const m = cardElsRef.current - if (el) m.set(item.id, el) - else m.delete(item.id) - }} + registerEl={(el) => registerCard(item.id, el)} /> ) : ( toggleGroup(item.id)} onMemberClick={(refId) => useCaseBoardStore.getState().open(refId)} @@ -1154,11 +787,7 @@ function CaseBoardMorphLayer({ targetPosition={target} morphProgress={morphProgress} portal={cardPortalRef} - registerEl={(el) => { - const m = cardElsRef.current - if (el) m.set(item.id, el) - else m.delete(item.id) - }} + registerEl={(el) => registerCard(item.id, el)} /> ), )} @@ -1177,83 +806,79 @@ const CONNECTOR_LABEL_BG = "#0a0e15" const CONNECTOR_LABEL_TEXT = "rgba(180, 210, 240, 0.85)" function CaseBoardConnectorsSvg({ + projectionsRef, cardElsRef, edges, morphProgress, }: { + // Projected (board-layer-local) screen positions of every card centre, + // written each frame by the in-Canvas ProjectionEmitter. These are the SAME + // coordinates drei uses to position the cards, BEFORE the board layer's CSS + // transform — so drawing here and letting that transform scale the SVG keeps + // edges glued to the cards at any zoom (React-Flow model). + projectionsRef: React.RefObject + // Card DOM roots — used only for their natural (un-transformed) size via + // offsetWidth/Height, to clip endpoints to the card borders. cardElsRef: React.RefObject> - // One per visible-pair edge. id is a stable key (e.g. `${a}|${b}|${label}`) - // so React can keep DOM stable across renders. a/b are refIds. + // One per visible-pair edge. a/b are refIds (a = focal/source). edges: { id: string; a: string; b: string; label: string }[] morphProgress: number }) { - // One ref per dynamic element per edge id: path, two endpoint circles, - // label group, label rect. const pathRefs = useRef>(new Map()) const dotARefs = useRef>(new Map()) const dotBRefs = useRef>(new Map()) const labelGRefs = useRef>(new Map()) - const labelRectRefs = useRef>(new Map()) - const labelTextRefs = useRef>(new Map()) useEffect(() => { let raf = 0 function tick() { + const proj = projectionsRef.current?.positions const els = cardElsRef.current - if (els) { + if (proj && els) { for (const e of edges) { + const ca = proj.get(e.a) + const cb = proj.get(e.b) const ea = els.get(e.a) const eb = els.get(e.b) - if (!ea || !eb) continue - // Measure the real on-screen card rectangles (viewport px). This is - // the only space that matches what's rendered — drei's distanceFactor - // and the board's CSS zoom both feed into getBoundingClientRect. - const ra = ea.getBoundingClientRect() - const rb = eb.getBoundingClientRect() - const pa = { x: ra.left + ra.width / 2, y: ra.top + ra.height / 2 } - const pb = { x: rb.left + rb.width / 2, y: rb.top + rb.height / 2 } - const halfA = { x: ra.width / 2, y: ra.height / 2 } - const halfB = { x: rb.width / 2, y: rb.height / 2 } - const dx = pb.x - pa.x - const dy = pb.y - pa.y + if (!ca || !cb || !ea || !eb) continue + // Natural card half-sizes (offsetWidth/Height ignore the CSS scale, + // matching the un-transformed space these coords live in). + const hax = ea.offsetWidth / 2 + const hay = ea.offsetHeight / 2 + const hbx = eb.offsetWidth / 2 + const hby = eb.offsetHeight / 2 + const dx = cb.x - ca.x + const dy = cb.y - ca.y const dist = Math.sqrt(dx * dx + dy * dy) || 1 const nx = dx / dist const ny = dy / dist - // Clip each endpoint to its card rectangle so the line spans only - // the gap between cards instead of tunnelling under them. A is the - // focal card (big), B a group container (smaller) — every edge here - // runs focal → group, so the roles are fixed. - const clip = (hx: number, hy: number) => - Math.min( - hx / Math.max(Math.abs(nx), 1e-3), - hy / Math.max(Math.abs(ny), 1e-3), - ) - let insetA = clip(halfA.x, halfA.y) - let insetB = clip(halfB.x, halfB.y) - const room = dist - 10 - if (room <= 0) { - insetA = 0 - insetB = 0 - } else if (insetA + insetB > room) { - const s = room / (insetA + insetB) - insetA *= s - insetB *= s + + // Where the centre→centre ray exits each card rect, and which face it + // crossed. Picking the face keeps the line on the edge that faces the + // other card, never on a corner. + const boundary = ( + px: number, py: number, hx: number, hy: number, ux: number, uy: number, + ) => { + const tx = hx / Math.max(Math.abs(ux), 1e-3) + const ty = hy / Math.max(Math.abs(uy), 1e-3) + const t = Math.min(tx, ty) + return { x: px + ux * t, y: py + uy * t, faceX: tx <= ty } } - const ax = pa.x + nx * insetA - const ay = pa.y + ny * insetA - const bx = pb.x - nx * insetB - const by = pb.y - ny * insetB - - // Smooth cubic bezier with horizontal tangents — control points - // extend sideways from each endpoint toward the other, so the line - // leaves the focal and enters the card cleanly (React-Flow style). - const cdx = bx - ax - const dirX = cdx >= 0 ? 1 : -1 - const ctrl = Math.max(40, Math.abs(cdx) * 0.5) - const c1x = ax + ctrl * dirX - const c1y = ay - const c2x = bx - ctrl * dirX - const c2y = by + const A = boundary(ca.x, ca.y, hax, hay, nx, ny) + const B = boundary(cb.x, cb.y, hbx, hby, -nx, -ny) + const ax = A.x, ay = A.y + const bx = B.x, by = B.y + + // Leave/enter each card PERPENDICULAR to its face (smoothstep edge). + const ctrl = Math.max(20, Math.min(90, dist * 0.4)) + const nAx = A.faceX ? Math.sign(ax - ca.x) || 1 : 0 + const nAy = A.faceX ? 0 : Math.sign(ay - ca.y) || 1 + const nBx = B.faceX ? Math.sign(bx - cb.x) || 1 : 0 + const nBy = B.faceX ? 0 : Math.sign(by - cb.y) || 1 + const c1x = ax + nAx * ctrl + const c1y = ay + nAy * ctrl + const c2x = bx + nBx * ctrl + const c2y = by + nBy * ctrl const path = pathRefs.current.get(e.id) if (path) { @@ -1274,9 +899,15 @@ function CaseBoardConnectorsSvg({ } const labelG = labelGRefs.current.get(e.id) if (labelG) { - // Cubic midpoint (t = 0.5): (A + 3·C1 + 3·C2 + B) / 8. - const lx = (ax + 3 * c1x + 3 * c2x + bx) / 8 - const ly = (ay + 3 * c1y + 3 * c2y + by) / 8 + // Place the pill just past the SOURCE card, ALONG THE FACE NORMAL + // (= the curve's tangent where it leaves the card), not along the + // straight source→target line. The edge leaves perpendicular and + // then curves, so offsetting along the straight line drifts the + // label off the visible curve (top/bottom edges floated sideways). + const pillW = Math.max(48, (e.label || "linked to").length * 7 + 18) + const along = pillW / 2 + 10 + const lx = ax + nAx * along + const ly = ay + nAy * along labelG.setAttribute("transform", `translate(${lx.toFixed(1)}, ${ly.toFixed(1)})`) } } @@ -1285,25 +916,26 @@ function CaseBoardConnectorsSvg({ } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) - }, [cardElsRef, edges]) + }, [projectionsRef, cardElsRef, edges]) + // One SVG filling the (transformed) connector layer. No per-element scaling — + // the layer's CSS transform scales strokes, dots, and the pill as crisp + // vectors, exactly like the cards. return ( {edges.map((e) => { - // Width of the label pill grows with text length so long edge - // types ("ANTAGONIST_OF") don't truncate. - const label = (e.label || "linked to").toLowerCase() - const pillW = Math.max(48, label.length * 6 + 16) + const label = (e.label || "linked to").toUpperCase() + const pillW = Math.max(48, label.length * 7 + 18) return ( { - if (el) labelRectRefs.current.set(e.id, el) - else labelRectRefs.current.delete(e.id) - }} x={-pillW / 2} y={-9} width={pillW} @@ -1358,18 +986,15 @@ function CaseBoardConnectorsSvg({ strokeWidth={0.75} /> { - if (el) labelTextRefs.current.set(e.id, el) - else labelTextRefs.current.delete(e.id) - }} x={0} y={1} textAnchor="middle" dominantBaseline="central" fill={CONNECTOR_LABEL_TEXT} fontSize={9} + fontWeight={600} fontFamily='"Space Grotesk", system-ui, sans-serif' - letterSpacing={0.5} + letterSpacing={1} > {label} @@ -1386,6 +1011,11 @@ function CaseBoardConnectorsSvg({ // dark backdrop. const CARD_BG_FOR_DOT = "#0a0e15" +// Board zoom limits. Past ~0.5 the cards are tiny; zooming out further just +// scatters them into empty space with no added value, so we stop there. +const BOARD_MIN_ZOOM = 0.5 +const BOARD_MAX_ZOOM = 4 + interface GraphCanvasProps { nodes: ApiNode[] edges: ApiEdge[] @@ -1458,6 +1088,37 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas return result }, [effectiveNodes, effectiveEdges, schemas]) + // ref_id → API node lookups. Built once per data change so the per-click / + // per-hover / per-board-item paths don't each rescan the node array. + // `nodes` and `effectiveNodes` are kept separate on purpose: callers that + // previously scanned `nodes` must not start resolving the metro fixture + // stations that only live in `effectiveNodes`. + const nodeByRefId = useMemo(() => { + const m = new Map() + for (const n of nodes) m.set(n.ref_id, n) + return m + }, [nodes]) + const effectiveNodeByRefId = useMemo(() => { + const m = new Map() + for (const n of effectiveNodes) m.set(n.ref_id, n) + return m + }, [effectiveNodes]) + + // Metro stations are drawn by the dedicated schematic overlay (colored lines + // + bullets), so their 3D graph glyph + label rest muted to avoid doubling + // up and cluttering the overview. They stay interactive — hover/select + // restores the label and highlight. Only active in the metro view. + const mutedNodeIds = useMemo(() => { + if (!metroEnabled) return null + const set = new Set() + for (const n of effectiveNodes) { + if (n.node_type !== "Station") continue + const idx = refIdToIndex.get(n.ref_id) + if (idx !== undefined) set.add(idx) + } + return set.size > 0 ? set : null + }, [effectiveNodes, refIdToIndex, metroEnabled]) + // Lowercase type → schema icon name (e.g. "EpisodeIcon"). The pill in // GraphView resolves this through schema-icons to a Lucide component. const nodeTypeIcons = useMemo(() => { @@ -1495,6 +1156,16 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // the Html cards + SVG connectors. Lives entirely in DOM so the 3D // camera stays locked and the underlying scene doesn't move at all. const boardLayerRef = useRef(null) + // Connector layer — a sibling of the board layer that gets the SAME pan/zoom + // transform, so the edge SVG (drawn in the same projected coordinates the + // cards use) scales as one with the cards. This is the React-Flow / Miro + // model: nodes + edges + labels in one transformed space, so everything + // stays aligned and crisp at any zoom with no per-element scaling hacks. + const connectorLayerRef = useRef(null) + // Stable, UNTRANSFORMED container — used to anchor cursor-relative zoom. The + // board layer itself has the pan/zoom transform applied, so its own + // getBoundingClientRect is post-transform and can't be used as the reference. + const containerRef = useRef(null) const boardPanRef = useRef({ x: 0, y: 0 }) const boardZoomRef = useRef(1) // Apply pan/zoom imperatively to the board layer's transform — NOT via React @@ -1504,10 +1175,12 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // layout. The connector overlay already tracks via rAF + measured rects, so // the DOM transform is fine as the single source of truth. const applyBoardTransform = useCallback(() => { - const el = boardLayerRef.current - if (!el) return const p = boardPanRef.current - el.style.transform = `translate(${p.x}px, ${p.y}px) scale(${boardZoomRef.current})` + const z = boardZoomRef.current + const t = `translate(${p.x}px, ${p.y}px) scale(${z})` + if (boardLayerRef.current) boardLayerRef.current.style.transform = t + // Same transform on the connector layer so edges track the cards exactly. + if (connectorLayerRef.current) connectorLayerRef.current.style.transform = t }, []) const setBoard = useCallback( (pan: { x: number; y: number }, zoom: number) => { @@ -1531,7 +1204,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // the pan/zoom from the last session. useEffect(() => { // Reset to identity whenever the board opens or closes so each open starts - // centered at scale 1. Pure imperative now — no setState, no re-render. + // centered at scale 1. Pure imperative — no setState, no re-render. setBoard({ x: 0, y: 0 }, 1) }, [morphOpen, setBoard]) @@ -1604,12 +1277,16 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const factor = Math.exp(-delta * 0.0015) const z = boardZoomRef.current - const next = Math.max(0.25, Math.min(4, z * factor)) + const next = Math.max(BOARD_MIN_ZOOM, Math.min(BOARD_MAX_ZOOM, z * factor)) // Clamping can shrink the effective factor — recompute it so the // cursor-anchor math stays exact at the zoom limits. const applied = next / z - const rect = e.currentTarget.getBoundingClientRect() + // Anchor against the UNTRANSFORMED container, not the board layer itself + // (its rect is post-transform, which threw the anchor off by the current + // pan and made the zoom drift toward a point). cx/cy is the cursor + // relative to the transform origin (center center). + const rect = (containerRef.current ?? e.currentTarget).getBoundingClientRect() const cx = e.clientX - rect.left - rect.width / 2 const cy = e.clientY - rect.top - rect.height / 2 // Keep the content point under the cursor fixed: pan' = c - f·(c - pan). @@ -1683,7 +1360,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const byKey = new Map() const order: string[] = [] for (const refId of morphNeighborIds) { - const node = effectiveNodes.find((n) => n.ref_id === refId) + const node = effectiveNodeByRefId.get(refId) if (!node) continue const type = node.node_type || "Node" const rel = relFor.get(refId) ?? "" @@ -1709,7 +1386,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas } } return items - }, [morphSelectedRefId, morphNeighborIds, morphVisibleEdges, effectiveNodes]) + }, [morphSelectedRefId, morphNeighborIds, morphVisibleEdges, effectiveNodeByRefId]) // One connector per board item: focal → item. const boardConnectorEdges = useMemo( @@ -1797,8 +1474,8 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas if (viewState.mode !== "subgraph") return null const refId = indexMap.get(viewState.selectedNodeId) if (!refId) return null - return nodes.find((n) => n.ref_id === refId) ?? null - }, [viewState, indexMap, nodes]) + return nodeByRefId.get(refId) ?? null + }, [viewState, indexMap, nodeByRefId]) // Opens the in-3D case board (morph + camera tilt + Html cards) on the // node the user has been zooming into. apparentRadius is unused now — @@ -1812,16 +1489,8 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas ) // Closes the in-3D case board: drops morph state and pulls the camera back - // to the selected node's rest distance so the trigger re-arms and the user - // has room to maneuver before the next dolly-in. - // Disarm the dolly-in auto-trigger across a manual close. computeCamTarget - // below parks the camera at the node's rest distance, which for leaf nodes - // is inside CASE_VIEW_TRIGGER_DISTANCE — without this guard the trigger - // would see "camera is close" and reopen the board immediately. Cleared in - // CaseViewTrigger once the camera pulls back out past the re-arm threshold. - const caseTriggerSuppressedRef = useRef(false) + // to the selected node's rest distance so the user has room to maneuver. const handleCloseCaseBoard = useCallback(() => { - caseTriggerSuppressedRef.current = true useCaseBoardStore.getState().close() if (viewState.mode === "subgraph") { setCamTarget( @@ -1869,12 +1538,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const set = new Set() for (let i = 0; i < nodes.length; i++) { const p = nodes[i].properties as Record | undefined - const lineStr = - (p && typeof p.metro_line === "string" ? p.metro_line : null) ?? - (p && typeof p.line === "string" ? p.line : null) ?? - "" - const lines = lineStr.split(",").map((s: string) => s.trim().toLowerCase()) - if (lines.includes(hoveredLine)) set.add(i) + if (readStationLines(p).includes(hoveredLine)) set.add(i) } return set.size > 0 ? set : null }, [nodes, hoveredLine]) @@ -1888,13 +1552,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas for (const n of nodes) { if (n.node_type !== "Station") continue const p = n.properties as Record | undefined - const raw = - (p && typeof p.metro_line === "string" ? p.metro_line : null) ?? - (p && typeof p.line === "string" ? p.line : null) ?? - "" - const lines = new Set( - raw.split(",").map((s: string) => s.trim().toLowerCase()).filter(Boolean) - ) + const lines = new Set(readStationLines(p)) if (lines.size > 0) stationLines.set(n.ref_id, lines) } const map = new Map>() @@ -2007,10 +1665,10 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas setHoveredCardNode(null) return } - const apiNode = nodes.find((n) => n.ref_id === refId) + const apiNode = nodeByRefId.get(refId) setHoveredCardNode(apiNode ?? null) }, - [indexMap, nodes] + [indexMap, nodeByRefId] ) const handlePointerMove = useCallback((e: React.PointerEvent) => { @@ -2027,7 +1685,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas useGraphStore.getState().setHoveredNode(null) const refId = indexMap.get(nodeId) if (refId && onNodeSelect) { - const apiNode = nodes.find((n) => n.ref_id === refId) + const apiNode = nodeByRefId.get(refId) if (apiNode) onNodeSelect(apiNode) } @@ -2106,7 +1764,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // the search-pan effect from firing for it. lastPannedSearchTerm.current = searchTerm }, - [graph, indexMap, nodes, onNodeSelect, setCamTarget, searchTerm] + [graph, indexMap, nodeByRefId, onNodeSelect, setCamTarget, searchTerm] ) const handleReset = useCallback(() => { @@ -2165,6 +1823,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas return (
{ useGraphStore.getState().setSidebarSelectedNode(null) useGraphStore.getState().setHoveredNode(null) @@ -2235,8 +1895,6 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas selectedApiNode={selectedApiNode} onOpen={handleOpenCaseView} disabled={morphOpen} - camAnim={camAnim} - suppressedRef={caseTriggerSuppressedRef} /> {morphOpen && morphSelectedRefId && (
- {morphOpen && ( - - )} + {/* Connector layer — sibling of the board layer, given the SAME pan/zoom + transform (see applyBoardTransform) so the edge SVG scales as one with + the cards. Above the cards so dots/pills read on top; inert to pointer + so it never blocks card clicks or board panning. */} +
+ {morphOpen && ( + + )} +
{morphOpen && ( + ) + } + + return ( +
+
+ + Add image + + +
+ + { + const f = e.target.files?.[0] + e.target.value = "" + if (f) handleFile(f) + }} + /> + + {/* Drop / paste / browse zone */} + + + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/src/lib/__tests__/node-preview-panel.test.tsx b/src/lib/__tests__/node-preview-panel.test.tsx index f4b8c27e..9a46cbb7 100644 --- a/src/lib/__tests__/node-preview-panel.test.tsx +++ b/src/lib/__tests__/node-preview-panel.test.tsx @@ -32,7 +32,8 @@ const { mockTriggerDeepResearch, mockGetLatestStakworkRun, mockGetNode, mockGetA mockGetNode: vi.fn().mockResolvedValue(null), mockGetAttachables: vi.fn().mockResolvedValue({ nodes: [], edges: [] }), })) -vi.mock("@/lib/graph-api", () => ({ +vi.mock("@/lib/graph-api", async (importOriginal) => ({ + ...(await importOriginal()), triggerDeepResearch: (...args: unknown[]) => mockTriggerDeepResearch(...args), getLatestStakworkRun: (...args: unknown[]) => mockGetLatestStakworkRun(...args), getNode: (...args: unknown[]) => mockGetNode(...args), diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index aea21342..71887cb1 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -268,6 +268,45 @@ export async function addImageContent( return response.json() } +// Link two existing nodes with an `attachable:true` edge so the target renders +// inline under the source in the preview panel (see AttachableEmbeds). The +// flag lives in edge_data; the render side fetches via edge_props={attachable:true}. +// edge_type defaults to CONTAINS — a built-in EDGE_TYPE, so it needs no +// per-type edge schema between source and target. +export async function createAttachableEdge( + sourceRefId: string, + targetRefId: string, + edgeType: string = "CONTAINS", + signal?: AbortSignal +) { + return createEdge( + { + edge: { edge_type: edgeType, edge_data: { attachable: true } }, + source: { ref_id: sourceRefId }, + target: { ref_id: targetRefId }, + }, + signal + ) +} + +// One-shot "attach an image to a node": upload the file as an Image node +// (reusing the /v2/content/image pipeline) then wire an attachable CONTAINS +// edge from the target node to it. Returns the new Image node's ref_id. +// No schema field is touched — the image shows up purely via the edge. +export async function attachImageToNode( + targetRefId: string, + file: File, + signal?: AbortSignal +): Promise<{ imageRefId: string }> { + const res = await addImageContent(file, {}, signal) + const imageRefId = res.nodes?.[0]?.ref_id + if (typeof imageRefId !== "string") { + throw new Error("image upload did not return a node ref_id") + } + await createAttachableEdge(targetRefId, imageRefId, "CONTAINS", signal) + return { imageRefId } +} + // Update a node export async function updateNode( refId: string, diff --git a/src/lib/node-display.ts b/src/lib/node-display.ts index 70f1e885..2de34823 100644 --- a/src/lib/node-display.ts +++ b/src/lib/node-display.ts @@ -92,5 +92,15 @@ export function resolveNodeTitle(node: GraphNode, schemas: SchemaNode[]): string } export function resolveNodeThumbnail(node: GraphNode): string | undefined { - return pickString(node.properties, "image_url") ?? pickString(node.properties, "thumbnail") + // image_url/thumbnail cover most nodes. Image-type nodes (e.g. ones attached + // via /v2/content/image) hold their image in url/source_link/source_url + // instead, so fall back to those — otherwise attached Image nodes render as + // an empty placeholder. + return ( + pickString(node.properties, "image_url") ?? + pickString(node.properties, "thumbnail") ?? + pickString(node.properties, "url") ?? + pickString(node.properties, "source_link") ?? + pickString(node.properties, "source_url") + ) } From 4afd92ac19b529c4d681094790f067cfe9d3f96b Mon Sep 17 00:00:00 2001 From: Rassl Date: Sat, 6 Jun 2026 01:16:31 +0400 Subject: [PATCH 12/19] feat: test build --- src/stores/app-store.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stores/app-store.ts b/src/stores/app-store.ts index 30caa8f9..3f274c99 100644 --- a/src/stores/app-store.ts +++ b/src/stores/app-store.ts @@ -2,6 +2,7 @@ import { create } from "zustand" +// // interface AppState { searchTerm: string sidebarOpen: boolean From 89503cab4fdb7ee6f23c7e3e692eba85d4a2286b Mon Sep 17 00:00:00 2001 From: Rassl Date: Sat, 6 Jun 2026 01:45:45 +0400 Subject: [PATCH 13/19] feat: allow attach images for non admins --- src/components/layout/attachable-embeds.tsx | 61 +++++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/src/components/layout/attachable-embeds.tsx b/src/components/layout/attachable-embeds.tsx index d9ae8189..398b8508 100644 --- a/src/components/layout/attachable-embeds.tsx +++ b/src/components/layout/attachable-embeds.tsx @@ -29,10 +29,20 @@ import { resolveNodeTitle, resolveNodeThumbnail, pickString } from "@/lib/node-d import { displayNodeType, cn } from "@/lib/utils" import { payL402 } from "@/lib/sphinx" import { useUserStore } from "@/stores/user-store" +import { cookieStorage } from "@/lib/cookie-storage" +import { BoostButton } from "@/components/boost/boost-button" +import { BulletIcon } from "@/components/ui/bullet-icon" import type { SchemaNode } from "@/app/ontology/page" const ALLOWED_IMAGE_TYPE_SET = new Set(ALLOWED_IMAGE_TYPES) +// Current boost on a node — `boost` is the canonical field, `num_boost` a +// legacy fallback (mirrors node-preview-panel). +function nodeBoost(node: GraphNode): number { + const b = node.properties?.boost ?? node.properties?.num_boost + return typeof b === "number" && b > 0 ? b : 0 +} + interface AttachableEmbedsProps { nodeRefId: string schemas: SchemaNode[] @@ -46,7 +56,11 @@ export function AttachableEmbeds({ nodeRefId, schemas, onNavigate }: AttachableE const [lightbox, setLightbox] = useState<{ images: GraphNode[]; index: number } | null>(null) // Bumped after a successful attach to re-pull the attachables for this node. const [reloadNonce, setReloadNonce] = useState(0) + // Anyone signed in (admin, a pubkey, or an L402 balance) can attach — not + // admins only. Matches the "Add Edge" gate in the node panel. const isAdmin = useUserStore((s) => s.isAdmin) + const pubKey = useUserStore((s) => s.pubKey) + const canAttach = isAdmin || !!pubKey || !!cookieStorage.getItem("l402") useEffect(() => { const controller = new AbortController() @@ -78,9 +92,9 @@ export function AttachableEmbeds({ nodeRefId, schemas, onNavigate }: AttachableE } }, [peers]) - // For non-admins with no attachables → render nothing (no label, no empty - // state). Admins always get the section so they can add the first one. - if ((!peers || peers.length === 0) && !isAdmin) return null + // With no attachables and no way to add → render nothing (no label, no empty + // state). Signed-in users always get the section so they can add the first one. + if ((!peers || peers.length === 0) && !canAttach) return null return (
@@ -96,7 +110,7 @@ export function AttachableEmbeds({ nodeRefId, schemas, onNavigate }: AttachableE onNavigate?.(n)} /> ))} - {isAdmin && ( + {canAttach && ( setReloadNonce((n) => n + 1)} @@ -211,6 +225,11 @@ function MediaGrid({ images, onOpen }: { images: GraphNode[]; onOpen: (index: nu +{extra} )} + {nodeBoost(im) > 0 && ( + + {nodeBoost(im)} + + )} ))}
@@ -332,8 +351,9 @@ function Lightbox({ )} -
+
{resolveNodeTitle(im, schemas)} +
@@ -405,6 +425,37 @@ function ImageThumb({ ) } +/* ── Boost an image — shows current amount and lets anyone boost ─────────── */ +function ImageBoost({ node }: { node: GraphNode }) { + const p = node.properties ?? {} + const ownerReference = typeof p.owner_reference_id === "string" ? p.owner_reference_id : undefined + const pubkey = typeof p.pubkey === "string" ? p.pubkey : undefined + const routeHint = typeof p.route_hint === "string" ? p.route_hint : undefined + const boost = nodeBoost(node) + + // BoostButton handles the L402 flow and shows the live count. It needs an + // owner to credit; without one we can only display the current amount. + if (ownerReference) { + return ( + + ) + } + if (boost > 0) { + return ( + + {boost} bullets + + ) + } + return null +} + /* ── Add image — drop / paste / browse → Image node + attachable edge ────── */ const maxMb = Math.round(MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024) From 481933ad25d25c921b0daa6657571eed2800b28c Mon Sep 17 00:00:00 2001 From: Rassl Date: Sun, 7 Jun 2026 00:50:56 +0400 Subject: [PATCH 14/19] feat: attachables one payment --- src/components/layout/attachable-embeds.tsx | 54 ++++++++++++--------- src/lib/graph-api.ts | 48 +++--------------- 2 files changed, 40 insertions(+), 62 deletions(-) diff --git a/src/components/layout/attachable-embeds.tsx b/src/components/layout/attachable-embeds.tsx index 398b8508..3e1f2101 100644 --- a/src/components/layout/attachable-embeds.tsx +++ b/src/components/layout/attachable-embeds.tsx @@ -20,7 +20,7 @@ import { Play, Clock, Image as ImageIcon, ChevronRight, X, ChevronLeft, ImagePlu import { getAttachables, - attachImageToNode, + addImageContent, ALLOWED_IMAGE_TYPES, MAX_IMAGE_UPLOAD_BYTES, } from "@/lib/graph-api" @@ -459,6 +459,22 @@ function ImageBoost({ node }: { node: GraphNode }) { /* ── Add image — drop / paste / browse → Image node + attachable edge ────── */ const maxMb = Math.round(MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024) +// Run one paid call, settling the L402 once on a 402 and retrying that SAME +// call. Deliberately per-call: the image upload and the edge are retried +// independently, so a 402 on the (cheaper) edge step can never re-run — and +// re-charge for — the image upload. +async function withL402Retry(fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + if (err instanceof Response && err.status === 402) { + await payL402(() => {}) + return await fn() + } + throw err + } +} + function AttachImageControl({ nodeRefId, onAttached, @@ -472,25 +488,6 @@ function AttachImageControl({ const [dragOver, setDragOver] = useState(false) const inputRef = useRef(null) - // Upload validated bytes via the working pipeline (Image node + S3 + workflow), - // then wire the attachable edge. Retries once through the L402 on a 402. - const uploadAndAttach = useCallback( - async (file: File) => { - const run = () => attachImageToNode(nodeRefId, file) - try { - await run() - } catch (err) { - if (err instanceof Response && err.status === 402) { - await payL402(() => {}) - await run() - } else { - throw err - } - } - }, - [nodeRefId] - ) - async function describeError(err: unknown): Promise { if (err instanceof Response) { const body = (await err.json().catch(() => null)) as { message?: string; errorCode?: string } | null @@ -512,7 +509,20 @@ function AttachImageControl({ setBusy(true) setError(null) try { - await uploadAndAttach(file) + // Single paid step: boltwall uploads the Image node AND creates the + // attachable edge to nodeRefId server-side, so the user is charged once. + const res = await withL402Retry(() => + addImageContent(file, { attachTo: nodeRefId }) + ) + if (typeof res.nodes?.[0]?.ref_id !== "string") { + throw new Error("image upload did not return a node ref_id") + } + // attached === false means the image uploaded but the edge insert + // failed server-side — surface it rather than silently showing nothing. + if (res.attached === false) { + setError("Image uploaded but couldn't be attached — please try again") + return + } onAttached() setOpen(false) } catch (err) { @@ -521,7 +531,7 @@ function AttachImageControl({ setBusy(false) } }, - [uploadAndAttach, onAttached] + [nodeRefId, onAttached] ) // Paste an image anywhere while the panel is open (e.g. a screenshot). diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 71887cb1..31467abb 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -230,13 +230,16 @@ export const MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024 // application/json) so the browser can set the multipart boundary itself. export async function addImageContent( file: File, - opts: { name?: string; webhookUrl?: string } = {}, + opts: { name?: string; webhookUrl?: string; attachTo?: string } = {}, signal?: AbortSignal ): Promise<{ status: string nodes: Array> status_messages: string[] temp_url?: string + // Present only when attachTo was supplied: whether the server created the + // attachable edge. false means the image uploaded but the edge insert failed. + attached?: boolean }> { const url = new URL(`${API_URL}/v2/content/image`) @@ -246,6 +249,10 @@ export async function addImageContent( url.searchParams.append("msg", signed.message) } + // One-payment attach: the boltwall image endpoint creates the attachable + // edge server-side (unbilled) when attach_to is present — a single charge. + if (opts.attachTo) url.searchParams.append("attach_to", opts.attachTo) + const headers: Record = {} const l402 = await getL402() if (l402) headers.Authorization = l402 @@ -268,45 +275,6 @@ export async function addImageContent( return response.json() } -// Link two existing nodes with an `attachable:true` edge so the target renders -// inline under the source in the preview panel (see AttachableEmbeds). The -// flag lives in edge_data; the render side fetches via edge_props={attachable:true}. -// edge_type defaults to CONTAINS — a built-in EDGE_TYPE, so it needs no -// per-type edge schema between source and target. -export async function createAttachableEdge( - sourceRefId: string, - targetRefId: string, - edgeType: string = "CONTAINS", - signal?: AbortSignal -) { - return createEdge( - { - edge: { edge_type: edgeType, edge_data: { attachable: true } }, - source: { ref_id: sourceRefId }, - target: { ref_id: targetRefId }, - }, - signal - ) -} - -// One-shot "attach an image to a node": upload the file as an Image node -// (reusing the /v2/content/image pipeline) then wire an attachable CONTAINS -// edge from the target node to it. Returns the new Image node's ref_id. -// No schema field is touched — the image shows up purely via the edge. -export async function attachImageToNode( - targetRefId: string, - file: File, - signal?: AbortSignal -): Promise<{ imageRefId: string }> { - const res = await addImageContent(file, {}, signal) - const imageRefId = res.nodes?.[0]?.ref_id - if (typeof imageRefId !== "string") { - throw new Error("image upload did not return a node ref_id") - } - await createAttachableEdge(targetRefId, imageRefId, "CONTAINS", signal) - return { imageRefId } -} - // Update a node export async function updateNode( refId: string, From 0ede514910345be5764c5ab0cfa253faaac25672 Mon Sep 17 00:00:00 2001 From: Rassl Date: Mon, 8 Jun 2026 19:19:10 +0400 Subject: [PATCH 15/19] feat: attachables view --- src/components/boost/boost-button.tsx | 48 +- src/components/case-board/case-card.tsx | 113 +++- src/components/case-board/node-morph.tsx | 4 + src/components/case-view/adapter.ts | 103 ---- src/components/case-view/camera.ts | 41 -- src/components/case-view/case-view.tsx | 633 -------------------- src/components/case-view/constants.ts | 69 --- src/components/case-view/draw.ts | 517 ---------------- src/components/case-view/index.ts | 2 - src/components/case-view/layout.ts | 82 --- src/components/case-view/types.ts | 33 - src/components/layout/attachable-embeds.tsx | 63 +- src/components/universe/graph-canvas.tsx | 30 + 13 files changed, 238 insertions(+), 1500 deletions(-) delete mode 100644 src/components/case-view/adapter.ts delete mode 100644 src/components/case-view/camera.ts delete mode 100644 src/components/case-view/case-view.tsx delete mode 100644 src/components/case-view/constants.ts delete mode 100644 src/components/case-view/draw.ts delete mode 100644 src/components/case-view/index.ts delete mode 100644 src/components/case-view/layout.ts delete mode 100644 src/components/case-view/types.ts diff --git a/src/components/boost/boost-button.tsx b/src/components/boost/boost-button.tsx index 9ba094d5..6579c06f 100644 --- a/src/components/boost/boost-button.tsx +++ b/src/components/boost/boost-button.tsx @@ -5,8 +5,9 @@ import { BulletIcon } from "@/components/ui/bullet-icon" import { cn } from "@/lib/utils" import { api } from "@/lib/api" import { isMocksEnabled } from "@/lib/mock-data" -import { adminKeysend, isSphinx, payL402 } from "@/lib/sphinx" +import { adminKeysend, hasWebLN, isSphinx, payL402 } from "@/lib/sphinx" import { useUserStore } from "@/stores/user-store" +import { useModalStore } from "@/stores/modal-store" import { parsePubkeyWithHint } from "@/lib/pubkey-utils" const DEFAULT_BOOST_AMOUNT = 10 @@ -20,6 +21,9 @@ interface BoostButtonProps { routeHint?: string boostCount?: number className?: string + /** "default" = labelled button; "compact" = single glassy pill that always + * shows the current total and doubles as the trigger (for image overlays). */ + variant?: "default" | "compact" } export function BoostButton({ @@ -29,6 +33,7 @@ export function BoostButton({ routeHint, boostCount = 0, className, + variant = "default", }: BoostButtonProps) { const [count, setCount] = useState(boostCount) const [boosting, setBoosting] = useState(false) @@ -38,6 +43,7 @@ export function BoostButton({ const isAdmin = useUserStore((s) => s.isAdmin) const setBudget = useUserStore((s) => s.setBudget) const refreshBalance = useUserStore((s) => s.refreshBalance) + const openModal = useModalStore((s) => s.open) const handleBoost = useCallback(async () => { if (boosting) return @@ -62,6 +68,15 @@ export function BoostButton({ await api.post("/boost", body) } catch (err) { if (err instanceof Response && err.status === 402) { + // 402 means no L402 token or insufficient balance. payL402 can + // settle this inline only when a wallet is present (Sphinx app or + // WebLN extension). Otherwise the user needs the QR top-up flow — + // open the budget modal rather than throwing "No WebLN provider". + if (!isSphinx() && !hasWebLN()) { + openModal("budget") + setError("Top up your balance to boost.") + return + } await payL402(setBudget) await api.post("/boost", body) } else { @@ -81,7 +96,36 @@ export function BoostButton({ } finally { setBoosting(false) } - }, [refId, ownerReference, pubkey, routeHint, boosting, isAdmin, setBudget, refreshBalance]) + }, [refId, ownerReference, pubkey, routeHint, boosting, isAdmin, setBudget, refreshBalance, openModal]) + + if (variant === "compact") { + // Single pill: always shows the current total and is itself the trigger. + // Lives as an overlay on image tiles / the lightbox, so keep it tiny and + // glassy. Errors are surfaced via the title tooltip (the no-wallet case + // opens the budget modal from handleBoost). + return ( + + ) + } return (
diff --git a/src/components/case-board/case-card.tsx b/src/components/case-board/case-card.tsx index 58f5f619..143b7567 100644 --- a/src/components/case-board/case-card.tsx +++ b/src/components/case-board/case-card.tsx @@ -101,6 +101,14 @@ function pickTitle(node: GraphNode): string { return node.ref_id } +// Current boost on a node — `boost` is canonical, `num_boost` a legacy +// fallback (mirrors attachable-embeds / node-preview-panel). +function nodeBoost(node: GraphNode): number { + const props = node.properties as Record | undefined + const b = props?.boost ?? props?.num_boost + return typeof b === "number" && b > 0 ? b : 0 +} + export interface CaseCardProps { node: GraphNode variant: "selected" | "neighbor" @@ -108,9 +116,11 @@ export interface CaseCardProps { // below ~0.001 so this stays a smooth fade. morphProgress: number onClick?: () => void + // Focal-only: the node's attached images, rendered as an embedded strip. + attachedImages?: GraphNode[] } -export function CaseCard({ node, variant, morphProgress, onClick }: CaseCardProps) { +export function CaseCard({ node, variant, morphProgress, onClick, attachedImages }: CaseCardProps) { const type = node.node_type || "" const accent = TYPE_ACCENT[type] ?? DEFAULT_ACCENT const title = pickTitle(node) @@ -120,6 +130,10 @@ export function CaseCard({ node, variant, morphProgress, onClick }: CaseCardProp const thumbnail = resolveNodeThumbnail(node) const description = isSelected ? pickDescription(node) : null const fields = pickFields(node, isSelected ? 4 : 3, 60) + // Attached images only render on the focal card. Attachables are fetched + // upstream (getAttachables) and passed down — the board's neighbour set + // doesn't include them. + const images = isSelected ? attachedImages ?? [] : [] const opacity = Math.max(0, Math.min(1, morphProgress)) const widthPx = isSelected ? 300 : 240 @@ -272,6 +286,103 @@ export function CaseCard({ node, variant, morphProgress, onClick }: CaseCardProp ))}
)} + {images.length > 0 && ( +
0 ? 12 : 10 }}> +
+ {images.length} {images.length === 1 ? "image" : "images"} +
+
+ {images.slice(0, 4).map((im, i) => { + const thumb = resolveNodeThumbnail(im) + const boost = nodeBoost(im) + const extra = i === 3 && images.length > 4 ? images.length - 4 : 0 + return ( +
+ {boost > 0 && extra === 0 && ( +
+ + {boost} +
+ )} + {extra > 0 && ( +
+ +{extra} +
+ )} +
+ ) + })} +
+
+ )} ) diff --git a/src/components/case-board/node-morph.tsx b/src/components/case-board/node-morph.tsx index 31713a3e..143efe85 100644 --- a/src/components/case-board/node-morph.tsx +++ b/src/components/case-board/node-morph.tsx @@ -25,6 +25,8 @@ interface NodeMorphProps { // Registers the card's DOM root so the connector overlay can measure its // real on-screen rectangle and attach edges to the actual card border. registerEl?: (el: HTMLElement | null) => void + // Focal-only: the node's attached images, embedded as a strip in the card. + attachedImages?: GraphNode[] } function lerp(a: number, b: number, t: number) { @@ -44,6 +46,7 @@ export function NodeMorph({ onClick, portal, registerEl, + attachedImages, }: NodeMorphProps) { if (morphProgress <= 0.001) return null const t = Math.max(0, Math.min(1, morphProgress)) @@ -78,6 +81,7 @@ export function NodeMorph({ variant={variant} morphProgress={morphProgress} onClick={onClick} + attachedImages={attachedImages} /> diff --git a/src/components/case-view/adapter.ts b/src/components/case-view/adapter.ts deleted file mode 100644 index d3004d8b..00000000 --- a/src/components/case-view/adapter.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { GraphNode, GraphEdge } from "@/lib/graph-api" -import type { SchemaNode } from "@/app/ontology/page" -import { resolveNodeTitle } from "@/lib/node-display" -import type { SigEntity, SigEdge, SigDataset } from "./types" -import { - TYPE_HUES, - KIND_RADIUS, - DEFAULT_KIND_RADIUS, - SELECTED_SCALE, - C, -} from "./constants" -import { layoutRing, computeWorldBBox } from "./layout" - -interface BuildArgs { - selectedRefId: string - nodes: GraphNode[] - edges: GraphEdge[] - schemas: SchemaNode[] -} - -export function buildCaseDataset({ - selectedRefId, - nodes, - edges, - schemas, -}: BuildArgs): SigDataset | null { - const selectedNode = nodes.find((n) => n.ref_id === selectedRefId) - if (!selectedNode) return null - - // 1-hop neighbors: any node connected to selected by one edge in either - // direction. The backend returns the union via expand=edges; we just need to - // map source/target → neighbor refs. - const neighborRefIds = new Set() - for (const e of edges) { - if (e.source === selectedRefId) neighborRefIds.add(e.target) - if (e.target === selectedRefId) neighborRefIds.add(e.source) - } - - const byId = new Map() - const flat: SigEntity[] = [] - - function toSig(node: GraphNode, isSelected: boolean): SigEntity { - const type = node.node_type || "Unknown" - const baseR = KIND_RADIUS[type] ?? DEFAULT_KIND_RADIUS - const r = isSelected ? baseR * SELECTED_SCALE : baseR - const color = TYPE_HUES[type] ?? C.accent - return { - id: node.ref_id, - name: resolveNodeTitle(node, schemas), - kind: type, - isSelected, - x: 0, - y: 0, - r, - color, - node, - } - } - - const selectedSig = toSig(selectedNode, true) - byId.set(selectedSig.id, selectedSig) - flat.push(selectedSig) - - const neighbors: SigEntity[] = [] - for (const refId of neighborRefIds) { - const node = nodes.find((n) => n.ref_id === refId) - if (!node) continue - const sig = toSig(node, false) - byId.set(sig.id, sig) - flat.push(sig) - neighbors.push(sig) - } - - layoutRing(selectedSig, neighbors) - - const sigEdges: SigEdge[] = [] - const seen = new Set() - for (const e of edges) { - const from = byId.get(e.source) - const to = byId.get(e.target) - if (!from || !to || from === to) continue - const key = `${e.source}→${e.target}→${e.edge_type}` - if (seen.has(key)) continue - seen.add(key) - sigEdges.push({ - id: key, - fromId: e.source, - toId: e.target, - from, - to, - label: e.edge_type, - }) - } - - return { - selectedId: selectedSig.id, - selected: selectedSig, - byId, - flat, - edges: sigEdges, - worldBBox: computeWorldBBox(flat), - } -} diff --git a/src/components/case-view/camera.ts b/src/components/case-view/camera.ts deleted file mode 100644 index 1559fb23..00000000 --- a/src/components/case-view/camera.ts +++ /dev/null @@ -1,41 +0,0 @@ -export interface Cam { - x: number - y: number - scale: number -} - -export function smoothstep(x: number, a: number, b: number): number { - const t = Math.max(0, Math.min(1, (x - a) / (b - a))) - return t * t * (3 - 2 * t) -} - -export function worldToScreen( - wx: number, - wy: number, - cam: Cam, - w: number, - h: number, -) { - return { - x: (wx - cam.x) * cam.scale + w / 2, - y: (wy - cam.y) * cam.scale + h / 2, - } -} - -export function screenToWorld( - sx: number, - sy: number, - cam: Cam, - w: number, - h: number, -) { - return { - x: (sx - w / 2) / cam.scale + cam.x, - y: (sy - h / 2) / cam.scale + cam.y, - } -} - -export function hexToRGB(hex: string): string { - const n = parseInt(hex.slice(1), 16) - return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}` -} diff --git a/src/components/case-view/case-view.tsx b/src/components/case-view/case-view.tsx deleted file mode 100644 index 3a874947..00000000 --- a/src/components/case-view/case-view.tsx +++ /dev/null @@ -1,633 +0,0 @@ -"use client" - -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import type { GraphNode, GraphData, GraphEdge } from "@/lib/graph-api" -import { getNode } from "@/lib/graph-api" -import type { SchemaNode } from "@/app/ontology/page" -import { metroSeries } from "@/data/metro" -import { buildCaseDataset } from "./adapter" -import { LOD, C, FONT_MONO } from "./constants" -import { worldToScreen, screenToWorld, type Cam } from "./camera" -import { - clear, - drawDot, - drawLeafGlyph, - drawEdge, - getNodeCardBounds, -} from "./draw" -import type { SigDataset, SigEntity } from "./types" - -const METRO_FIXTURE_STATION_REF_IDS = new Set( - (metroSeries.nodes as { ref_id: string; node_type?: string }[]) - .filter((n) => n.node_type === "Station") - .map((n) => n.ref_id), -) - -// Read the 1-hop subgraph for a refId. Backend nodes go through /v2/nodes; -// metro stations short-circuit to the local fixture (same pattern as -// node-preview-panel.tsx — backend collapses platform variants we want to keep). -async function fetchCaseSubgraph( - refId: string, - signal?: AbortSignal, -): Promise { - if (METRO_FIXTURE_STATION_REF_IDS.has(refId)) { - const allNodes = metroSeries.nodes as GraphNode[] - const allEdges = metroSeries.edges as GraphEdge[] - const neighborIds = new Set([refId]) - for (const e of allEdges) { - if (e.source === refId) neighborIds.add(e.target) - if (e.target === refId) neighborIds.add(e.source) - } - return { - nodes: allNodes.filter((n) => neighborIds.has(n.ref_id)), - edges: allEdges.filter( - (e) => e.source === refId || e.target === refId, - ), - } - } - return getNode(refId, "edges", signal) -} - -const INITIAL_SCALE = 1.0 -const EXIT_ZOOM_THRESHOLD = 0.25 // cam.scale below this → trigger 2D→3D exit - -export interface CaseViewProps { - initialNode: GraphNode - schemas: SchemaNode[] - // Apparent screen radius the selected node had in 3D at the handoff frame. - // We initialize cam.scale so the same node has the same on-screen radius - // here — the user's eye doesn't lose it. - initialApparentRadius?: number - // Fired when the 2D overlay flips from hidden to visible (data has landed - // and the fade-in is starting). Parent uses this to start fading the 3D - // canvas out — synchronized so there's no blank gap during a slow fetch. - onShown?: () => void - // Fired when requestExit starts (user pressed Esc / hit close / zoomed out). - // Parent uses this to start fading the 3D canvas back in, in lockstep with - // the 2D fade-out. - onWillHide?: () => void - onExit: () => void -} - -// Duration of the cross-fade between the 3D canvas and the 2D case view, in -// ms. Used both on open (after data arrives) and on close (before onExit). -const FADE_MS = 300 -// Per-entity entry animation duration. Focal card visually shrinks from -// handoff size to rest; neighbors + edges fade in alpha after a short delay -// so the eye locks on the focal first and the rest "draws in" around it. -const ENTRY_MS = 600 -const NEIGHBOR_DELAY_MS = 120 -const REST_SCALE = 1.0 - -export function CaseView({ - initialNode, - schemas, - initialApparentRadius, - onShown, - onWillHide, - onExit, -}: CaseViewProps) { - const [currentRefId, setCurrentRefId] = useState(initialNode.ref_id) - const [data, setData] = useState(null) - const [loadError, setLoadError] = useState(null) - // Drives the outer-div CSS opacity transition. Stays 0 until data lands, - // then ramps to 1 (open). Flips back to 0 on close, and after the - // transition completes we call the parent's onExit to actually unmount. - const [visible, setVisible] = useState(false) - const stageRef = useRef(null) - const bgRef = useRef(null) - const edgeRef = useRef(null) - const nodeRef = useRef(null) - - // Fetch dataset whenever the active refId changes - useEffect(() => { - const controller = new AbortController() - setLoadError(null) - fetchCaseSubgraph(currentRefId, controller.signal) - .then((g) => { - if (controller.signal.aborted) return - const ds = buildCaseDataset({ - selectedRefId: currentRefId, - nodes: g.nodes, - edges: g.edges, - schemas, - }) - if (!ds) { - setLoadError("No node data") - return - } - setData(ds) - }) - .catch((err) => { - if (controller.signal.aborted) return - setLoadError(err instanceof Error ? err.message : "Failed to load") - }) - return () => controller.abort() - }, [currentRefId, schemas]) - - // Imperative state for the render loop. Decoupled from React's render cycle - // so 60fps pan/zoom doesn't trigger re-renders. - const stateRef = useRef({ - cam: { x: 0, y: 0, scale: INITIAL_SCALE } as Cam, - DPR: typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 2) : 1, - w: 0, - h: 0, - mouse: { x: 0, y: 0, down: false, dragStart: null as null | { x: number; y: number; camX: number; camY: number } }, - hover: null as SigEntity | null, - data: null as SigDataset | null, - t0: typeof performance !== "undefined" ? performance.now() : 0, - t: 0, - rafId: 0, - onNeighborClick: null as null | ((e: SigEntity) => void), - onExitZoom: null as null | (() => void), - exitFired: false, - // Entry animation. openedAt is the value of S.t when data landed; the - // focal card eases its visual scale from focalScaleStart down to 1 over - // ENTRY_MS, and neighbors/edges fade in alpha 0→1 after NEIGHBOR_DELAY_MS. - openedAt: 0, - focalScaleStart: 1, - }) - - useEffect(() => { - stateRef.current.data = data - if (!data) return - const S = stateRef.current - S.cam.x = data.selected.x - S.cam.y = data.selected.y - - // Pick cam.scale so the 1-hop subgraph fits comfortably in the viewport - // immediately — no separate auto-fit beat after the fade. If the canvas - // hasn't been measured yet (S.w === 0), fall back to REST_SCALE; the - // focal-card visual transform will still run, just without the precise - // fit clamping. - let restScale: number - if (S.w > 0 && S.h > 0) { - const bb = data.worldBBox - const worldW = Math.max(bb.maxX - bb.minX, 1) - const worldH = Math.max(bb.maxY - bb.minY, 1) - const margin = 80 - const fitScale = Math.min( - (S.w - margin * 2) / worldW, - (S.h - margin * 2) / worldH, - ) - // Clamp above the exit threshold so the trigger can't fire mid-entry, - // and below 2× rest scale so low-degree centers don't zoom in absurdly. - restScale = Math.max( - EXIT_ZOOM_THRESHOLD * 1.5, - Math.min(fitScale, REST_SCALE * 2), - ) - } else { - restScale = REST_SCALE - } - - // Handoff scale = the cam.scale that would have matched the 3D sphere's - // pixel radius. We don't *use* it as cam.scale (that'd zoom the whole - // world); instead the focal card gets a visual transform of - // handoff/rest, so it draws at the handoff pixel size while the rest of - // the world is already at rest scale. The transform eases to 1 over - // ENTRY_MS — only the focal card morphs, neighbors stay put. - const handoffScale = - initialApparentRadius && initialApparentRadius > 0 - ? initialApparentRadius / data.selected.r - : restScale - - S.cam.scale = restScale - S.focalScaleStart = handoffScale / restScale - S.openedAt = S.t - S.exitFired = false - }, [data, initialApparentRadius]) - - // Fade in once data is ready — keeps the case view transparent (so the 3D - // scene shows through) during the fetch, eliminating the "loading…" flash. - // Errors also flip visible so the failure message isn't hidden by opacity:0. - // Notifies the parent via onShown so it can start fading the 3D canvas out - // in sync with this fade-in (otherwise a slow fetch leaves a blank gap). - useEffect(() => { - if (!data && !loadError) return - // rAF lets the browser commit the opacity:0 initial frame before - // flipping to 1, so the CSS transition actually animates. - const id = requestAnimationFrame(() => { - setVisible(true) - onShown?.() - }) - return () => cancelAnimationFrame(id) - }, [data, loadError, onShown]) - - // Triggers the fade-out, then calls the parent's onExit once the - // transition has finished playing. Replaces direct onExit() everywhere so - // every close path (Esc / X button / zoom-out trigger) animates. Fires - // onWillHide so the parent can start fading the 3D canvas back in. - const requestExit = useCallback(() => { - setVisible(false) - onWillHide?.() - setTimeout(onExit, FADE_MS) - }, [onExit, onWillHide]) - - // Esc + close button exit - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") requestExit() - } - window.addEventListener("keydown", onKey) - return () => window.removeEventListener("keydown", onKey) - }, [requestExit]) - - // Hit-test in screen space. At high LOD the node renders as a content - // card (drawNodeCard), so the test uses its rectangular bounds; - // otherwise it falls back to the circular radius the glyph occupies. - // Neighbors that haven't faded in past ~50% are skipped so the user - // can't click an entity they can barely see. - const hitTest = useCallback((sx: number, sy: number): SigEntity | null => { - const S = stateRef.current - if (!S.data) return null - const entryElapsed = S.t - S.openedAt - const neighborT = Math.min( - 1, - Math.max(0, (entryElapsed - NEIGHBOR_DELAY_MS) / (ENTRY_MS - NEIGHBOR_DELAY_MS)), - ) - for (const e of S.data.flat) { - if (!e.isSelected && neighborT < 0.5) continue - const sc = worldToScreen(e.x, e.y, S.cam, S.w, S.h) - const appR = e.r * S.cam.scale - if (appR > LOD.CARD_VISIBLE) { - const b = getNodeCardBounds(e, sc, appR) - if (sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h) { - return e - } - } else { - const r = appR + 6 - const dx = sx - sc.x - const dy = sy - sc.y - if (dx * dx + dy * dy <= r * r) return e - } - } - return null - }, []) - - const handleNeighborClick = useCallback((e: SigEntity) => { - if (e.id === currentRefId) return - setCurrentRefId(e.id) - }, [currentRefId]) - - useEffect(() => { - stateRef.current.onNeighborClick = handleNeighborClick - stateRef.current.onExitZoom = requestExit - }, [handleNeighborClick, requestExit]) - - // Render loop + input handling - useEffect(() => { - const stage = stageRef.current - const bgC = bgRef.current - const edgeC = edgeRef.current - const nodeC = nodeRef.current - if (!stage || !bgC || !edgeC || !nodeC) return - const bgCtx = bgC.getContext("2d") - const edgeCtx = edgeC.getContext("2d") - const nodeCtx = nodeC.getContext("2d") - if (!bgCtx || !edgeCtx || !nodeCtx) return - const S = stateRef.current - - function resize() { - const r = stage!.getBoundingClientRect() - S.w = r.width - S.h = r.height - for (const c of [bgC, edgeC, nodeC]) { - if (!c) continue - c.width = Math.floor(r.width * S.DPR) - c.height = Math.floor(r.height * S.DPR) - c.getContext("2d")!.setTransform(S.DPR, 0, 0, S.DPR, 0, 0) - } - } - window.addEventListener("resize", resize) - resize() - - function drawBackground() { - clear(bgCtx!, S.w, S.h) - bgCtx!.fillStyle = C.bg0 - bgCtx!.fillRect(0, 0, S.w, S.h) - // grid - const step = 200 - const sStep = step * S.cam.scale - if (sStep < 10) return - const tl = screenToWorld(0, 0, S.cam, S.w, S.h) - const ox = (Math.floor(tl.x / step) * step - S.cam.x) * S.cam.scale + S.w / 2 - const oy = (Math.floor(tl.y / step) * step - S.cam.y) * S.cam.scale + S.h / 2 - bgCtx!.strokeStyle = `rgba(120, 200, 220, 0.06)` - bgCtx!.lineWidth = 1 - bgCtx!.beginPath() - for (let x = ox; x < S.w + sStep; x += sStep) { - bgCtx!.moveTo(x, 0) - bgCtx!.lineTo(x, S.h) - } - for (let y = oy; y < S.h + sStep; y += sStep) { - bgCtx!.moveTo(0, y) - bgCtx!.lineTo(S.w, y) - } - bgCtx!.stroke() - } - - // Cubic-out easing — fast at the start, settles smoothly. Same curve - // for focal scale and neighbor alpha so the two motions feel coordinated. - function easeOutCubic(t: number) { - return 1 - Math.pow(1 - t, 3) - } - - // Returns the entry-animation progress for neighbors and edges (alpha 0→1 - // after a short delay so the focal card "lands" first). - function entryNeighborAlpha() { - const elapsed = S.t - S.openedAt - const t = Math.min( - 1, - Math.max(0, (elapsed - NEIGHBOR_DELAY_MS) / (ENTRY_MS - NEIGHBOR_DELAY_MS)), - ) - return easeOutCubic(t) - } - - // Returns the entry-animation focal-card visual scale (handoff size → - // rest size over ENTRY_MS). - function entryFocalScale() { - const elapsed = S.t - S.openedAt - const t = Math.min(1, Math.max(0, elapsed / ENTRY_MS)) - const eased = easeOutCubic(t) - return S.focalScaleStart + (1 - S.focalScaleStart) * eased - } - - function drawEdges() { - clear(edgeCtx!, S.w, S.h) - if (!S.data) return - const alpha = entryNeighborAlpha() - if (alpha <= 0.005) return - const selectedId = S.data.selectedId - // 1) Only draw edges that touch the selected node — sibling-to-sibling - // edges from the API turn the case board into a hairball. - const drawable = S.data.edges.filter( - (e) => e.fromId === selectedId || e.toId === selectedId, - ) - // 2) Label dedup: show each edge-type label at most once, on the - // edge whose midpoint is closest to screen-center. - const showLabelsAtAll = S.cam.scale > 0.4 - const labelEdgeId = new Set() - if (showLabelsAtAll) { - const byType = new Map() - for (const e of drawable) { - const arr = byType.get(e.label ?? "") ?? [] - arr.push(e) - byType.set(e.label ?? "", arr) - } - for (const [, arr] of byType) { - let bestId = arr[0].id - let bestDx = Infinity - for (const e of arr) { - const ax = (e.from.x - S.cam.x) * S.cam.scale + S.w / 2 - const bx = (e.to.x - S.cam.x) * S.cam.scale + S.w / 2 - const mx = (ax + bx) / 2 - const dx = Math.abs(mx - S.w / 2) - if (dx < bestDx) { - bestDx = dx - bestId = e.id - } - } - labelEdgeId.add(bestId) - } - } - edgeCtx!.save() - edgeCtx!.globalAlpha = alpha - for (const e of drawable) { - const a = worldToScreen(e.from.x, e.from.y, S.cam, S.w, S.h) - const b = worldToScreen(e.to.x, e.to.y, S.cam, S.w, S.h) - const label = labelEdgeId.has(e.id) ? e.label : undefined - drawEdge(edgeCtx!, a, b, 1, label) - } - edgeCtx!.restore() - } - - function drawNodes() { - clear(nodeCtx!, S.w, S.h) - if (!S.data) return - const focalScale = entryFocalScale() - const neighborAlpha = entryNeighborAlpha() - for (const e of S.data.flat) { - const sc = worldToScreen(e.x, e.y, S.cam, S.w, S.h) - const appR = e.r * S.cam.scale - const margin = 200 + appR - if (sc.x < -margin || sc.x > S.w + margin) continue - if (sc.y < -margin || sc.y > S.h + margin) continue - if (appR < LOD.MIN_VISIBLE) continue - - // The focal card draws at rest-state appR; the visual scale transform - // (centered on the card itself) is what makes it grow/shrink without - // re-layouting card geometry. Neighbors fade in alpha; selected node - // stays at alpha 1 throughout so the user's eye locks on it. - const isFocal = e.isSelected - if (!isFocal && neighborAlpha <= 0.005) continue - - nodeCtx!.save() - if (isFocal && Math.abs(focalScale - 1) > 0.001) { - nodeCtx!.translate(sc.x, sc.y) - nodeCtx!.scale(focalScale, focalScale) - nodeCtx!.translate(-sc.x, -sc.y) - } - if (!isFocal) { - nodeCtx!.globalAlpha = neighborAlpha - } - if (appR < LOD.GLYPH_MIN) { - drawDot(nodeCtx!, sc, e.color, 1) - } else { - drawLeafGlyph(nodeCtx!, e, sc, appR, { - selected: e.isSelected, - hover: S.hover === e, - dim: 1, - t: S.t, - }) - } - nodeCtx!.restore() - } - } - - function frame() { - S.t = performance.now() - S.t0 - drawBackground() - drawEdges() - drawNodes() - // Exit-on-zoom-out: once the user pulls the camera back past the - // threshold, trigger a clean 2D→3D handoff. The initial cam.scale is - // always clamped above EXIT_ZOOM_THRESHOLD * 1.5 in the data-landed - // effect, so this can only fire from intentional user dolly. - if ( - !S.exitFired && - S.data && - S.cam.scale < EXIT_ZOOM_THRESHOLD && - S.onExitZoom - ) { - S.exitFired = true - S.onExitZoom() - } - S.rafId = requestAnimationFrame(frame) - } - S.rafId = requestAnimationFrame(frame) - - // ── input ── - function getMousePos(ev: MouseEvent): { x: number; y: number } { - const r = stage!.getBoundingClientRect() - return { x: ev.clientX - r.left, y: ev.clientY - r.top } - } - - function onMouseDown(ev: MouseEvent) { - const m = getMousePos(ev) - S.mouse.down = true - S.mouse.dragStart = { x: m.x, y: m.y, camX: S.cam.x, camY: S.cam.y } - } - function onMouseMove(ev: MouseEvent) { - const m = getMousePos(ev) - S.mouse.x = m.x - S.mouse.y = m.y - if (S.mouse.down && S.mouse.dragStart) { - const dx = m.x - S.mouse.dragStart.x - const dy = m.y - S.mouse.dragStart.y - S.cam.x = S.mouse.dragStart.camX - dx / S.cam.scale - S.cam.y = S.mouse.dragStart.camY - dy / S.cam.scale - } else { - const hit = hitTest(m.x, m.y) - S.hover = hit - stage!.style.cursor = hit ? "pointer" : "default" - } - } - function onMouseUp(ev: MouseEvent) { - const m = getMousePos(ev) - const wasDragging = - S.mouse.dragStart && - (Math.abs(m.x - S.mouse.dragStart.x) > 3 || - Math.abs(m.y - S.mouse.dragStart.y) > 3) - S.mouse.down = false - S.mouse.dragStart = null - if (!wasDragging) { - const hit = hitTest(m.x, m.y) - if (hit && !hit.isSelected && S.onNeighborClick) { - S.onNeighborClick(hit) - } - } - } - function onWheel(ev: WheelEvent) { - ev.preventDefault() - const m = getMousePos(ev) - // zoom toward the cursor — keeps the world point under the cursor fixed - const worldBefore = screenToWorld(m.x, m.y, S.cam, S.w, S.h) - const factor = Math.exp(-ev.deltaY * 0.0015) - S.cam.scale = Math.max(0.02, Math.min(20, S.cam.scale * factor)) - const worldAfter = screenToWorld(m.x, m.y, S.cam, S.w, S.h) - S.cam.x += worldBefore.x - worldAfter.x - S.cam.y += worldBefore.y - worldAfter.y - } - - stage.addEventListener("mousedown", onMouseDown) - window.addEventListener("mousemove", onMouseMove) - window.addEventListener("mouseup", onMouseUp) - stage.addEventListener("wheel", onWheel, { passive: false }) - - return () => { - cancelAnimationFrame(S.rafId) - window.removeEventListener("resize", resize) - stage.removeEventListener("mousedown", onMouseDown) - window.removeEventListener("mousemove", onMouseMove) - window.removeEventListener("mouseup", onMouseUp) - stage.removeEventListener("wheel", onWheel) - } - }, [hitTest]) - - const breadcrumb = useMemo(() => { - if (!data) return "" - return data.selected.name - }, [data]) - - return ( -
- - - - -
- CASE - - {breadcrumb} - - {data && ( - - · {data.flat.length - 1} connected - - )} -
- - - - {loadError && ( -
- {loadError} -
- )} - - {!data && !loadError && ( -
- loading… -
- )} -
- ) -} diff --git a/src/components/case-view/constants.ts b/src/components/case-view/constants.ts deleted file mode 100644 index 55cf1ca7..00000000 --- a/src/components/case-view/constants.ts +++ /dev/null @@ -1,69 +0,0 @@ -// LOD thresholds — measured in apparent screen-px of an entity's radius -// (entity.r * cam.scale). Same primitive everywhere, threshold decides which -// rendering variant runs. Ported from graph-viz/src/components/SignalCanvasPage. -export const LOD = { - MIN_VISIBLE: 2, // below this: skip entirely - GLYPH_MIN: 6, // below this: single dot - LABEL_VISIBLE: 12, // when leaf label appears - CARD_VISIBLE: 16, // when the inline content card replaces the circle - LEAF_DETAIL: 30, // when type/region subtitle appears - LEAF_DEEP: 60, // (legacy) old offset sidecar threshold -} - -export const C = { - bg0: "#05080c", - bg1: "#0a1016", - ink: "#d7e6ea", - inkDim: "#7a8e96", - inkFaint: "#3d4a52", - accent: "#4ae0d2", - accentLine: "rgba(74, 224, 210, 0.55)", - accentSoft: "rgba(74, 224, 210, 0.15)", - warm: "#f5b65a", - selected: "#ffd11a", - panel: "rgba(10, 16, 22, 0.92)", - panelBorder: "rgba(120, 200, 220, 0.28)", -} - -export const FONT_SANS = '"Space Grotesk", system-ui, sans-serif' -export const FONT_MONO = '"JetBrains Mono", ui-monospace, monospace' - -// Per-type hue mapping. Fall back to accent if a type isn't listed. -export const TYPE_HUES: Record = { - Person: "#7aa8df", - Organization: "#a78bfa", - Location: "#6ad3a4", - Station: "#f5b65a", - Weapon: "#f472b6", - Item: "#5cc9d8", - Transport: "#f59e0b", - Creature: "#fb7185", - Episode: "#4ae0d2", - Chapter: "#4ae0d2", - Clip: "#4ae0d2", - Topic: "#a78bfa", - Tweet: "#5cc9d8", -} - -// Per-type visual radius (world units). Bigger means more prominent. Selected -// gets multiplied by SELECTED_SCALE in the layout pass. Sized so the inline -// content card (drawNodeCard) has room at rest scale without neighbors -// colliding on the ring. -export const KIND_RADIUS: Record = { - Person: 30, - Organization: 34, - Location: 32, - Station: 28, - Weapon: 26, - Item: 26, - Transport: 28, - Creature: 28, - Episode: 34, - Chapter: 30, - Clip: 26, - Topic: 32, - Tweet: 26, -} - -export const DEFAULT_KIND_RADIUS = 28 -export const SELECTED_SCALE = 1.35 diff --git a/src/components/case-view/draw.ts b/src/components/case-view/draw.ts deleted file mode 100644 index 196dc1e0..00000000 --- a/src/components/case-view/draw.ts +++ /dev/null @@ -1,517 +0,0 @@ -import type { SigEntity } from "./types" -import { C, FONT_MONO, LOD } from "./constants" -import { hexToRGB } from "./camera" -import { - pickString, - DISPLAY_KEY_FALLBACKS, - resolveNodeThumbnail, -} from "@/lib/node-display" - -// Module-level image cache. Each URL maps to an HTMLImageElement that may or -// may not be loaded yet — `complete && naturalWidth > 0` is the readiness -// check. The render loop runs every frame, so an image that arrives between -// frames will simply appear on the next one (no manual invalidation needed). -const imageCache = new Map() - -function getCachedImage(url: string | undefined): HTMLImageElement | null { - if (!url) return null - let img = imageCache.get(url) - if (!img) { - img = new Image() - // Intentionally NOT setting crossOrigin — most backends don't send CORS - // headers and we'd rather paint the image into a tainted canvas than - // fail to load it. We never need to read pixels back. - img.src = url - imageCache.set(url, img) - } - if (img.complete && img.naturalWidth > 0) return img - return null -} - -export function clear(ctx: CanvasRenderingContext2D, w: number, h: number) { - ctx.clearRect(0, 0, w, h) -} - -function roundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number, - r: number, -) { - ctx.beginPath() - ctx.moveTo(x + r, y) - ctx.lineTo(x + w - r, y) - ctx.quadraticCurveTo(x + w, y, x + w, y + r) - ctx.lineTo(x + w, y + h - r) - ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) - ctx.lineTo(x + r, y + h) - ctx.quadraticCurveTo(x, y + h, x, y + h - r) - ctx.lineTo(x, y + r) - ctx.quadraticCurveTo(x, y, x + r, y) -} - -export function drawDot( - ctx: CanvasRenderingContext2D, - sc: { x: number; y: number }, - color: string, - dim: number, -) { - ctx.fillStyle = `rgba(${hexToRGB(color)}, ${0.6 * dim})` - ctx.beginPath() - ctx.arc(sc.x, sc.y, 2.5, 0, Math.PI * 2) - ctx.fill() -} - -interface LeafOpts { - selected: boolean - hover: boolean - dim: number - t: number -} - -export function drawLeafGlyph( - ctx: CanvasRenderingContext2D, - e: SigEntity, - sc: { x: number; y: number }, - appR: number, - opts: LeafOpts, -) { - const { selected, hover, dim, t } = opts - - // High-LOD: render a Peaky Blinders-style content card centered on the - // node position instead of a circle. The card carries the same chrome - // (selection ring, hover glow) so transitions between LODs read as the - // same primitive scaling up. - if (appR > LOD.CARD_VISIBLE) { - drawNodeCard(ctx, e, sc, appR, opts) - return - } - - const baseColor = selected ? C.selected : e.color - const rgb = hexToRGB(baseColor) - - const pulse = selected ? 0.55 + 0.45 * Math.sin(t * 0.003) : 0 - const ringR = appR + 6 + pulse * 5 - - if (pulse > 0 || hover || selected) { - const g = ctx.createRadialGradient(sc.x, sc.y, 0, sc.x, sc.y, ringR * 2) - g.addColorStop(0, `rgba(${rgb}, ${0.28 * (0.5 + pulse) * dim})`) - g.addColorStop(1, `rgba(${rgb}, 0)`) - ctx.fillStyle = g - ctx.beginPath() - ctx.arc(sc.x, sc.y, ringR * 2, 0, Math.PI * 2) - ctx.fill() - } - - ctx.strokeStyle = `rgba(${rgb}, ${0.5 * dim})` - ctx.lineWidth = selected ? 2 : 1 - ctx.beginPath() - ctx.arc(sc.x, sc.y, appR + 4, 0, Math.PI * 2) - ctx.stroke() - - ctx.save() - ctx.translate(sc.x, sc.y) - ctx.fillStyle = `rgba(10, 16, 22, 0.95)` - ctx.strokeStyle = `rgba(${rgb}, ${0.95 * dim})` - ctx.lineWidth = selected ? 1.8 : 1.2 - ctx.beginPath() - ctx.arc(0, 0, appR, 0, Math.PI * 2) - ctx.fill() - ctx.stroke() - if (selected) { - ctx.fillStyle = `rgba(${rgb}, ${0.9 * dim})` - ctx.beginPath() - ctx.arc(0, 0, 2.5, 0, Math.PI * 2) - ctx.fill() - } - ctx.restore() - - const showLabel = appR > LOD.LABEL_VISIBLE || hover || selected - if (showLabel) { - const labelY = sc.y + appR + 14 - ctx.textAlign = "center" - ctx.textBaseline = "top" - ctx.fillStyle = `rgba(215, 230, 234, ${dim * (selected ? 1 : 0.85)})` - ctx.font = `500 11px ${FONT_MONO}` - const text = e.name.length > 32 ? e.name.slice(0, 32) + "…" : e.name - ctx.fillText(text, sc.x, labelY) - if (appR > LOD.LEAF_DETAIL) { - ctx.fillStyle = `rgba(120, 180, 190, ${dim * 0.75})` - ctx.font = `10px ${FONT_MONO}` - ctx.fillText(e.kind.toUpperCase(), sc.x, labelY + 14) - } - } -} - -const CARD_INTERNAL_KEYS = new Set([ - "ref_id", "pubkey", "owner_reference_id", "node_type", - "date_added_to_graph", "status", "project_id", - "name", "title", "description", "text", "transcript", "summary", - "media_url", "link", "image_url", "thumbnail", "source_link", - "mapX", "mapY", "mapZ", -]) - -function pickCardFields(e: SigEntity, max = 4): { label: string; value: string }[] { - const props = e.node.properties as Record | undefined - if (!props) return [] - const out: { label: string; value: string }[] = [] - for (const key of Object.keys(props)) { - if (CARD_INTERNAL_KEYS.has(key)) continue - const v = props[key] - if (typeof v === "string" && v.length > 0) { - out.push({ label: key, value: v.length > 48 ? v.slice(0, 48) + "…" : v }) - } else if (typeof v === "number") { - out.push({ label: key, value: String(v) }) - } - if (out.length >= max) break - } - // Always surface a description preview if present - if (out.length < max) { - const desc = pickString(props, "description") ?? pickString(props, "summary") - if (desc) out.push({ label: "about", value: desc.slice(0, 64) + (desc.length > 64 ? "…" : "") }) - } - // Fall back: if no fields surfaced, show the title-key name - if (out.length === 0) { - for (const key of DISPLAY_KEY_FALLBACKS) { - const v = props[key] - if (typeof v === "string" && v.length > 0) { - out.push({ label: key, value: v.length > 48 ? v.slice(0, 48) + "…" : v }) - break - } - } - } - return out -} - -// Inline content card centered on the node, scaled with appR. Selected gets -// a wider card with a hero image + description; neighbors get a compact one -// with a small thumbnail (or letter avatar when no image is available). -function drawNodeCard( - ctx: CanvasRenderingContext2D, - e: SigEntity, - sc: { x: number; y: number }, - appR: number, - opts: LeafOpts, -) { - const { selected, hover, dim, t } = opts - const accent = selected ? C.selected : e.color - const rgb = hexToRGB(accent) - - const maxFields = selected ? 5 : 3 - const fields = pickCardFields(e, maxFields) - const description = selected ? pickDescription(e) : null - const thumbnailUrl = resolveNodeThumbnail(e.node) - const thumbnail = getCachedImage(thumbnailUrl) - - const w = selected - ? Math.max(260, Math.min(appR * 7.5, 440)) - : Math.max(150, Math.min(appR * 5.6, 320)) - const lineH = Math.max(13, Math.min(appR * 0.45, 16)) - const headerH = Math.max(46, Math.min(appR * 1.6, 60)) - const heroH = selected ? Math.max(110, Math.min(appR * 3.2, 160)) : 0 - const descLines = description - ? wrapText(ctx, description, w - 20, `${Math.max(11, Math.min(appR * 0.42, 12))}px ${FONT_MONO}`, 4) - : [] - const descH = descLines.length > 0 ? descLines.length * (lineH - 1) + 8 : 0 - const bodyH = Math.max(fields.length * lineH + 14, 12) + heroH + descH - const h = headerH + bodyH - const x = sc.x - w / 2 - const y = sc.y - h / 2 - - const pulse = selected ? 0.55 + 0.45 * Math.sin(t * 0.003) : 0 - if (pulse > 0 || hover || selected) { - const gradR = Math.max(w, h) * 0.9 - const g = ctx.createRadialGradient(sc.x, sc.y, 0, sc.x, sc.y, gradR) - g.addColorStop(0, `rgba(${rgb}, ${0.22 * (0.5 + pulse) * dim})`) - g.addColorStop(1, `rgba(${rgb}, 0)`) - ctx.fillStyle = g - ctx.beginPath() - ctx.arc(sc.x, sc.y, gradR, 0, Math.PI * 2) - ctx.fill() - } - - ctx.save() - ctx.fillStyle = `rgba(10, 16, 22, ${0.94 * dim})` - ctx.strokeStyle = `rgba(${rgb}, ${(selected ? 0.95 : hover ? 0.7 : 0.55) * dim})` - ctx.lineWidth = selected ? 2 : hover ? 1.4 : 1 - roundRect(ctx, x, y, w, h, 6) - ctx.fill() - ctx.stroke() - ctx.restore() - - const pad = 10 - const pillH = Math.max(16, Math.min(appR * 0.7, 20)) - const pillY = y + 8 - const kindText = e.kind.toUpperCase() - ctx.font = `600 ${Math.max(9, Math.min(appR * 0.4, 11))}px ${FONT_MONO}` - const pillW = ctx.measureText(kindText).width + 14 - ctx.fillStyle = `rgba(${rgb}, ${0.18 * dim})` - ctx.strokeStyle = `rgba(${rgb}, ${0.55 * dim})` - ctx.lineWidth = 1 - roundRect(ctx, x + pad, pillY, pillW, pillH, pillH / 2) - ctx.fill() - ctx.stroke() - ctx.fillStyle = `rgba(${rgb}, ${0.95 * dim})` - ctx.textAlign = "left" - ctx.textBaseline = "middle" - ctx.fillText(kindText, x + pad + 7, pillY + pillH / 2 + 0.5) - - const avatarR = Math.max(10, Math.min(appR * 0.45, 14)) - const avatarCX = x + w - pad - avatarR - const avatarCY = pillY + pillH / 2 - if (thumbnail && !selected) { - ctx.save() - ctx.beginPath() - ctx.arc(avatarCX, avatarCY, avatarR, 0, Math.PI * 2) - ctx.clip() - drawImageCover(ctx, thumbnail, avatarCX - avatarR, avatarCY - avatarR, avatarR * 2, avatarR * 2) - ctx.restore() - ctx.beginPath() - ctx.arc(avatarCX, avatarCY, avatarR, 0, Math.PI * 2) - ctx.strokeStyle = `rgba(${rgb}, ${0.7 * dim})` - ctx.lineWidth = 1 - ctx.stroke() - } else { - ctx.beginPath() - ctx.arc(avatarCX, avatarCY, avatarR, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${rgb}, ${0.22 * dim})` - ctx.fill() - ctx.strokeStyle = `rgba(${rgb}, ${0.7 * dim})` - ctx.lineWidth = 1 - ctx.stroke() - const initial = (e.name || "?").trim().charAt(0).toUpperCase() - ctx.fillStyle = `rgba(${rgb}, ${0.95 * dim})` - ctx.font = `700 ${Math.max(10, Math.min(appR * 0.45, 13))}px ${FONT_MONO}` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText(initial, avatarCX, avatarCY + 0.5) - } - - const titleY = pillY + pillH + 4 - ctx.fillStyle = `rgba(235, 245, 248, ${(selected ? 1 : 0.95) * dim})` - const titleSize = selected - ? Math.max(15, Math.min(appR * 0.7, 19)) - : Math.max(12, Math.min(appR * 0.55, 15)) - ctx.font = `600 ${titleSize}px ${FONT_MONO}` - ctx.textAlign = "left" - ctx.textBaseline = "top" - const titleMaxW = w - pad * 2 - ctx.fillText(truncateToWidth(ctx, e.name, titleMaxW), x + pad, titleY) - - ctx.strokeStyle = `rgba(${rgb}, ${0.18 * dim})` - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(x + pad, y + headerH) - ctx.lineTo(x + w - pad, y + headerH) - ctx.stroke() - - let cy = y + headerH + 6 - - if (selected && heroH > 0) { - const heroX = x + pad - const heroY = cy - const heroW = w - pad * 2 - ctx.save() - roundRect(ctx, heroX, heroY, heroW, heroH, 4) - ctx.clip() - if (thumbnail) { - drawImageCover(ctx, thumbnail, heroX, heroY, heroW, heroH) - } else { - const g = ctx.createLinearGradient(heroX, heroY, heroX, heroY + heroH) - g.addColorStop(0, `rgba(${rgb}, ${0.22 * dim})`) - g.addColorStop(1, `rgba(${rgb}, ${0.06 * dim})`) - ctx.fillStyle = g - ctx.fillRect(heroX, heroY, heroW, heroH) - ctx.fillStyle = `rgba(${rgb}, ${0.55 * dim})` - ctx.font = `700 ${Math.min(heroH * 0.55, 64)}px ${FONT_MONO}` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - const initial = (e.name || "?").trim().charAt(0).toUpperCase() - ctx.fillText(initial, heroX + heroW / 2, heroY + heroH / 2) - } - ctx.restore() - ctx.strokeStyle = `rgba(${rgb}, ${0.35 * dim})` - ctx.lineWidth = 1 - roundRect(ctx, heroX, heroY, heroW, heroH, 4) - ctx.stroke() - cy += heroH + 8 - } - - if (descLines.length > 0) { - ctx.fillStyle = `rgba(200, 215, 220, ${0.92 * dim})` - ctx.font = `${Math.max(11, Math.min(appR * 0.42, 12))}px ${FONT_MONO}` - ctx.textAlign = "left" - ctx.textBaseline = "top" - for (const line of descLines) { - ctx.fillText(line, x + pad, cy) - cy += lineH - 1 - } - cy += 6 - } - - const labelW = Math.min(80, w * 0.32) - const valueX = x + pad + labelW - const valueMaxW = w - pad - labelW - pad - ctx.font = `${Math.max(9, Math.min(appR * 0.42, 11))}px ${FONT_MONO}` - for (const f of fields) { - ctx.fillStyle = `rgba(120, 180, 190, ${0.72 * dim})` - ctx.textAlign = "left" - ctx.fillText(f.label, x + pad, cy) - ctx.fillStyle = `rgba(215, 230, 234, ${0.95 * dim})` - ctx.fillText(truncateToWidth(ctx, f.value, valueMaxW), valueX, cy) - cy += lineH - } - - if (selected) { - ctx.fillStyle = `rgba(${rgb}, ${0.9 * dim})` - ctx.beginPath() - ctx.arc(x + 6, y + 6, 2.5, 0, Math.PI * 2) - ctx.fill() - } -} - -// Cover-crop: scale the image so it fills the destination rect, cropping -// whichever axis overflows (the CSS `object-fit: cover` equivalent). -function drawImageCover( - ctx: CanvasRenderingContext2D, - img: HTMLImageElement, - dx: number, - dy: number, - dw: number, - dh: number, -) { - const iw = img.naturalWidth - const ih = img.naturalHeight - if (iw === 0 || ih === 0) return - const scale = Math.max(dw / iw, dh / ih) - const sw = dw / scale - const sh = dh / scale - const sx = (iw - sw) / 2 - const sy = (ih - sh) / 2 - ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) -} - -function pickDescription(e: SigEntity): string | null { - const props = e.node.properties as Record | undefined - if (!props) return null - return ( - pickString(props, "description") ?? - pickString(props, "summary") ?? - pickString(props, "text") ?? - pickString(props, "bio") ?? - null - ) -} - -// Greedy word-wrap that honors the canvas's current font. Returns up to -// `maxLines` lines, ellipsising the last one if the source overruns. Sets -// the font on `ctx` because measureText reads from current state. -function wrapText( - ctx: CanvasRenderingContext2D, - text: string, - maxW: number, - font: string, - maxLines: number, -): string[] { - const prev = ctx.font - ctx.font = font - const words = text.replace(/\s+/g, " ").trim().split(" ") - const lines: string[] = [] - let line = "" - for (const word of words) { - const candidate = line ? line + " " + word : word - if (ctx.measureText(candidate).width <= maxW) { - line = candidate - } else { - if (line) lines.push(line) - if (lines.length >= maxLines) break - line = word - } - } - if (line && lines.length < maxLines) lines.push(line) - // If we ran out of room, ellipsise the last line. - if (lines.length === maxLines) { - const remainingIdx = words.indexOf(line.split(" ").pop() || "") - if (remainingIdx !== -1 && remainingIdx < words.length - 1) { - let truncated = lines[lines.length - 1] - while (truncated.length > 0 && ctx.measureText(truncated + "…").width > maxW) { - truncated = truncated.slice(0, -1) - } - lines[lines.length - 1] = truncated + "…" - } - } - ctx.font = prev - return lines -} - -function truncateToWidth( - ctx: CanvasRenderingContext2D, - text: string, - maxW: number, -): string { - if (ctx.measureText(text).width <= maxW) return text - const ellipsis = "…" - let lo = 0 - let hi = text.length - while (lo < hi) { - const mid = (lo + hi + 1) >> 1 - if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxW) lo = mid - else hi = mid - 1 - } - return text.slice(0, lo) + ellipsis -} - -// Card bounding box in screen space — kept in sync with drawNodeCard so -// hit-tests can use the same rectangle the user actually clicked. -export function getNodeCardBounds( - e: SigEntity, - sc: { x: number; y: number }, - appR: number, -): { x: number; y: number; w: number; h: number } { - const selected = e.isSelected - const fieldCount = pickCardFields(e, selected ? 5 : 3).length - const w = selected - ? Math.max(260, Math.min(appR * 7.5, 440)) - : Math.max(150, Math.min(appR * 5.6, 320)) - const lineH = Math.max(13, Math.min(appR * 0.45, 16)) - const headerH = Math.max(46, Math.min(appR * 1.6, 60)) - const heroH = selected ? Math.max(110, Math.min(appR * 3.2, 160)) : 0 - const hasDesc = selected && pickDescription(e) !== null - const descH = hasDesc ? 4 * (lineH - 1) + 8 : 0 - const bodyH = Math.max(fieldCount * lineH + 14, 12) + heroH + descH - const h = headerH + bodyH - return { x: sc.x - w / 2, y: sc.y - h / 2, w, h } -} - -export function drawEdge( - ctx: CanvasRenderingContext2D, - from: { x: number; y: number }, - to: { x: number; y: number }, - dim: number, - label?: string, -) { - ctx.strokeStyle = `rgba(120, 200, 220, ${0.35 * dim})` - ctx.lineWidth = 1 - ctx.setLineDash([4, 4]) - ctx.beginPath() - ctx.moveTo(from.x, from.y) - ctx.lineTo(to.x, to.y) - ctx.stroke() - ctx.setLineDash([]) - - if (label) { - const mx = (from.x + to.x) / 2 - const my = (from.y + to.y) / 2 - ctx.fillStyle = `rgba(10, 16, 22, ${0.85 * dim})` - ctx.font = `9px ${FONT_MONO}` - const w = ctx.measureText(label).width + 10 - roundRect(ctx, mx - w / 2, my - 7, w, 14, 2) - ctx.fill() - ctx.fillStyle = `rgba(120, 200, 220, ${0.9 * dim})` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText(label, mx, my) - } -} diff --git a/src/components/case-view/index.ts b/src/components/case-view/index.ts deleted file mode 100644 index 7e4f1d11..00000000 --- a/src/components/case-view/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CaseView } from "./case-view" -export type { CaseViewProps } from "./case-view" diff --git a/src/components/case-view/layout.ts b/src/components/case-view/layout.ts deleted file mode 100644 index 110c7051..00000000 --- a/src/components/case-view/layout.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { SigEntity } from "./types" - -// Radial 1-hop layout. Selected at origin; neighbors evenly spaced on -// concentric rings around it. For low-degree centers everyone fits on a -// single ring; for high-degree centers we add rings so each ring stays -// readable instead of becoming a thin, perimeter-only wreath that forces -// the user to zoom out past the LOD threshold for content cards. -// -// Ring radius is derived from the packing constraint per ring: -// 2πR ≥ 2 · sumR + N · gap → R ≥ (sumR + N·gap/2) / π -// Stable ordering by id so re-layouts (e.g. after a click switches the -// center) don't jitter. -const RING_GAP = 140 - -// Soft cap on neighbors per ring before we add another concentric ring. -// 12 keeps angular spacing ≥ 30° on each ring, which leaves plenty of room -// for content cards and dashed connector edges between them. -const MAX_PER_RING = 12 - -export function layoutRing(selected: SigEntity, neighbors: SigEntity[]): void { - selected.x = 0 - selected.y = 0 - - const N = neighbors.length - if (N === 0) return - - if (N === 1) { - const c = neighbors[0] - c.x = selected.r + c.r + RING_GAP - c.y = 0 - return - } - - let maxR = 0 - for (const c of neighbors) { - if (c.r > maxR) maxR = c.r - } - - // Stable ordering so swapping the center node doesn't reshuffle siblings. - const ordered = neighbors.slice().sort((a, b) => a.id.localeCompare(b.id)) - - const numRings = Math.max(1, Math.ceil(N / MAX_PER_RING)) - const perRing = Math.ceil(N / numRings) - - // Inner ring radius: clears the selected glyph plus a neighbor + gap. For - // a single-ring layout we also honor the per-ring packing constraint so - // dense low-N rings (e.g. N=12 small types) still don't collide. - const innerPack = (perRing * (2 * maxR + RING_GAP)) / (2 * Math.PI) - const innerR = Math.max(selected.r + maxR + RING_GAP, innerPack) - const ringSpacing = 2 * maxR + RING_GAP - - for (let k = 0; k < numRings; k++) { - const start = k * perRing - const end = Math.min(start + perRing, N) - const count = end - start - if (count === 0) continue - const ringR = innerR + k * ringSpacing - // Half-step rotation on alternate rings so neighbors don't line up - // radially with their inner/outer counterparts — keeps connector edges - // from running on top of each other. - const phase = -Math.PI / 2 + (k % 2 === 0 ? 0 : Math.PI / count) - for (let j = start; j < end; j++) { - const localI = j - start - const angle = (localI / count) * Math.PI * 2 + phase - const c = ordered[j] - c.x = Math.cos(angle) * ringR - c.y = Math.sin(angle) * ringR - } - } -} - -export function computeWorldBBox(entities: SigEntity[]) { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity - for (const e of entities) { - if (e.x - e.r < minX) minX = e.x - e.r - if (e.y - e.r < minY) minY = e.y - e.r - if (e.x + e.r > maxX) maxX = e.x + e.r - if (e.y + e.r > maxY) maxY = e.y + e.r - } - if (!isFinite(minX)) return { minX: -200, minY: -200, maxX: 200, maxY: 200 } - return { minX, minY, maxX, maxY } -} diff --git a/src/components/case-view/types.ts b/src/components/case-view/types.ts deleted file mode 100644 index 912c92ad..00000000 --- a/src/components/case-view/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { GraphNode } from "@/lib/graph-api" - -export type Status = "ACTIVE" | "WARN" | "IDLE" - -export interface SigEntity { - id: string - name: string - kind: string - isSelected: boolean - x: number - y: number - r: number - color: string - node: GraphNode -} - -export interface SigEdge { - id: string - fromId: string - toId: string - from: SigEntity - to: SigEntity - label?: string -} - -export interface SigDataset { - selectedId: string - selected: SigEntity - byId: Map - flat: SigEntity[] - edges: SigEdge[] - worldBBox: { minX: number; minY: number; maxX: number; maxY: number } -} diff --git a/src/components/layout/attachable-embeds.tsx b/src/components/layout/attachable-embeds.tsx index 3e1f2101..c587127d 100644 --- a/src/components/layout/attachable-embeds.tsx +++ b/src/components/layout/attachable-embeds.tsx @@ -202,35 +202,43 @@ function MediaGrid({ images, onOpen }: { images: GraphNode[]; onOpen: (index: nu style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} > {shown.map((im, i) => ( - + {i === 0 && n > 1 && ( - + {n} images )} {i === cap - 1 && extra > 0 && ( - + +{extra} )} - {nodeBoost(im) > 0 && ( - - {nodeBoost(im)} - - )} - + {/* Current boost amount + trigger — always visible, FB/X-style. */} +
+ +
+ ))} ) @@ -351,9 +359,9 @@ function Lightbox({ )} -
+
{resolveNodeTitle(im, schemas)} - +
@@ -426,7 +434,17 @@ function ImageThumb({ } /* ── Boost an image — shows current amount and lets anyone boost ─────────── */ -function ImageBoost({ node }: { node: GraphNode }) { +// variant="default" → labelled button (lightbox caption). +// variant="compact" → glassy pill overlay (image tile / lightbox over the art). +function ImageBoost({ + node, + variant = "default", + className, +}: { + node: GraphNode + variant?: "default" | "compact" + className?: string +}) { const p = node.properties ?? {} const ownerReference = typeof p.owner_reference_id === "string" ? p.owner_reference_id : undefined const pubkey = typeof p.pubkey === "string" ? p.pubkey : undefined @@ -443,10 +461,21 @@ function ImageBoost({ node }: { node: GraphNode }) { pubkey={pubkey} routeHint={routeHint} boostCount={boost} + variant={variant} + className={className} /> ) } + // No owner to credit — passive display only. Match the active pill's shape so + // the grid stays visually consistent whether or not a tile is boostable. if (boost > 0) { + if (variant === "compact") { + return ( + + {boost} + + ) + } return ( {boost} bullets diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 8c7b8f48..da857973 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -15,6 +15,7 @@ import { } from "@/graph-viz-kit" import type { Graph, ViewState } from "@/graph-viz-kit" import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/graph-api" +import { getAttachables } from "@/lib/graph-api" import { useGraphStore } from "@/stores/graph-store" import { useAppStore } from "@/stores/app-store" import type { SchemaNode } from "@/app/ontology/page" @@ -584,6 +585,34 @@ function CaseBoardMorphLayer({ const selectedNode = nodes.find((n) => n.ref_id === selectedRefId) ?? null // Viewport height drives the px→world conversion for the card layout below. const viewportHeight = useThree((s) => s.size.height) + + // The focal node's attached images. Attachables come from a separate + // server-side `edge_props` query (getAttachables), NOT the regular 1-hop + // neighbourhood the board lays out — so without this fetch they'd never + // appear on the board. Embedded as a strip inside the focal card. Keyed by + // refId so a previous node's images never flash while a new fetch is in + // flight (and so we don't setState synchronously inside the effect). + const [imagesResult, setImagesResult] = useState<{ refId: string; images: ApiNode[] }>( + () => ({ refId: "", images: [] }), + ) + useEffect(() => { + const controller = new AbortController() + getAttachables(selectedRefId, controller.signal) + .then((data) => { + if (controller.signal.aborted) return + setImagesResult({ + refId: selectedRefId, + images: (data.nodes ?? []).filter( + (n) => n.node_type === "Image" && n.ref_id !== selectedRefId, + ), + }) + }) + .catch(() => { + if (!controller.signal.aborted) setImagesResult({ refId: selectedRefId, images: [] }) + }) + return () => controller.abort() + }, [selectedRefId]) + const attachedImages = imagesResult.refId === selectedRefId ? imagesResult.images : [] const focalWorld = useMemo<[number, number, number] | null>(() => { if (selectedIdx === undefined) return null const p = graph.nodes[selectedIdx]?.position @@ -760,6 +789,7 @@ function CaseBoardMorphLayer({ morphProgress={morphProgress} portal={cardPortalRef} registerEl={(el) => registerCard(selectedRefId, el)} + attachedImages={attachedImages} /> )} {itemTargets.map(({ item, origin, target }) => From c151a660282c5d51f0e3a911bfff8801215bc5cc Mon Sep 17 00:00:00 2001 From: Rassl Date: Thu, 11 Jun 2026 17:25:53 +0400 Subject: [PATCH 16/19] feat: update bullet icon --- src/components/ui/bullet-icon.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/ui/bullet-icon.tsx b/src/components/ui/bullet-icon.tsx index 87a51a74..24024443 100644 --- a/src/components/ui/bullet-icon.tsx +++ b/src/components/ui/bullet-icon.tsx @@ -23,12 +23,14 @@ export function BulletIcon({ aria-hidden="true" {...rest} > - - - - - - + {/* bullet tip, occluded by the ingot below y=10 */} + + {/* casing, visible below the ingot */} + + {/* ingot: front, top and side faces */} + + + ) } From 0b77310da91089ec1670523f9f6a740e9a51cb75 Mon Sep 17 00:00:00 2001 From: Rassl Date: Sun, 14 Jun 2026 00:27:25 +0400 Subject: [PATCH 17/19] feat: metro stations view enhancment --- src/components/layout/node-preview-panel.tsx | 16 +- src/components/universe/graph-canvas.tsx | 200 +++- src/components/universe/station-hud-scene.tsx | 893 ++++++++++++++++++ src/data/metro.ts | 184 +++- src/data/station-timeline.ts | 152 +++ src/graph-viz-kit/GraphView.tsx | 10 +- 6 files changed, 1428 insertions(+), 27 deletions(-) create mode 100644 src/components/universe/station-hud-scene.tsx create mode 100644 src/data/station-timeline.ts diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index b7473c33..8e5ba8db 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -36,15 +36,17 @@ import { metroSeries } from "@/data/metro" const DEEP_RESEARCH_NODE_TYPES = ["Topic"] -// Stations live only in the local fixture — the backend collapses fixture's -// transfer-platform variants (komsomolskaya_k / _r) into one row, so we can't -// map fixture station ref_ids 1:1 to backend UUIDs without losing the dual- -// platform schematic. Short-circuit clicks on station nodes so they render -// from the fixture instead of 500-ing. All other fixture nodes (Persons, -// Orgs, etc.) have backend UUIDs applied in metro.ts and hit the API normally. +// Most fixture stations now carry their backend UUID (STATION_BACKEND_REF_ID_MAP +// in metro.ts), so clicks resolve to the real DB record. The only exceptions are +// the dual-platform transfer twins (e.g. komsomolskaya_r): the backend collapses +// them into their ring node, so they keep a fixture slug for the schematic and +// have no 1:1 backend record. Short-circuit ONLY those — i.e. station ref_ids +// that are still slugs (not UUIDs) — so they render from the fixture instead of +// 500-ing, while the mapped stations hit the API normally. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const METRO_FIXTURE_STATION_REF_IDS = new Set( (metroSeries.nodes as { ref_id: string; node_type?: string }[]) - .filter((n) => n.node_type === "Station") + .filter((n) => n.node_type === "Station" && !UUID_RE.test(n.ref_id)) .map((n) => n.ref_id), ) diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index da857973..73b4ccac 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -43,6 +43,12 @@ import { readStationLines, type StationState, } from "./metro-overlay" +import { + StationHudScene, + StationZonePlate, + type SceneNeighbor, +} from "./station-hud-scene" +import type { EraId } from "@/data/station-timeline" // Max number of search hits that keep a text label at once. Beyond this the // view becomes an unreadable pile of overlapping labels; the rest of the hits @@ -92,6 +98,33 @@ const OVERVIEW_CAM: CamTarget = { lookX: 0, lookY: 0, lookZ: 0, } +// Camera pose for a selected metro Station — an angled tactical view instead +// of the straight-overhead subgraph pose, so the diegetic station HUD (radar +// rings on the ground, holo cards floating on beams) reads with depth like a +// game map. Preserves the user's current orbit azimuth. +const STATION_CAM_DIST = 19 +const STATION_CAM_ELEV = (38 * Math.PI) / 180 + +function computeStationCamTarget( + graph: Graph, + nodeId: number, + currentAzimuth: number, +): CamTarget { + const p = graph.nodes[nodeId].position + const horiz = STATION_CAM_DIST * Math.cos(STATION_CAM_ELEV) + const vert = STATION_CAM_DIST * Math.sin(STATION_CAM_ELEV) + return { + posX: p.x + Math.sin(currentAzimuth) * horiz, + posY: p.y + vert, + posZ: p.z + Math.cos(currentAzimuth) * horiz, + lookX: p.x, + // Aim a touch above the node so the floating cards sit comfortably in + // frame rather than crowding the top edge. + lookY: p.y + 2.2, + lookZ: p.z, + } +} + // Press-and-hold duration (ms) on the selected node to open the 2D case view. const CASE_VIEW_HOLD_MS = 600 @@ -1067,9 +1100,12 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // render — useful when pointing at a non-metro backend dataset. const metroEnabled = process.env.NEXT_PUBLIC_METRO_OVERLAY === "1" - // The metro overlay always renders from the local fixture so the - // schematic stays visible even when search replaces the graph store - // with results that don't include Station nodes. + // The metro overlay renders from the local fixture so the schematic stays + // visible even when search replaces the graph store with results that don't + // include Station nodes. Station node ref_ids are rewritten to their backend + // UUIDs (see STATION_BACKEND_REF_ID_MAP in metro.ts), so the fixture supplies + // the static map (positions + tunnels) while each station still resolves to + // its live DB record on click. const overlayNodes = metroEnabled ? (metroSeries.nodes as ApiNode[]) : [] const overlayEdges = metroEnabled ? (metroSeries.edges as ApiEdge[]) : [] @@ -1431,6 +1467,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas })), [boardItems, morphSelectedRefId], ) + const [hoveredCardNode, setHoveredCardNode] = useState(null) const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) // Metro overlay focus state — driven by hovering the lines (3D) or the @@ -1492,6 +1529,41 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas camAnim.current.progress = 0 }, []) + // Smooth orbital fly-to using camera-controls' own damped transition, which + // interpolates in SPHERICAL coordinates around the look-at point. Used for + // station selections: CameraSync's Cartesian lerp cuts a straight chord when + // the azimuth changes (the tunnel-axis re-orientation), which reads as a + // jump / unexpected rotation. Stations are fixed-position nodes, so the + // camera doesn't need CameraSync's lockstep with geometry inflation. + const defaultSmoothTimeRef = useRef(null) + const flyCamTo = useCallback((target: CamTarget) => { + // Park CameraSync so its in-flight lerp can't fight this transition. + camAnim.current.progress = 1 + const cam = cameraRef.current + if (!cam) return + // Lengthen the damping for the fly-in, then restore the original value + // (captured once) so user wheel/drag feel is untouched afterwards. Always + // restoring to the captured default keeps rapid successive clicks from + // permanently "locking in" the slow transition time. + if (defaultSmoothTimeRef.current === null) { + defaultSmoothTimeRef.current = cam.smoothTime + } + cam.smoothTime = 0.65 + const transition = cam.setLookAt( + target.posX, target.posY, target.posZ, + target.lookX, target.lookY, target.lookZ, + true, + ) + // setLookAt leaves theta un-normalized — after the user has orbited, the + // accumulated angle can differ from the destination by > π and the damped + // transition would swing the camera the long way around. Normalizing + // snaps both angles into the same revolution = shortest-path rotation. + cam.normalizeRotations() + void transition.finally(() => { + cam.smoothTime = defaultSmoothTimeRef.current! + }) + }, []) + // Reset view only on full data replacement (new search), not on appends // from sidebar-driven neighbor fetches — otherwise focusing the camera on // a clicked node would be undone every time a neighborhood arrives. @@ -1514,6 +1586,48 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas return effectiveNodeByRefId.get(refId) ?? nodeByRefId.get(refId) ?? null }, [viewState, indexMap, effectiveNodeByRefId, nodeByRefId]) + // Diegetic station HUD — active whenever a Station node is the current + // selection on the metro map (and the full-screen morph isn't covering the + // scene). Renders radar rings + floating holo cards in the 3D scene itself. + const hudSceneActive = + metroEnabled && !morphOpen && viewState.mode === "subgraph" && + selectedApiNode?.node_type === "Station" + + // Tunnel-linked station neighbors of the selected station, with their graph + // indices so the holo cards can both anchor at node positions and navigate + // on click. Non-station neighbors keep their regular GraphView labels. + const sceneNeighbors = useMemo(() => { + if (!hudSceneActive || !selectedApiNode) return [] + const out: SceneNeighbor[] = [] + const seen = new Set([selectedApiNode.ref_id]) + for (const e of effectiveEdges) { + let nb: string | null = null + if (e.source === selectedApiNode.ref_id) nb = e.target + else if (e.target === selectedApiNode.ref_id) nb = e.source + if (!nb || seen.has(nb)) continue + seen.add(nb) + const node = effectiveNodeByRefId.get(nb) + if (!node || node.node_type !== "Station") continue + const idx = refIdToIndex.get(nb) + if (idx === undefined) continue + out.push({ node, idx, edgeLabel: e.edge_type }) + } + return out + }, [hudSceneActive, selectedApiNode, effectiveEdges, effectiveNodeByRefId, refIdToIndex]) + + // Time-dial era for the station HUD. Owned here (not in the scene) so the + // zone plate — a separate DOM tree — follows the dial, and so every new + // station selection starts back at the present day. + const [hudEra, setHudEra] = useState("now") + + // The holo cards ARE the labels for these nodes — suppress GraphView's own. + const hudSuppressedLabelIds = useMemo | null>(() => { + if (!hudSceneActive || viewState.mode !== "subgraph") return null + const set = new Set([viewState.selectedNodeId]) + for (const n of sceneNeighbors) set.add(n.idx) + return set + }, [hudSceneActive, viewState, sceneNeighbors]) + // Opens the in-3D case board (morph + camera tilt + Html cards) on the // node the user has been zooming into. apparentRadius is unused now — // kept in the trigger's signature for back-compat, but the morph doesn't @@ -1530,15 +1644,15 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const handleCloseCaseBoard = useCallback(() => { useCaseBoardStore.getState().close() if (viewState.mode === "subgraph") { - setCamTarget( - computeCamTarget( - graph, - viewState.selectedNodeId, - cameraRef.current?.azimuthAngle ?? 0, - ), - ) + const isStation = metroEnabled && selectedApiNode?.node_type === "Station" + const azimuth = cameraRef.current?.azimuthAngle ?? 0 + if (isStation) { + flyCamTo(computeStationCamTarget(graph, viewState.selectedNodeId, azimuth)) + } else { + setCamTarget(computeCamTarget(graph, viewState.selectedNodeId, azimuth)) + } } - }, [viewState, graph, setCamTarget]) + }, [viewState, graph, setCamTarget, flyCamTo, metroEnabled, selectedApiNode]) const externalHoveredId = sidebarHoveredNode ? (refIdToIndex.get(sidebarHoveredNode.ref_id) ?? null) : null const externalSelectedId = sidebarSelectedNode ? (refIdToIndex.get(sidebarSelectedNode.ref_id) ?? null) : null @@ -1720,6 +1834,8 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas (nodeId: number) => { useGraphStore.getState().setSidebarSelectedNode(null) useGraphStore.getState().setHoveredNode(null) + // Every selection starts at the present day on the time dial. + setHudEra("now") const refId = indexMap.get(nodeId) if (refId && onNodeSelect) { const apiNode = nodeByRefId.get(refId) @@ -1793,7 +1909,49 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // anchor were moving in opposite directions. Capture the current // orbit azimuth so the final view preserves it rather than snapping // to a canonical orientation when the camera lands above the node. - setCamTarget(computeCamTarget(graph, nodeId, cameraRef.current?.azimuthAngle ?? 0)) + // Metro stations get the angled tactical pose (the diegetic HUD's + // rings + floating cards need depth); everything else keeps the + // overhead subgraph pose. + const clickedIsStation = + metroEnabled && + refId !== undefined && + effectiveNodeByRefId.get(refId)?.node_type === "Station" + let azimuth = cameraRef.current?.azimuthAngle ?? 0 + if (clickedIsStation && refId) { + // Orient the camera so the station's tunnel axis runs screen- + // horizontal: neighbor holo cards then spread left/right of the + // focal card instead of stacking behind it / on the ring center. + // Average the neighbor bearings as an AXIS (angle-doubling trick, so + // opposite directions reinforce instead of canceling), then pick the + // of the two facing azimuths closest to the user's current orbit. + const p0 = graph.nodes[nodeId].position + let s2 = 0 + let c2 = 0 + const seenNb = new Set([refId]) + for (const e of effectiveEdges) { + const nb = + e.source === refId ? e.target : e.target === refId ? e.source : null + if (!nb || seenNb.has(nb)) continue + seenNb.add(nb) + if (effectiveNodeByRefId.get(nb)?.node_type !== "Station") continue + const ni = refIdToIndex.get(nb) + const q = ni !== undefined ? graph.nodes[ni]?.position : undefined + if (!q) continue + const phi = Math.atan2(q.z - p0.z, q.x - p0.x) + s2 += Math.sin(2 * phi) + c2 += Math.cos(2 * phi) + } + if (s2 !== 0 || c2 !== 0) { + let aligned = -0.5 * Math.atan2(s2, c2) + if (Math.cos(aligned - azimuth) < 0) aligned += Math.PI + azimuth = aligned + } + } + if (clickedIsStation) { + flyCamTo(computeStationCamTarget(graph, nodeId, azimuth)) + } else { + setCamTarget(computeCamTarget(graph, nodeId, azimuth)) + } // Consume any pending search-pan: if results haven't landed yet, a // later payload would otherwise yank the camera off the node the @@ -1801,7 +1959,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // the search-pan effect from firing for it. lastPannedSearchTerm.current = searchTerm }, - [graph, indexMap, nodeByRefId, onNodeSelect, setCamTarget, searchTerm] + [graph, indexMap, nodeByRefId, effectiveNodeByRefId, effectiveEdges, refIdToIndex, metroEnabled, onNodeSelect, setCamTarget, flyCamTo, searchTerm] ) const handleReset = useCallback(() => { @@ -1900,11 +2058,23 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas onResetView={handleReset} suppressHover={cameraInteracting} mutedNodeIds={mutedNodeIds} + suppressLabelIds={hudSuppressedLabelIds} onGraphClick={() => { useGraphStore.getState().setSidebarSelectedNode(null) useGraphStore.getState().setHoveredNode(null) }} /> + {hudSceneActive && viewState.mode === "subgraph" && selectedApiNode && ( + + )} {debugMarkers && ( )} + {hudSceneActive && selectedApiNode && ( + + )} + {metroEnabled && ( diff --git a/src/components/universe/station-hud-scene.tsx b/src/components/universe/station-hud-scene.tsx new file mode 100644 index 00000000..d37432ea --- /dev/null +++ b/src/components/universe/station-hud-scene.tsx @@ -0,0 +1,893 @@ +"use client" + +import { useMemo, useRef } from "react" +import * as THREE from "three" +import { useFrame } from "@react-three/fiber" +import { Html, Line } from "@react-three/drei" +import type { Graph } from "@/graph-viz-kit" +import type { GraphNode as ApiNode } from "@/lib/graph-api" +import { pickString, resolveNodeThumbnail } from "@/lib/node-display" +import { + BLOCKING_STATES, + MAP_Y_OFFSET, + METRO_LINE_COLORS, + STATION_FILL, + STATION_GLOW, + STATION_STATE_LABEL, + readStationLines, + statusToState, +} from "./metro-overlay" +import { + ERAS, + stationTimeline, + type EraId, + type EraSnapshot, +} from "@/data/station-timeline" + +const TEAL = "#46e3d4" +const GOLD = "#f2b73f" +const INK = "#d9fbf6" +const INK_DIM = "rgba(150, 200, 195, 0.55)" + +const teal = (a: number) => `rgba(70, 227, 212, ${a})` +const gold = (a: number) => `rgba(242, 183, 63, ${a})` + +// Faction id (metro2087 data) → display name for the zone plate. +const FACTION_LABEL: Record = { + union: "HANSA RING", + central: "POLIS ALLIANCE", + commune: "RED COMMUNE", + iron: "IRON ORDER", + free: "FREE STATIONS", + swamp: "SWAMP ENCLAVE", +} + +function hashCode(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 + return Math.abs(h) +} + +function lineColorCss(line: string): string { + const c = METRO_LINE_COLORS[line] + if (!c) return "#9aa3ab" + return `rgb(${Math.round(c[0] * 255)}, ${Math.round(c[1] * 255)}, ${Math.round(c[2] * 255)})` +} + +function nodeName(node: ApiNode): string { + return ( + pickString(node.properties, "name") ?? + pickString(node.properties, "title") ?? + node.ref_id + ) +} + +// `image` / `images` are the metro fixture's lore overrides; the generic +// thumbnail resolver covers backend-shaped nodes. +function nodeImage(node: ApiNode): string | undefined { + const p = node.properties as Record | undefined + if (typeof p?.image === "string" && p.image) return p.image + if (Array.isArray(p?.images) && typeof p.images[0] === "string") return p.images[0] + return resolveNodeThumbnail(node) +} + +// State label without lore parentheticals ("Anomaly (creatures)" → "ANOMALY") +// so chips and the zone plate stay one crisp word. +function stateWord(label: string): string { + return label.split(" (")[0].toUpperCase() +} + +// Procedural hero art for nodes without an image — deterministic per ref_id +// so a station always renders the same hologram. Diagonal struts + a hue +// shifted glow + ghosted initials read as "no visual feed, schematic only". +function heroArtStyle(refId: string, accent: string): React.CSSProperties { + const h = hashCode(refId) + const hue = 165 + (h % 55) // teal → blue-green band + const angle = 100 + (h % 60) + return { + background: [ + `linear-gradient(180deg, rgba(2,12,14,0.1) 30%, rgba(2,12,14,0.85) 100%)`, + `repeating-linear-gradient(${angle}deg, ${teal(0.08)} 0 2px, transparent 2px 11px)`, + `repeating-linear-gradient(${angle - 90}deg, rgba(255,255,255,0.04) 0 1px, transparent 1px 17px)`, + `radial-gradient(ellipse at ${20 + (h % 50)}% 18%, hsla(${hue}, 75%, 42%, 0.55), transparent 62%)`, + `linear-gradient(180deg, #0a2b30, #051317)`, + ].join(", "), + boxShadow: `inset 0 0 32px rgba(0,0,0,0.55), inset 0 0 3px ${accent}33`, + } +} + +function ghostInitials(name: string): string { + return name + .split(/\s+/) + .slice(0, 2) + .map((w) => w[0] ?? "") + .join("") + .toUpperCase() +} + +// Corner-notched silhouette shared by the cards — the single strongest +// "game HUD" shape cue. +const NOTCH = (n: number) => + `polygon(0 0, calc(100% - ${n}px) 0, 100% ${n}px, 100% 100%, ${n}px 100%, 0 calc(100% - ${n}px))` + +// Diegetic station HUD — rendered INSIDE the 3D scene when a Station node is +// selected on the metro map. Radar rings spin on the map plane around the +// station, a gold light beam lifts a holo card above it, and each tunnel +// neighbor gets a small teal holo card on a stem, wired up with glowing +// dashed ground links. The default GraphView labels for these nodes are +// suppressed (suppressLabelIds) so the cards ARE the labels. + +// Rings sit above the schematic lines/bullets but below the node glyphs. +const RING_LIFT = MAP_Y_OFFSET + 0.22 + +// World heights of the floating cards (Html anchors) and their beams/stems. +// Sized for the angled station camera (STATION_CAM_DIST in graph-canvas) — +// tall enough to float clear of the glyphs, short enough that the cards +// never approach the camera plane. +const FOCAL_CARD_H = 4.6 +const FOCAL_BEAM_TOP = 4.3 +const SAT_CARD_H = 3.0 +const SAT_STEM_TOP = 2.75 + +export interface SceneNeighbor { + node: ApiNode + idx: number + edgeLabel: string +} + +function stationState(node: ApiNode) { + const p = node.properties as Record + return statusToState(p.station_status ?? p.status, p.faction) +} + +// Sweep wedge doubles as the TIME CURSOR: it glides (shortest path) to point +// at the active era notch on the dial instead of free-spinning. +const SWEEP_WIDTH = 0.55 + +function SweepWedge({ targetAngle }: { targetAngle: number }) { + const ref = useRef(null) + useFrame((_, delta) => { + if (!ref.current) return + const cur = ref.current.rotation.z + const want = targetAngle - SWEEP_WIDTH / 2 + let d = want - cur + d = Math.atan2(Math.sin(d), Math.cos(d)) + ref.current.rotation.z = cur + d * Math.min(1, delta * 5) + }) + return ( + + + + + ) +} + +// Ring-local angle of each era notch on the dial. Chronological, clockwise +// from the top of the ring. +function eraAngle(index: number): number { + return ((90 - index * 72) * Math.PI) / 180 +} + +function EraDial({ + era, + onEraChange, +}: { + era: EraId + onEraChange: (era: EraId) => void +}) { + return ( + <> + {ERAS.map((e, i) => { + const a = eraAngle(i) + const active = e.id === era + return ( + + + + ) + })} + + ) +} + +function RadarRings() { + // Tick marks — 72 short radial dashes between the inner and mid rings. + const ticks = useMemo(() => { + const pts: number[] = [] + const R0 = 2.55 + const R1 = 2.85 + const N = 72 + for (let i = 0; i < N; i++) { + const a = (i / N) * Math.PI * 2 + pts.push(Math.cos(a) * R0, Math.sin(a) * R0, 0, Math.cos(a) * R1, Math.sin(a) * R1, 0) + } + return new Float32Array(pts) + }, []) + + return ( + <> + {[ + { r: [2.0, 2.05] as const, o: 0.55 }, + { r: [3.4, 3.44] as const, o: 0.3 }, + { r: [4.7, 4.73] as const, o: 0.18 }, + ].map((ring, i) => ( + + + + + ))} + + + + + + + {/* Soft inner glow + gold core ring under the station glyph */} + + + + + + + + + + ) +} + +// Shared shell for the floating cards: notched border, dark glass fill, +// procedural hero (or image), scanlines. +// CSS filter per era — "archive footage" grading for the time dial. Pre-war +// goes warm sepia, the war burns red and dark, the book years desaturate, +// the present is untouched. +const ERA_FILTER: Record = { + prewar: "sepia(0.65) saturate(0.65) brightness(0.85)", + war: "sepia(0.4) hue-rotate(-25deg) saturate(1.6) brightness(0.7) contrast(1.15)", + y2033: "saturate(0.7) brightness(0.85)", + y2036: "saturate(0.85) brightness(0.95)", + now: "none", +} + +function HoloHero({ + node, + accent, + ghostSize, + filter, +}: { + node: ApiNode + accent: string + ghostSize: number + filter?: string +}) { + const image = nodeImage(node) + const p = node.properties as Record + const ghost = ghostInitials( + (typeof p.name_ru === "string" ? p.name_ru : null) ?? nodeName(node), + ) + return ( +
+ {image ? ( + // eslint-disable-next-line @next/next/no-img-element + {nodeName(node)} + ) : ( +
+ {ghost} +
+ )} +
+
+ ) +} + +function FocalHoloCard({ + focal, + neighbors, + snapshot, +}: { + focal: ApiNode + neighbors: SceneNeighbor[] + snapshot: EraSnapshot +}) { + const props = focal.properties as Record + const name = nodeName(focal) + const nameRu = typeof props.name_ru === "string" ? props.name_ru : null + const lines = readStationLines(props) + const passable = neighbors.filter((n) => !BLOCKING_STATES.has(stationState(n.node))).length + const total = neighbors.length + const isNow = snapshot.era === "now" + + return ( +
+ + {/* Name strip */} +
+ STATION + + {name} + + + {snapshot.year} + + {nameRu && {nameRu}} +
+ +
+
+ + {!isNow && ( + <> + {/* Archive-footage chrome: flicker pass + corner tag */} +
+
+ ● REC {snapshot.year} +
+ + )} +
+
+
+ {isNow ? ( + <> + TUNNELS +
+ {Array.from({ length: Math.max(total, 1) }, (_, i) => ( + 0 && i < passable ? GOLD : "rgba(120,120,110,0.22)", + boxShadow: total > 0 && i < passable ? `0 0 7px ${gold(0.5)}` : "none", + }} + /> + ))} +
+ + {passable}/{total} + + + ) : ( + <> + ARCHIVE + + {snapshot.faction ?? snapshot.label} + + + )} + {snapshot.status && ( + + + {snapshot.status} + + )} +
+ {/* Era story — or an honest placeholder when no record exists. */} + {snapshot.text ? ( +
+ {snapshot.text} +
+ ) : ( +
+ — No archival record — +
+ )} + {isNow && lines.length > 0 && ( +
+ LINES + {lines.map((l) => ( + + ))} +
+ )} +
+
+
+ ) +} + +function SatelliteHoloCard({ + neighbor, + onClick, +}: { + neighbor: SceneNeighbor + onClick: () => void +}) { + const node = neighbor.node + const name = nodeName(node) + const state = stationState(node) + const lines = readStationLines(node.properties as Record) + const edge = (neighbor.edgeLabel || "LINKED").replace(/_/g, " ").toUpperCase() + + return ( +
{ + e.stopPropagation() + onClick() + }} + style={{ + position: "relative", + width: 156, + fontFamily: "var(--font-heading), sans-serif", + color: INK, + clipPath: NOTCH(9), + border: `1px solid ${teal(0.55)}`, + background: "rgba(5, 16, 17, 0.9)", + boxShadow: `0 0 14px ${teal(0.12)}`, + cursor: "pointer", + transition: "transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease", + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = "translateY(-3px)" + e.currentTarget.style.boxShadow = `0 0 24px ${teal(0.35)}` + e.currentTarget.style.borderColor = teal(0.95) + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = "" + e.currentTarget.style.boxShadow = `0 0 14px ${teal(0.12)}` + e.currentTarget.style.borderColor = teal(0.55) + }} + > +
+ {edge} +
+ +
+
+ {name} +
+
+ + + {stateWord(STATION_STATE_LABEL[state])} + + + {lines.map((l) => ( + + ))} +
+
+
+ ) +} + +export interface StationHudSceneProps { + graph: Graph + selectedNodeId: number + focal: ApiNode + neighbors: SceneNeighbor[] + // Active era on the time dial; "now" = present day. State lives in + // GraphCanvas so the zone plate (separate DOM tree) stays in sync. + era: EraId + onEraChange: (era: EraId) => void + onFocusNode: (nodeId: number) => void +} + +export function StationHudScene({ + graph, + selectedNodeId, + focal, + neighbors, + era, + onEraChange, + onFocusNode, +}: StationHudSceneProps) { + const snapshots = useMemo(() => stationTimeline(focal), [focal]) + const p = graph.nodes[selectedNodeId]?.position + if (!p) return null + const ringY = p.y + RING_LIFT + const snapshot = snapshots.find((s) => s.era === era) ?? snapshots[snapshots.length - 1] + const isNow = snapshot.era === "now" + const eraIndex = Math.max(0, ERAS.findIndex((e) => e.id === snapshot.era)) + + return ( + + {/* Radar rings + era dial on the map plane around the station */} + + + + + + + {/* Gold beam + central holo card */} + + + + + + +
+ +
+ +
+ + {/* Tunnel neighbors: ground link, anchor ring, stem, holo card. The + neighbor network describes the PRESENT — viewing a past era dims it + so the archive story owns the stage. */} + {neighbors.map((nb) => { + const np = graph.nodes[nb.idx]?.position + if (!np) return null + const dim = isNow ? 1 : 0.3 + return ( + + + + + + + + + + + + + + +
+ onFocusNode(nb.idx)} /> +
+ +
+
+ ) + })} +
+ ) +} + +// DOM chrome shown alongside the in-scene HUD: the zone plate (bottom-center) +// with the station's state + faction, and a small sector readout. Follows +// the time dial: past eras swap the zone word for the era title and show +// that era's controlling power. +export function StationZonePlate({ node, era }: { node: ApiNode; era: EraId }) { + const props = node.properties as Record + const snapshots = useMemo(() => stationTimeline(node), [node]) + const snapshot = snapshots.find((s) => s.era === era) ?? snapshots[snapshots.length - 1] + const isNow = snapshot.era === "now" + const stateGlow = snapshot.statusGlow + const nowFaction = + typeof props.faction === "string" ? (FACTION_LABEL[props.faction] ?? null) : null + const faction = isNow ? nowFaction : snapshot.faction + const sector = `SEC-${String(hashCode(node.ref_id) % 999).padStart(3, "0")}` + const title = isNow ? `${snapshot.status} ZONE` : `${snapshot.label} · ${snapshot.year}` + + return ( +
+
+
+
+
+ {title} + + {faction ? `${faction} · ${sector}` : sector} + +
+
+
+
+ ) +} diff --git a/src/data/metro.ts b/src/data/metro.ts index c207bfdf..aed048f4 100644 --- a/src/data/metro.ts +++ b/src/data/metro.ts @@ -163,11 +163,11 @@ const tunnels = tunnelEdges() // definitions readable while letting the runtime data align with the seeded // graph so unlock/preview calls hit the right backend records. // -// Stations are intentionally omitted — the backend collapses fixture's -// transfer-platform variants (komsomolskaya_k / komsomolskaya_r, etc.) into -// one row per real station. Keeping station ref_ids as fixture slugs -// preserves the dual-platform schematic; node-preview-panel short-circuits -// stations to the fixture data so they don't try to fetch. +// Stations are mapped separately in STATION_BACKEND_REF_ID_MAP (below), since +// the backend DOES have all ~160 Station nodes (it's a seed of this fixture). +// The only exceptions are the dual-platform transfer twins, which the backend +// collapses into their ring node — those keep fixture slugs to preserve the +// dual-platform schematic and short-circuit in node-preview-panel. const BACKEND_REF_ID_MAP: Record = { // Persons Artyom: "e7542bf7-1390-458a-af69-f334d1c59f8c", @@ -242,8 +242,180 @@ const BACKEND_REF_ID_MAP: Record = { Workbench: "41d28422-9013-4761-8273-756c19df360b", } +// Fixture station slug -> backend Station UUID. Generated by matching each +// fixture station's (x, -z) to the DB Station node's (mapX, mapZ) — the DB +// is a faithful seed of the fixture, so coordinates align exactly. Lets +// clicking a station resolve to its live DB record (content, edges, unlock) +// while the fixture still provides the static schematic (positions + tunnels). +// +// The 4 dual-platform transfer twins (park_kultury_r, komsomolskaya_r, +// belorusskaya_z, paveletskaya_z) are intentionally absent: the DB collapses +// them into their ring node, so they keep fixture slugs and short-circuit +// (their ring counterpart loads the shared real station). +const STATION_BACKEND_REF_ID_MAP: Record = { + aeroport: "a4358d1c-a507-4657-962a-1d03c51acc39", + akademicheskaya: "a2ec3ef9-755d-4933-a5ab-9da151072cf5", + alekseyevskaya: "836e8d99-bb1d-422f-9de1-09982cafd0bc", + altufyevo: "3eeb9bac-8c82-429e-908a-9e839256dde9", + annino: "4a27314b-1f20-4941-b9a4-f70e8f9f1c88", + arbatskaya: "f256e56d-c480-4b64-8c19-51d986388ca5", + aviamotornaya: "e132da71-1fee-4efb-b462-3ba642b3f835", + avtozavodskaya: "f3d1aeec-60b7-488b-87f9-7231c8c3c2cc", + babushkinskaya: "94ac4d72-4175-4ddc-a3cc-2f195ecfff5d", + bagrationovskaya: "e24b6fc5-90ec-41a5-aca2-b3170c91960d", + barrikadnaya: "7ed9e6e0-75a0-41af-8ca4-0d8cfc977eb3", + baumanskaya: "36ed02f8-8b67-415d-b2b6-0b3f2bc917f3", + begovaya: "f0c95008-1bd5-484d-884a-662e94857800", + belorusskaya_k: "aa5672be-812c-4363-a2bb-e01c8b26537d", + belyayevo: "1bc1a9c0-5293-4833-b7db-2022c21a1fd7", + bibirevo: "b28bc39e-5c0f-4762-9115-2415ab90d043", + biblioteka: "1d768f22-6463-48b1-aa8c-5f2207230a0a", + borovitskaya: "b2054536-307a-436f-ba41-5910e5979ac6", + botanichesky: "b84ee9c9-0f25-4eb1-93a6-a76aa3910e41", + bratislavskaya: "e2421e39-ad9c-4b99-8379-31a7ba84056e", + bulvar_dd: "0290e324-624c-4c8d-aa42-7ab961317696", + chekhovskaya: "b03de616-ec9b-489c-8b49-53417813a076", + cherkizovskaya: "727e12c1-8380-47ca-8430-f8ec166d191d", + chertanovskaya: "35d84fb4-d598-4002-8262-3936efeb8cff", + cheryomushki: "4a5d6c28-c904-4d2e-b519-dd9060762b97", + chistye_prudy: "dd534809-a82f-47e0-a70d-14d32bfb9e23", + chkalovskaya: "924cba93-4547-4922-a8b2-7676752b9bee", + dinamo: "16c33082-85b4-4727-a564-37c7f7fd5755", + dmitrovskaya: "8b11d73d-23d3-4455-9fec-6434b1416430", + dobryninskaya: "75fc09fc-7a70-4e5f-9e3b-f70995b0bf3f", + domodedovskaya: "8c800d11-efd1-41b7-9b0c-ca69d0175097", + dostoyevskaya: "1609dc1d-cf46-43ba-af09-bc20f8304c7b", + dubrovka: "797c6ed2-81a7-48d5-a88c-4c0e894d26b9", + elektrozavodskaya: "a3d935ea-8ce6-4299-84c9-1b29ab179ffa", + fili: "0fbdd817-7cd3-4ae5-b176-4ab293b79c5a", + filyovsky_park: "73bc9837-afd9-48fd-bb2b-ce1218470d8e", + frunzenskaya: "0c8d1801-94ee-4376-81cd-d7f9935fe0a6", + izmaylovskaya: "6a6f3d8e-1593-411d-9646-429592b6bb13", + kaluzhskaya: "8d4f07eb-b7d3-44c8-ac1c-4ff9ddf68211", + kantemirovskaya: "758c0c9e-a453-43a8-a525-bb9c9795a8a9", + kashirskaya: "3230c374-586e-411b-aa8d-96f9d5443494", + khovrino: "4916fd83-3515-4299-877d-225e1ed5f3e6", + kievskaya_k: "043328f8-df03-4441-8d4a-30995259ae47", + kitay_gorod: "d54b09b4-6f06-428a-b5fd-d947df069a2d", + kolomenskaya: "eadc5bdc-2377-445f-8833-c4c047ef7e9a", + komsomolskaya_k: "bb0f0a6c-6798-41e6-bba8-6deb345fadaa", + konkovo: "b693b358-31e7-44ca-b938-67062edcb910", + kozhukhovskaya: "700125ca-5a0b-4dfd-9ecc-1076fc1ce47f", + krasnogvardeyskaya: "72e73b28-9dec-4673-bc25-2fd48e24bea7", + krasnopresnenskaya: "bb922d34-ed2e-4c6b-8df1-673e7c3c861e", + krasnoselskaya: "afd8d3e1-2ee5-4f48-abc9-42c810ab36ef", + krasnye_vorota: "57e9f225-4975-4035-aec4-7cc3043a1b52", + krestyanskaya: "bc849e56-95e1-45de-b621-b9d95fa19f9b", + kropotkinskaya: "aa3f706b-bb8a-421b-9fcd-48ea25c2e103", + krylatskoye: "14034a5d-1252-40b5-8679-fe155836adf5", + kuntsevskaya: "4874a2e3-0b93-4899-b53c-ee6b5fd95641", + kurskaya_k: "8c7e3041-0f4d-4714-af36-e389f5ddfd88", + kutuzovskaya: "2da659fb-f9e2-435d-a2bb-54e2e8ba0424", + kuzminki: "001a0ed0-5e3e-415c-8962-cf6cc32ffde3", + kuznetsky_most: "19b96143-3f06-4199-8e2f-10389ee44289", + leninsky_pr: "302d4e35-233f-414a-9190-bca98c745a99", + lermontovsky: "d90fc695-de8a-4bb4-87aa-750f7974936e", + lubyanka: "1ba1482e-c81c-4ac9-b718-ced277e46d59", + lyublino: "8212f36b-8738-4775-80b1-1187688065cc", + marksistskaya: "8f8cd793-f889-4ee6-bcbf-0e848f79a25a", + maryina_roshcha: "a7f5b24e-3864-4c2d-a201-d1864f6282ec", + maryino: "6ec1cff2-ba41-49bb-9550-95775f887e6c", + mayakovskaya: "339e26fd-47e7-4c7d-a131-8dfc8da4f9bf", + medvedkovo: "0fcdfd92-f5c3-48a3-8a0d-029bb0de3c59", + mendeleyevskaya: "ad093c3f-5c4d-49da-a765-e92ea762fc45", + molodezhnaya: "dedbaa20-af9a-4029-aa1f-b69a871ef0f6", + myakinino: "eab09292-a25a-4b51-959e-d1f4f7667970", + nagatinskaya: "c6d3be24-38bc-4e1a-9d8d-d7cf7075710c", + nagornaya: "506dac8c-4a7c-4123-8daf-d4197b0271e4", + nakhimovsky: "b0887686-64c2-4edc-bdfb-f941500706df", + novogireevo: "6f6044bd-41e8-4ebd-92cd-57e94055bf3e", + novokuznetskaya: "4f06d251-fdc6-4420-b915-a818e0cc9dae", + novoslobodskaya: "c9dd1399-e98a-43ba-ba14-59a7e920a552", + novoyasenevskaya: "0fa354c6-ab5d-4dcd-a970-e633afd227fa", + okhotny_ryad: "44bb6e57-def5-4b90-bee4-acf7e3fbd387", + oktyabrskaya_k: "b1d5930b-ce14-4de2-a616-333d7ed9e276", + oktyabrskoye_pole: "a006e480-154c-413d-bd6b-96809e716bdf", + orekhovo: "1c9088f1-6748-4d56-9885-7c51be953bc8", + otradnoye: "7643c464-023a-4ec1-ad47-f27ee4af12ed", + park_kultury_k: "c8d6e282-26a6-4949-80a0-c4bb61a7c03a", + park_pobedy: "67c5af74-bb4a-4b1b-8792-9336a80a3fdf", + partizanskaya: "2f977a96-ef2e-4557-8504-cbe7805330ce", + paveletskaya_k: "3d116718-05d7-47da-a2de-c212f9e16ba9", + pechatniki: "8f5703e3-9560-4d1a-ad81-5998454a5c8e", + perovo: "752b8690-fdea-4378-b3fb-49114118536b", + pervomayskaya: "5da74ff3-57d2-4c70-b91b-f6af0e14b402", + petrovsko_raz: "f9e8f90a-8303-461e-bf71-9f77a0e7c518", + pionerskaya: "c46d1aec-1bdc-42b2-8371-f1b29be68ba9", + planernaya: "fb52cceb-27e7-4e7e-8456-32d28af057f8", + ploshchad_il: "f3fc9e41-d36f-46e5-b398-59f103da2210", + ploshchad_rev: "5b682ba3-470b-4053-b094-bb0430bf0cb9", + polezhayevskaya: "21c5cdac-3f0e-4d39-abc9-cd1ec74bfb44", + polyanka: "0cb344b8-9f60-4282-afb2-86104d0452cb", + prazhskaya: "0eb0ed27-7bc6-48c9-af4a-69bfb9eddd97", + preobrazhenskaya: "56814f60-dbee-4efd-bb3c-2f003a07660b", + profsoyuznaya: "abfbda26-ad68-46f7-bc60-186fb8333d0a", + proletarskaya: "9f8cdb9c-f773-44af-8014-fb1dc297773b", + prospekt_mira_k: "1ebcc93d-c97c-43d0-85e6-657ef052a3e6", + prospekt_vernad: "48a64e40-5cd0-4976-a33e-81cc0b144f04", + pushkinskaya: "ca061350-627b-4fd4-b932-b07216d7d1c7", + rechnoy_vokzal: "f99f6d1e-c02c-4b93-83c1-f59bd8d92c12", + rimskaya: "6843bd7f-9655-40d7-bd01-9eef59a5cdd8", + rizhskaya: "6960f047-4692-483e-a287-01ee9b4a5ef2", + rumyantsevo: "254d6c5f-55b6-4985-8926-a5136d11d821", + ryazansky: "c023faa9-e0dd-4776-a2e4-a2b15b2078bc", + salaryevo: "3fa90758-acba-41f9-a90f-059e085dc669", + savyolovskaya: "70fd344d-b523-4185-840c-93c6194f9570", + semyonovskaya: "9a979cd0-9584-4294-93a6-85fecd534fb9", + serpukhovskaya: "5d37a612-6b0d-4af4-83ce-8ca2abab0445", + sevastopolskaya: "e5b3ddfc-c8a1-4c6f-b284-7e4eae1503a7", + shabolovskaya: "e83d41b7-43ef-49df-9a39-8bc4da67f044", + shchukinskaya: "951422f4-1101-4077-86a2-25fd06080ae2", + shchyolkovskaya: "ecef1541-7cf8-4451-a401-dfb549cd02a9", + shosse_ent: "8c4516db-b591-4477-a77b-4a77dc06b8fc", + skhodnenskaya: "2d6959df-4ad4-4e9f-b607-6f18d9b9764e", + slavyansky_b: "bd23ef75-74f7-43f6-9ad5-0de09b5de39c", + smolenskaya: "11335e83-d9e4-4bdb-8dfa-b175c0eec4bf", + sokol: "216a0a58-ba0c-405b-9f21-a1db72c1602c", + sokolniki: "f9845f70-5181-4ac7-934d-4f2e21255000", + sportivnaya: "dd3119a6-b64c-4927-ba2a-76b921d9e7c7", + sretensky_b: "dadffecd-8cf8-400d-ba50-0b5c1838fe7f", + strogino: "1780e1fb-9f85-4806-9e49-86feedde4f1c", + studencheskaya: "d99a8fb7-0882-46f2-b5b2-23a5a40dee24", + sukharevskaya: "e98a1d74-1370-473f-a64e-f4ac3a8c289e", + sviblovo: "fe1eaa9e-fef3-4207-960c-3e628bc5e5ed", + taganskaya_k: "a093ca63-345d-4fb9-851b-0637cb24341d", + teatralnaya: "33f9db4a-34df-4e08-b39c-090ec6eb776b", + tekstilshchiki: "4a691c2e-dc88-4e8a-8ade-9389f04f8ba0", + timiryazevskaya: "3682c72b-3eca-4809-833e-fc462bf9e25d", + tretyakovskaya: "7d01b2b8-5c63-4152-93d8-f67f23e4bee9", + troparyovo: "c256440c-24bc-4198-a279-dc94cb2552f6", + trubnaya: "1d67cc44-d2c6-436c-b00e-dcf80f55eea2", + tsaritsyno: "5118f294-047d-4bb0-93ad-e0024766109d", + tsvetnoy: "c970483a-03be-43ac-90b7-2b5dcbf6a922", + tulskaya: "61cbd8e7-32f7-4c98-b555-27fbc564953d", + turgenevskaya: "4d527b84-70d6-46e5-96db-1a8f5e738fcb", + tushinskaya: "bc87df88-16ca-47ff-9698-e1c65f705faa", + tverskaya: "e6eb1a46-c752-4f8e-827b-a4e0bd8db077", + tyoply_stan: "a6dbb387-0f52-4ff1-909b-fe982a3ed6d2", + ulitsa_1905: "cb5cdcac-e881-453c-b219-b683e8615150", + ulitsa_pod: "214a76ec-e686-49ad-ba0e-03418a864ea7", + universitet: "f60ccbce-5b4b-4e8a-a7c3-6f4175aaa2ed", + vdnkh: "358a7770-17c2-44d3-a5dd-60bbbc893eeb", + vladykino: "68fccb43-a533-4d10-8231-a202689301fc", + vodny_stadion: "22b7c621-756c-440a-a343-1631d227f5b6", + volgogradsky: "7c0fc501-03a0-4bad-8f53-d5eb6cb5fd55", + volokolamskaya: "f9517146-15c2-48ee-80ed-22c010168962", + volzhskaya: "0f2f49a7-e9be-4537-9fd3-a335e6fb3f2c", + vorobyovy_gory: "7dab5b27-df26-477c-becf-0e776fdadcdc", + voykovskaya: "56c5dbed-220c-476e-b909-03cd09706592", + vykhino: "2125f0f1-967c-4560-b4eb-56b715882a62", + yasenevo: "2a0e8529-7827-4f93-aecb-b300ded3fee8", + yugo_zapadnaya: "e40d6b9e-1468-42ee-a72c-674c19318eaf", + yuzhnaya: "5602874b-3d1e-4539-88d7-84f33eb2cfa2", +} + function rid(id: string): string { - return BACKEND_REF_ID_MAP[id] ?? id + return BACKEND_REF_ID_MAP[id] ?? STATION_BACKEND_REF_ID_MAP[id] ?? id } const rawMetroSeries = { diff --git a/src/data/station-timeline.ts b/src/data/station-timeline.ts new file mode 100644 index 00000000..0a6ad88a --- /dev/null +++ b/src/data/station-timeline.ts @@ -0,0 +1,152 @@ +import type { GraphNode } from "@/lib/graph-api" +import { + STATION_FILL, + STATION_GLOW, + STATION_STATE_LABEL, + statusToState, + type StationState, +} from "@/components/universe/metro-overlay" + +// Station time dial — five canonical epochs of the Metro universe. The dial +// is a navigation/framing mechanic; it carries only data we can stand behind: +// • Pre-war / War — universal facts of the setting (every Moscow station +// opened as civic infrastructure and sealed as a shelter in 2013). +// • 2033 / 2036 — no per-station records exist yet, so these read as +// "no archival record" rather than inventing station-specific lore. +// • Present day — the real fixture state (status, description). +// Hand-authored or sourced per-era stories can later populate `text` (and the +// 2033/2036 status/faction) without changing the dial itself. + +export type EraId = "prewar" | "war" | "y2033" | "y2036" | "now" + +export interface Era { + id: EraId + // Short dial-notch label. + year: string + // Zone-plate era title. + label: string +} + +export const ERAS: Era[] = [ + { id: "prewar", year: "1935", label: "PRE-WAR ERA" }, + { id: "war", year: "2013", label: "THE WAR" }, + { id: "y2033", year: "2033", label: "YEAR 2033" }, + { id: "y2036", year: "2036", label: "EXODUS ERA" }, + { id: "now", year: "2087", label: "PRESENT DAY" }, +] + +export interface EraSnapshot { + era: EraId + year: string + label: string + // Status chip word + colors for this era. `status` is null when there's no + // record to show (the card then renders a "no archival record" placeholder). + status: string | null + statusFill: string + statusGlow: string + // Zone-plate secondary line (controlling power of the era). + faction: string | null + // The story paragraph — null when no real record exists for the era. + text: string | null +} + +// Chip palette for the canonical bookend statuses. +const ERA_CHIP: Record = { + CIVIC: { fill: "#cfe8ef", glow: "#7fd4e8" }, + SHELTER: { fill: "#d4a017", glow: "#e8b54a" }, + NEUTRAL: { fill: "#9aa3ab", glow: "#b8c2cc" }, +} + +// Dim accent for eras with no record — used by the zone-plate marker bar. +const NO_RECORD = { fill: "#39424a", glow: "#5a646c" } + +// Current-state story templates for the "now" era when a station has no +// authored description. Describes the present real status, not past events. +const NOW_TEXT: Record = { + inhabited: + "Still inhabited in 2087 — cook-fires on the platform, a generation born here that has never seen the sky. The tunnels are watched in both directions.", + neutral: + "Holding on as neutral ground in 2087. Travelers pass through, few stay; the station keeps its lamps low and its opinions lower.", + lost: "Lost. The lights failed decades ago and nobody reclaimed the dark. Caravans seal their masks and pass the platform at a run.", + anomaly: + "An anomaly zone in 2087 — instruments spin, sounds arrive before their causes, and the things on the platform are not always there when you look twice.", + scorched: + "Scorched out — fire took the station and the burn shadow still stains the vault. Nothing has grown back. Nothing will.", + flood: "Drowned. Black water stands to the escalator crowns, and divers tell stories about what swims the lower halls.", + quarantine: + "Under quarantine — the seals went up after the outbreak and no faction has dared cut them since. The warning signs are repainted every year.", +} + +function stateWord(label: string): string { + return label.split(" (")[0].toUpperCase() +} + +function chipSpread(status: string): { statusFill: string; statusGlow: string } { + const c = ERA_CHIP[status] ?? ERA_CHIP.NEUTRAL + return { statusFill: c.fill, statusGlow: c.glow } +} + +export function stationTimeline(node: GraphNode): EraSnapshot[] { + const p = node.properties as Record + const state = statusToState(p.station_status ?? p.status, p.faction) + const description = typeof p.description === "string" ? p.description : null + + // Canonical bookends — true of every station in the setting, so framed + // without inventing station-specific narrative. + const prewar: EraSnapshot = { + era: "prewar", + year: "1935", + label: "PRE-WAR ERA", + status: "CIVIC", + ...chipSpread("CIVIC"), + faction: "MOSCOW METROPOLITEN", + text: null, + } + + const war: EraSnapshot = { + era: "war", + year: "2013", + label: "THE WAR", + status: "SHELTER", + ...chipSpread("SHELTER"), + faction: "CIVIL DEFENSE", + text: null, + } + + // No per-station records for the book years — leave them empty rather than + // fabricate lore. + const y2033: EraSnapshot = { + era: "y2033", + year: "2033", + label: "YEAR 2033", + status: null, + statusFill: NO_RECORD.fill, + statusGlow: NO_RECORD.glow, + faction: null, + text: null, + } + + const y2036: EraSnapshot = { + era: "y2036", + year: "2036", + label: "EXODUS ERA", + status: null, + statusFill: NO_RECORD.fill, + statusGlow: NO_RECORD.glow, + faction: null, + text: null, + } + + const now: EraSnapshot = { + era: "now", + year: "2087", + label: "PRESENT DAY", + status: stateWord(STATION_STATE_LABEL[state]), + statusFill: STATION_FILL[state], + statusGlow: STATION_GLOW[state], + faction: null, + text: description ?? NOW_TEXT[state], + } + + return [prewar, war, y2033, y2036, now] +} diff --git a/src/graph-viz-kit/GraphView.tsx b/src/graph-viz-kit/GraphView.tsx index 652a4216..8143aa1e 100644 --- a/src/graph-viz-kit/GraphView.tsx +++ b/src/graph-viz-kit/GraphView.tsx @@ -75,6 +75,11 @@ interface GraphViewProps { * dedicated overlay and would otherwise clutter the resting view. They stay * fully interactive: hover/click brings back their label and highlight. */ mutedNodeIds?: Set | null; + /** Node indices whose text label (name + pills) is fully suppressed — used + * when an external overlay (e.g. the station holo cards) renders its own + * richer label at the node's position and the default one would double up. + * Unlike mutedNodeIds this also suppresses the hovered/selected label. */ + suppressLabelIds?: Set | null; } const tmpObj = new THREE.Object3D(); @@ -452,7 +457,7 @@ function renderHighlightedLabel(label: string, term: string): React.ReactNode { } -export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minimap, whiteboardNodeId, onExitWhiteboard, onDetailNavigate, searchMatches, searchLabelMatches, topMatchRanks, searchTerm, pulses, recentNodes, expandedClusterId, externalHoveredId, externalSelectedId, onGraphClick, nodeTypeIcons, onResetView, suppressHover, mutedNodeIds }: GraphViewProps) { +export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minimap, whiteboardNodeId, onExitWhiteboard, onDetailNavigate, searchMatches, searchLabelMatches, topMatchRanks, searchTerm, pulses, recentNodes, expandedClusterId, externalHoveredId, externalSelectedId, onGraphClick, nodeTypeIcons, onResetView, suppressHover, mutedNodeIds, suppressLabelIds }: GraphViewProps) { const meshRef = useRef(null); const linesRef = useRef(null); const highlightLinesRef = useRef(null); @@ -1829,6 +1834,9 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima // Skip invisible nodes, but keep expanded proxy label visible if (targets.scales[i] < 0.01 && !isExpandedProxy) return null; + // An external overlay (station holo cards) owns this node's label. + if (suppressLabelIds?.has(i)) return null; + // Label gating: show for depth 0-1, hovered + neighbors, cursor-revealed, recent const isSelected = viewState.mode === "subgraph" && i === viewState.selectedNodeId; const isHovered = i === hovered; From 00af37564e575a6229ada578f9bfa846b3ae6757 Mon Sep 17 00:00:00 2001 From: Rassl Date: Sun, 14 Jun 2026 01:23:29 +0400 Subject: [PATCH 18/19] feat: remove storyline --- src/components/universe/graph-canvas.tsx | 12 +- src/components/universe/station-hud-scene.tsx | 361 ++++-------------- src/data/station-timeline.ts | 152 -------- 3 files changed, 76 insertions(+), 449 deletions(-) delete mode 100644 src/data/station-timeline.ts diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 73b4ccac..8e97064e 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -48,7 +48,6 @@ import { StationZonePlate, type SceneNeighbor, } from "./station-hud-scene" -import type { EraId } from "@/data/station-timeline" // Max number of search hits that keep a text label at once. Beyond this the // view becomes an unreadable pile of overlapping labels; the rest of the hits @@ -1615,11 +1614,6 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas return out }, [hudSceneActive, selectedApiNode, effectiveEdges, effectiveNodeByRefId, refIdToIndex]) - // Time-dial era for the station HUD. Owned here (not in the scene) so the - // zone plate — a separate DOM tree — follows the dial, and so every new - // station selection starts back at the present day. - const [hudEra, setHudEra] = useState("now") - // The holo cards ARE the labels for these nodes — suppress GraphView's own. const hudSuppressedLabelIds = useMemo | null>(() => { if (!hudSceneActive || viewState.mode !== "subgraph") return null @@ -1834,8 +1828,6 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas (nodeId: number) => { useGraphStore.getState().setSidebarSelectedNode(null) useGraphStore.getState().setHoveredNode(null) - // Every selection starts at the present day on the time dial. - setHudEra("now") const refId = indexMap.get(nodeId) if (refId && onNodeSelect) { const apiNode = nodeByRefId.get(refId) @@ -2070,8 +2062,6 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas selectedNodeId={viewState.selectedNodeId} focal={selectedApiNode} neighbors={sceneNeighbors} - era={hudEra} - onEraChange={setHudEra} onFocusNode={handleNodeClick} /> )} @@ -2278,7 +2268,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas )} {hudSceneActive && selectedApiNode && ( - + )} diff --git a/src/components/universe/station-hud-scene.tsx b/src/components/universe/station-hud-scene.tsx index d37432ea..9cd7f4f6 100644 --- a/src/components/universe/station-hud-scene.tsx +++ b/src/components/universe/station-hud-scene.tsx @@ -17,12 +17,6 @@ import { readStationLines, statusToState, } from "./metro-overlay" -import { - ERAS, - stationTimeline, - type EraId, - type EraSnapshot, -} from "@/data/station-timeline" const TEAL = "#46e3d4" const GOLD = "#f2b73f" @@ -140,27 +134,19 @@ function stationState(node: ApiNode) { return statusToState(p.station_status ?? p.status, p.faction) } -// Sweep wedge doubles as the TIME CURSOR: it glides (shortest path) to point -// at the active era notch on the dial instead of free-spinning. -const SWEEP_WIDTH = 0.55 - -function SweepWedge({ targetAngle }: { targetAngle: number }) { +// Rotating sweep wedge — atmospheric "this station is in focus" cue. +function SweepWedge() { const ref = useRef(null) useFrame((_, delta) => { - if (!ref.current) return - const cur = ref.current.rotation.z - const want = targetAngle - SWEEP_WIDTH / 2 - let d = want - cur - d = Math.atan2(Math.sin(d), Math.cos(d)) - ref.current.rotation.z = cur + d * Math.min(1, delta * 5) + if (ref.current) ref.current.rotation.z -= delta * 0.55 }) return ( - + void -}) { - return ( - <> - {ERAS.map((e, i) => { - const a = eraAngle(i) - const active = e.id === era - return ( - - - - ) - })} - - ) -} - function RadarRings() { // Tick marks — 72 short radial dashes between the inner and mid rings. const ticks = useMemo(() => { @@ -272,33 +202,21 @@ function RadarRings() { + ) } // Shared shell for the floating cards: notched border, dark glass fill, // procedural hero (or image), scanlines. -// CSS filter per era — "archive footage" grading for the time dial. Pre-war -// goes warm sepia, the war burns red and dark, the book years desaturate, -// the present is untouched. -const ERA_FILTER: Record = { - prewar: "sepia(0.65) saturate(0.65) brightness(0.85)", - war: "sepia(0.4) hue-rotate(-25deg) saturate(1.6) brightness(0.7) contrast(1.15)", - y2033: "saturate(0.7) brightness(0.85)", - y2036: "saturate(0.85) brightness(0.95)", - now: "none", -} - function HoloHero({ node, accent, ghostSize, - filter, }: { node: ApiNode accent: string ghostSize: number - filter?: string }) { const image = nodeImage(node) const p = node.properties as Record @@ -311,8 +229,6 @@ function HoloHero({ position: "relative", aspectRatio: "16 / 6.5", overflow: "hidden", - filter, - transition: "filter 400ms ease", ...(image ? {} : heroArtStyle(node.ref_id, accent)), }} > @@ -353,35 +269,17 @@ function HoloHero({ ) } -function FocalHoloCard({ - focal, - neighbors, - snapshot, -}: { - focal: ApiNode - neighbors: SceneNeighbor[] - snapshot: EraSnapshot -}) { +function FocalHoloCard({ focal, neighbors }: { focal: ApiNode; neighbors: SceneNeighbor[] }) { const props = focal.properties as Record const name = nodeName(focal) const nameRu = typeof props.name_ru === "string" ? props.name_ru : null + const state = stationState(focal) const lines = readStationLines(props) const passable = neighbors.filter((n) => !BLOCKING_STATES.has(stationState(n.node))).length const total = neighbors.length - const isNow = snapshot.era === "now" return ( -
- +
{/* Name strip */}
{name} - - {snapshot.year} - {nameRu && {nameRu}}
@@ -435,141 +322,55 @@ function FocalHoloCard({ boxShadow: `0 0 22px ${gold(0.22)}`, }} > -
- - {!isNow && ( - <> - {/* Archive-footage chrome: flicker pass + corner tag */} -
-
- ● REC {snapshot.year} -
- - )} -
+
- {isNow ? ( - <> - TUNNELS -
- {Array.from({ length: Math.max(total, 1) }, (_, i) => ( - 0 && i < passable ? GOLD : "rgba(120,120,110,0.22)", - boxShadow: total > 0 && i < passable ? `0 0 7px ${gold(0.5)}` : "none", - }} - /> - ))} -
- - {passable}/{total} - - - ) : ( - <> - ARCHIVE + TUNNELS +
+ {Array.from({ length: Math.max(total, 1) }, (_, i) => ( - {snapshot.faction ?? snapshot.label} - - - )} - {snapshot.status && ( - - 0 && i < passable ? GOLD : "rgba(120,120,110,0.22)", + boxShadow: total > 0 && i < passable ? `0 0 7px ${gold(0.5)}` : "none", }} /> - {snapshot.status} - - )} -
- {/* Era story — or an honest placeholder when no record exists. */} - {snapshot.text ? ( -
- {snapshot.text} + ))}
- ) : ( -
+ {passable}/{total} + + - — No archival record — -
- )} - {isNow && lines.length > 0 && ( -
+ + {stateWord(STATION_STATE_LABEL[state])} + +
+ {lines.length > 0 && ( +
LINES {lines.map((l) => ( void onFocusNode: (nodeId: number) => void } @@ -717,25 +514,17 @@ export function StationHudScene({ selectedNodeId, focal, neighbors, - era, - onEraChange, onFocusNode, }: StationHudSceneProps) { - const snapshots = useMemo(() => stationTimeline(focal), [focal]) const p = graph.nodes[selectedNodeId]?.position if (!p) return null const ringY = p.y + RING_LIFT - const snapshot = snapshots.find((s) => s.era === era) ?? snapshots[snapshots.length - 1] - const isNow = snapshot.era === "now" - const eraIndex = Math.max(0, ERAS.findIndex((e) => e.id === snapshot.era)) return ( - {/* Radar rings + era dial on the map plane around the station */} + {/* Radar rings on the map plane around the station */} - - {/* Gold beam + central holo card */} @@ -752,18 +541,15 @@ export function StationHudScene({
- +
- {/* Tunnel neighbors: ground link, anchor ring, stem, holo card. The - neighbor network describes the PRESENT — viewing a past era dims it - so the archive story owns the stage. */} + {/* Tunnel neighbors: ground link, anchor ring, stem, holo card */} {neighbors.map((nb) => { const np = graph.nodes[nb.idx]?.position if (!np) return null - const dim = isNow ? 1 : 0.3 return ( - + @@ -791,7 +577,7 @@ export function StationHudScene({ @@ -801,14 +587,7 @@ export function StationHudScene({ zIndexRange={[80, 0]} style={{ pointerEvents: "none" }} > -
+
onFocusNode(nb.idx)} />
@@ -821,20 +600,15 @@ export function StationHudScene({ } // DOM chrome shown alongside the in-scene HUD: the zone plate (bottom-center) -// with the station's state + faction, and a small sector readout. Follows -// the time dial: past eras swap the zone word for the era title and show -// that era's controlling power. -export function StationZonePlate({ node, era }: { node: ApiNode; era: EraId }) { +// with the station's state + faction, and a small sector readout. +export function StationZonePlate({ node }: { node: ApiNode }) { const props = node.properties as Record - const snapshots = useMemo(() => stationTimeline(node), [node]) - const snapshot = snapshots.find((s) => s.era === era) ?? snapshots[snapshots.length - 1] - const isNow = snapshot.era === "now" - const stateGlow = snapshot.statusGlow - const nowFaction = + const state = statusToState(props.station_status ?? props.status, props.faction) + const stateGlow = STATION_GLOW[state] + const faction = typeof props.faction === "string" ? (FACTION_LABEL[props.faction] ?? null) : null - const faction = isNow ? nowFaction : snapshot.faction const sector = `SEC-${String(hashCode(node.ref_id) % 999).padStart(3, "0")}` - const title = isNow ? `${snapshot.status} ZONE` : `${snapshot.label} · ${snapshot.year}` + const desc = pickString(props, "description") return (
- {title} + {stateWord(STATION_STATE_LABEL[state])} ZONE {faction ? `${faction} · ${sector}` : sector}
+ {desc && ( +
+ {desc} +
+ )}
diff --git a/src/data/station-timeline.ts b/src/data/station-timeline.ts deleted file mode 100644 index 0a6ad88a..00000000 --- a/src/data/station-timeline.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { GraphNode } from "@/lib/graph-api" -import { - STATION_FILL, - STATION_GLOW, - STATION_STATE_LABEL, - statusToState, - type StationState, -} from "@/components/universe/metro-overlay" - -// Station time dial — five canonical epochs of the Metro universe. The dial -// is a navigation/framing mechanic; it carries only data we can stand behind: -// • Pre-war / War — universal facts of the setting (every Moscow station -// opened as civic infrastructure and sealed as a shelter in 2013). -// • 2033 / 2036 — no per-station records exist yet, so these read as -// "no archival record" rather than inventing station-specific lore. -// • Present day — the real fixture state (status, description). -// Hand-authored or sourced per-era stories can later populate `text` (and the -// 2033/2036 status/faction) without changing the dial itself. - -export type EraId = "prewar" | "war" | "y2033" | "y2036" | "now" - -export interface Era { - id: EraId - // Short dial-notch label. - year: string - // Zone-plate era title. - label: string -} - -export const ERAS: Era[] = [ - { id: "prewar", year: "1935", label: "PRE-WAR ERA" }, - { id: "war", year: "2013", label: "THE WAR" }, - { id: "y2033", year: "2033", label: "YEAR 2033" }, - { id: "y2036", year: "2036", label: "EXODUS ERA" }, - { id: "now", year: "2087", label: "PRESENT DAY" }, -] - -export interface EraSnapshot { - era: EraId - year: string - label: string - // Status chip word + colors for this era. `status` is null when there's no - // record to show (the card then renders a "no archival record" placeholder). - status: string | null - statusFill: string - statusGlow: string - // Zone-plate secondary line (controlling power of the era). - faction: string | null - // The story paragraph — null when no real record exists for the era. - text: string | null -} - -// Chip palette for the canonical bookend statuses. -const ERA_CHIP: Record = { - CIVIC: { fill: "#cfe8ef", glow: "#7fd4e8" }, - SHELTER: { fill: "#d4a017", glow: "#e8b54a" }, - NEUTRAL: { fill: "#9aa3ab", glow: "#b8c2cc" }, -} - -// Dim accent for eras with no record — used by the zone-plate marker bar. -const NO_RECORD = { fill: "#39424a", glow: "#5a646c" } - -// Current-state story templates for the "now" era when a station has no -// authored description. Describes the present real status, not past events. -const NOW_TEXT: Record = { - inhabited: - "Still inhabited in 2087 — cook-fires on the platform, a generation born here that has never seen the sky. The tunnels are watched in both directions.", - neutral: - "Holding on as neutral ground in 2087. Travelers pass through, few stay; the station keeps its lamps low and its opinions lower.", - lost: "Lost. The lights failed decades ago and nobody reclaimed the dark. Caravans seal their masks and pass the platform at a run.", - anomaly: - "An anomaly zone in 2087 — instruments spin, sounds arrive before their causes, and the things on the platform are not always there when you look twice.", - scorched: - "Scorched out — fire took the station and the burn shadow still stains the vault. Nothing has grown back. Nothing will.", - flood: "Drowned. Black water stands to the escalator crowns, and divers tell stories about what swims the lower halls.", - quarantine: - "Under quarantine — the seals went up after the outbreak and no faction has dared cut them since. The warning signs are repainted every year.", -} - -function stateWord(label: string): string { - return label.split(" (")[0].toUpperCase() -} - -function chipSpread(status: string): { statusFill: string; statusGlow: string } { - const c = ERA_CHIP[status] ?? ERA_CHIP.NEUTRAL - return { statusFill: c.fill, statusGlow: c.glow } -} - -export function stationTimeline(node: GraphNode): EraSnapshot[] { - const p = node.properties as Record - const state = statusToState(p.station_status ?? p.status, p.faction) - const description = typeof p.description === "string" ? p.description : null - - // Canonical bookends — true of every station in the setting, so framed - // without inventing station-specific narrative. - const prewar: EraSnapshot = { - era: "prewar", - year: "1935", - label: "PRE-WAR ERA", - status: "CIVIC", - ...chipSpread("CIVIC"), - faction: "MOSCOW METROPOLITEN", - text: null, - } - - const war: EraSnapshot = { - era: "war", - year: "2013", - label: "THE WAR", - status: "SHELTER", - ...chipSpread("SHELTER"), - faction: "CIVIL DEFENSE", - text: null, - } - - // No per-station records for the book years — leave them empty rather than - // fabricate lore. - const y2033: EraSnapshot = { - era: "y2033", - year: "2033", - label: "YEAR 2033", - status: null, - statusFill: NO_RECORD.fill, - statusGlow: NO_RECORD.glow, - faction: null, - text: null, - } - - const y2036: EraSnapshot = { - era: "y2036", - year: "2036", - label: "EXODUS ERA", - status: null, - statusFill: NO_RECORD.fill, - statusGlow: NO_RECORD.glow, - faction: null, - text: null, - } - - const now: EraSnapshot = { - era: "now", - year: "2087", - label: "PRESENT DAY", - status: stateWord(STATION_STATE_LABEL[state]), - statusFill: STATION_FILL[state], - statusGlow: STATION_GLOW[state], - faction: null, - text: description ?? NOW_TEXT[state], - } - - return [prewar, war, y2033, y2036, now] -} From 4236feb984ee948bb8986339c48038972eb6c91f Mon Sep 17 00:00:00 2001 From: Rassl Date: Sun, 14 Jun 2026 21:36:32 +0400 Subject: [PATCH 19/19] fix: skip descendant relayout in metro view (broken deselect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recomputeDescendantLayout re-lays-out the selected node's entire directed subtree and rewrites those nodes' originalPositions to a compact collapse- toward-selection. That's the intended UX for the pure radial graph, but the metro view's positions are data-driven (fixed stations + lifted lore), so the rewrite dragged the schematic into a pile on select and — because it also added originalPositions entries for fixed stations that applyLayout deliberately omits — left everything collapsed after deselect. Gate the recompute on !metroEnabled. Metro fetches now rely on appendToGraph's placeChildren alone (fans new nodes around their parent), leaving existing and fixed-position nodes untouched so deselect restores the clean schematic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/universe/graph-canvas.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 3f37c128..84c1785c 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -1215,8 +1215,16 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // relay out the selected node's descendant subgraph as a clean radial // anchored on the selection — no incremental patching, no reshuffle of // ancestors or other branches, no camera motion. + // + // Skipped in the metro view: recompute re-lays-out the whole directed + // subtree and rewrites its originalPositions to a compact collapse-toward- + // selection. That's correct for the pure radial graph, but the metro view's + // positions are data-driven (fixed stations + lifted lore), so the rewrite + // drags the schematic into a pile on select and leaves it collapsed after + // deselect. Metro fetches rely on appendToGraph's placeChildren alone, which + // fans new nodes around their parent without disturbing existing/fixed nodes. const sel = selectedIdRef.current - if (sel != null && res.model.graph.nodes[sel]) { + if (sel != null && res.model.graph.nodes[sel] && !metroEnabled) { // Nodes with index >= the pre-append count are the freshly added ones. recomputeDescendantLayout(res.model.graph, sel, model.graph.nodes.length) } @@ -1244,7 +1252,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas return { ...vs, visibleNodeIds: Array.from(visible), depthMap } }) } - }, [nodes, edges, schemas, model]) + }, [nodes, edges, schemas, model, metroEnabled]) // Downstream feature code (metro, case-board, station HUD, click/hover) reads // these — derive them from the active model so they track incremental appends.