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/boost/boost-button.tsx b/src/components/boost/boost-button.tsx index ee362c2d..6579c06f 100644 --- a/src/components/boost/boost-button.tsx +++ b/src/components/boost/boost-button.tsx @@ -1,12 +1,13 @@ "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" -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 (
@@ -97,7 +141,7 @@ export function BoostButton({ className )} > - 0 ? count : DEFAULT_BOOST_AMOUNT} - {count > 0 ? "sats" : "boost"} + {count > 0 ? "bullets" : "boost"} {error && ( 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 new file mode 100644 index 00000000..a53d271c --- /dev/null +++ b/src/components/case-board/case-board-animator.tsx @@ -0,0 +1,159 @@ +"use client" + +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" + +// Morph easing durations (seconds), asymmetric on purpose: +// • OPEN — the board blooms in. Snappy but not instant. +// • CLOSE — dismissal should feel immediate, so it's roughly half the open. +// • NAV — a node-to-node switch re-blooms the new neighborhood; in between +// open and close so it reads as a quick "settle", not a full open. +const OPEN_DURATION_S = 0.8 +const CLOSE_DURATION_S = 0.4 +const NAV_DURATION_S = 0.55 +// On a node switch the morph drops to this progress and springs back to 1, so +// the new neighbors collapse toward the arrived node and fan back out — the cue +// that makes navigation read as travel instead of a teleport. +const NAV_DIP = 0.45 + +// Camera move durations (seconds): +// • CAM_MOVE — the first fly-in from wherever the camera was to the board pose. +// • NAV_MOVE — the hop from the old focal to the new one when switching nodes. +const CAM_MOVE_S = 0.85 +const NAV_MOVE_S = 0.55 + +// 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) +} + +interface CaseBoardAnimatorProps { + // World position of the focal node when the morph is opening — drives the + // 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 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. +// +// When the focal node CHANGES while the board stays open (navigating to a +// neighbor), the camera fly + morph dip are re-armed so the transition is felt: +// the camera hops to the new node and its neighborhood blooms back out. +export function CaseBoardAnimator({ focalWorld, cameraRef }: CaseBoardAnimatorProps) { + const target = useCaseBoardStore((s) => s.morphTarget) + const setProgress = useCaseBoardStore((s) => s.setProgress) + const linearRef = useRef(0) + // Active opening/nav morph duration — CLOSE is chosen by direction below. + const morphDurRef = useRef(OPEN_DURATION_S) + + // Camera fly state for the current open / nav hop. + const camProgRef = useRef(1) + const camStartPosRef = useRef<[number, number, number] | null>(null) + const camStartLookRef = useRef<[number, number, number] | null>(null) + const camDurRef = useRef(CAM_MOVE_S) + const armedRef = useRef(false) + // The focal we're currently flying toward — re-arms when it changes (= a + // node switch), distinguishing "open" (was unarmed) from "navigate". + const armedFocalRef = useRef<[number, number, number] | null>(null) + 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) { + armedRef.current = false + armedFocalRef.current = null + morphDurRef.current = OPEN_DURATION_S + } + }, [target]) + + useFrame((_, delta) => { + const cam = cameraRef.current + + // --- Camera: steer to the board pose while open, hop on node switch --- + if (cam && target > 0.001 && focalWorld) { + const af = armedFocalRef.current + const focalChanged = + !af || + Math.abs(af[0] - focalWorld[0]) > 1e-3 || + Math.abs(af[1] - focalWorld[1]) > 1e-3 || + Math.abs(af[2] - focalWorld[2]) > 1e-3 + if (!armedRef.current || focalChanged) { + // Already armed + focal moved ⇒ this is a node-to-node navigation. + const isNav = armedRef.current + 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 + camDurRef.current = isNav ? NAV_MOVE_S : CAM_MOVE_S + armedRef.current = true + armedFocalRef.current = focalWorld + if (isNav) { + // Collapse the neighborhood toward the new focal then spring it back + // out (clamp so we never make it pop further in than it already is). + linearRef.current = Math.min(linearRef.current, NAV_DIP) + morphDurRef.current = NAV_DURATION_S + } else { + morphDurRef.current = OPEN_DURATION_S + } + } + camProgRef.current = Math.min(1, camProgRef.current + delta / camDurRef.current) + 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) { + linearRef.current = target + setProgress(smoothstep(target)) + } + return + } + const dir = target > cur ? 1 : -1 + // Closing is faster than opening/nav; opening + nav re-bloom use morphDurRef. + const dur = dir < 0 ? CLOSE_DURATION_S : morphDurRef.current + const step = (delta / dur) * dir + let next = cur + step + if (dir > 0 && next > target) next = target + if (dir < 0 && next < target) next = target + linearRef.current = next + setProgress(smoothstep(next)) + }) + + return null +} diff --git a/src/components/case-board/case-board-store.ts b/src/components/case-board/case-board-store.ts new file mode 100644 index 00000000..006c4c83 --- /dev/null +++ b/src/components/case-board/case-board-store.ts @@ -0,0 +1,36 @@ +"use client" + +import { create } from "zustand" + +interface CaseBoardState { + // ref_id of the focal node; null = no case board ever opened or fully closed + selectedRefId: string | null + // 0 = pre-morph (3D scene), 1 = fully morphed (case board). Eased by the + // CaseBoardAnimator each frame; read by CaseCard for opacity + drop-in. + morphProgress: number + // Target progress the animator eases toward. open() sets to 1, close() to 0. + morphTarget: number + open: (refId: string) => void + close: () => void + setProgress: (p: number) => void +} + +export const useCaseBoardStore = create((set) => ({ + selectedRefId: null, + morphProgress: 0, + morphTarget: 0, + // Re-focusing on a different node mid-morph keeps the existing progress so + // the camera/card animation continues without a visible reset — i.e. we only + // swap selectedRefId and re-target to 1, leaving morphProgress untouched. + open: (refId) => set({ selectedRefId: refId, morphTarget: 1 }), + close: () => set({ morphTarget: 0 }), + setProgress: (p) => + set((s) => { + // Once the close animation has fully settled, drop the selection so the + // 3D scene can fully reclaim the node and the animator goes idle. + if (p <= 0.001 && s.morphTarget <= 0.001) { + return { morphProgress: 0, selectedRefId: null } + } + return { morphProgress: p } + }), +})) diff --git a/src/components/case-board/case-card.tsx b/src/components/case-board/case-card.tsx new file mode 100644 index 00000000..143b7567 --- /dev/null +++ b/src/components/case-board/case-card.tsx @@ -0,0 +1,389 @@ +"use client" + +import type { GraphNode } from "@/lib/graph-api" +import { + pickString, + DISPLAY_KEY_FALLBACKS, + resolveNodeThumbnail, +} from "@/lib/node-display" + +// Type accents — saturated enough to read as the neon border + pill chrome +// against the very dark card fill. Matches the Peaky Blinders reference +// where each entity type gets its own glow color. +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", +} +const DEFAULT_ACCENT = "#94a3b8" + +const INK_PRIMARY = "#e8edf2" +const INK_BODY = "#c9d1d9" +const INK_DIM = "#6b7280" +const CARD_BG = "#0d1218" +const FIELD_BG = "#070b11" + +const 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 pickFields( + node: GraphNode, + max: number, + valueMax = 60, +): { label: string; value: string }[] { + const props = node.properties as Record | undefined + if (!props) return [] + const clip = (v: string) => (v.length > valueMax ? v.slice(0, valueMax) + "…" : v) + const out: { label: string; value: string }[] = [] + for (const key of Object.keys(props)) { + if (INTERNAL_KEYS.has(key)) continue + const v = props[key] + if (typeof v === "string" && v.length > 0) { + out.push({ label: key, value: clip(v) }) + } else if (typeof v === "number") { + out.push({ label: key, value: String(v) }) + } + if (out.length >= max) break + } + 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: clip(v) }) + break + } + } + } + return out +} + +function pickDescription(node: GraphNode): string | null { + const props = node.properties as Record | undefined + if (!props) return null + return ( + pickString(props, "description") ?? + pickString(props, "summary") ?? + pickString(props, "text") ?? + pickString(props, "bio") ?? + 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.length > TITLE_MAX ? v.slice(0, TITLE_MAX).trimEnd() + "…" : v + } + } + 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" + // 0..1, drives opacity during the morph. Card is unmounted by NodeMorph + // 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, attachedImages }: CaseCardProps) { + const type = node.node_type || "" + const accent = TYPE_ACCENT[type] ?? DEFAULT_ACCENT + const title = pickTitle(node) + const isSelected = variant === "selected" + // Focal card shows the description + a few more fields; neighbors stay tighter + // (hero + title + a few fields). + 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 + const heroHeight = isSelected ? 170 : 132 + + return ( +
+ {thumbnail && ( +
+ )} +
+ {/* Type pill — pill border + text in accent, dark fill */} +
+ {type || "node"} +
+ {/* Title */} +
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} +
+ {description && ( +
0 ? 10 : 0, + display: "-webkit-box", + WebkitLineClamp: 3, + WebkitBoxOrient: "vertical", + overflow: "hidden", + }} + > + {description} +
+ )} + {fields.length > 0 && ( +
+ {fields.map((f) => ( +
+
+ {f.label} +
+ {/* Value box — dark fill with a vertical accent bar on the + left. Matches the Peaky reference's framed-field style. */} +
+
+
+ {f.value} +
+
+
+ ))} +
+ )} + {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/case-group.tsx b/src/components/case-board/case-group.tsx new file mode 100644 index 00000000..ea3e4731 --- /dev/null +++ b/src/components/case-board/case-group.tsx @@ -0,0 +1,380 @@ +"use client" + +import type { GraphNode } from "@/lib/graph-api" +import { DISPLAY_KEY_FALLBACKS, capTitle, resolveNodeThumbnail } from "@/lib/node-display" +import { + accentFor, + INK_PRIMARY, + INK_BODY, + INK_DIM, + CARD_BG, +} from "./card-style" + +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 +} + +// One compact member card — a single thumbnail + title (+ optional meta). +// Reused as the deck's face card (collapsed) and as every tile in the spread +// (expanded). Kept lean so a popped-out group of N reads as N clean cards +// rather than N full property panels. +const MEMBER_W = 168 +const MEMBER_HERO_H = 88 + +function MemberCard({ + node, + accent, + onClick, +}: { + node: GraphNode + accent: string + // Omitted for the deck face card (the deck click unstacks instead). + onClick?: () => void +}) { + const thumb = resolveNodeThumbnail(node) + const title = titleOf(node) + const meta = metaOf(node) + return ( +
{ + e.stopPropagation() + onClick() + } + : undefined + } + style={{ + width: MEMBER_W, + background: CARD_BG, + border: `1px solid ${accent}66`, + borderRadius: 8, + overflow: "hidden", + cursor: onClick ? "pointer" : "default", + boxShadow: `0 0 16px ${accent}14, 0 6px 16px rgba(0,0,0,0.45)`, + }} + > +
+ {!thumb && (title[0]?.toUpperCase() || "•")} +
+
+
+ {title} +
+ {meta && ( +
+ {meta} +
+ )} +
+
+ ) +} + +// Width the expanded spread wraps at — three member tiles per row. +const SPREAD_COLS = 3 +const SPREAD_GAP = 12 +const SPREAD_MAX_W = SPREAD_COLS * MEMBER_W + (SPREAD_COLS - 1) * SPREAD_GAP +// Hard cap on tiles rendered in the spread so a 200-member group can't blow the +// board out. The rest collapse into a "+N more" chip. +const SPREAD_CAP = 24 +// How many cards peek behind the deck's face card, and their per-layer offset. +const DECK_LAYERS = 3 +const DECK_OFFSET = 7 + +export interface CaseGroupProps { + // node_type — drives the label + accent. + type: string + members: GraphNode[] + // Whether the group is unstacked (members spread as tiles) vs stacked (deck). + expanded: boolean + morphProgress: number + onToggle: () => void + onMemberClick: (refId: string) => void +} + +// A labeled group container with two states: +// • stacked (collapsed) — a deck/pile preview; click to unstack +// • unstacked (expanded) — members spread as individual tiles inside the +// container, which grows to fit them; the board re-packs around the new +// measured size. Click the header to re-stack. +export function CaseGroup({ + type, + members, + 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 · count, plus a stack/unstack affordance. */} +
+ + {expanded ? ( +
+ {members.slice(0, SPREAD_CAP).map((m) => ( + onMemberClick(m.ref_id)} + /> + ))} + {count > SPREAD_CAP && ( +
+ +{count - SPREAD_CAP} more +
+ )} +
+ ) : ( + + )} +
+ ) +} + +function Header({ + type, + accent, + count, + expanded, + onToggle, +}: { + type: string + accent: string + count: number + expanded: boolean + onToggle: () => void +}) { + return ( +
+ + {type || "node"} + + + {count} + + + {expanded ? "⊟ stack" : "⊞ unstack"} + +
+ ) +} + +// The stacked preview: a face card with a few offset "backs" peeking behind it, +// and the members' metas fanned below so the group's range stays visible while +// collapsed. The whole thing unstacks on click. +function Deck({ + members, + accent, + onToggle, +}: { + members: GraphNode[] + accent: string + onToggle: () => void +}) { + const layers = Math.min(members.length - 1, DECK_LAYERS) + const metas = members + .map(metaOf) + .filter((m): m is string => !!m) + .slice(0, 5) + return ( +
+
+ {/* Backs — plain offset card shapes peeking down-right. */} + {Array.from({ length: layers }).map((_, i) => { + const off = (layers - i) * DECK_OFFSET + return ( +
+ ) + })} + {/* Face card — frontmost, display-only (the deck click unstacks). */} +
+ +
+
+ {metas.length > 0 && ( +
+ {metas.map((m, i) => ( + {m} + ))} + {members.length > metas.length && metas.length === 5 && ( + + )} +
+ )} +
+ ) +} 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..b2f2bbab --- /dev/null +++ b/src/components/case-board/group-morph.tsx @@ -0,0 +1,74 @@ +"use client" + +import { Html } from "@react-three/drei" +import type { GraphNode } from "@/lib/graph-api" +import { CaseGroup } from "./case-group" + +interface GroupMorphProps { + // Card id (= group key). + id: string + type: string + members: GraphNode[] + 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, + 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 new file mode 100644 index 00000000..2ca0a57c --- /dev/null +++ b/src/components/case-board/index.ts @@ -0,0 +1,10 @@ +export { CaseCard } from "./case-card" +export { NodeMorph } from "./node-morph" +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 new file mode 100644 index 00000000..5ad184bb --- /dev/null +++ b/src/components/case-board/layout.ts @@ -0,0 +1,190 @@ +// Force-directed (Fruchterman-Reingold style) layout for the case board. +// Operates in normalized 2D space — caller maps results into the plane +// perpendicular to the case-board camera direction. +// +// The focal node is anchored at origin; neighbors find positions through +// repulsion (every pair pushes apart) and attraction (edges pull together), +// with iteration cooling. A deterministic RNG seeded by the focal refId +// means re-opening the same node always yields the same layout — no +// shuffling on every entry. + +export type Pos2D = { x: number; y: number } + +export interface ForceLayoutInput { + nodes: string[] + edges: Array<{ a: string; b: string }> + // Refid to keep pinned at (0, 0). null = no anchor. + 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({ + nodes, + edges, + anchorId, + seed, + minSep = 0, +}: ForceLayoutInput): Map { + const n = nodes.length + const pos = new Map() + if (n === 0) return pos + if (n === 1) { + pos.set(nodes[0], { x: 0, y: 0 }) + return pos + } + + // Deterministic seeded LCG so re-opens are stable. + let s = 0 + for (let i = 0; i < seed.length; i++) s = (s * 31 + seed.charCodeAt(i)) | 0 + s = (s >>> 0) || 1 + function rand() { + s = (s * 1664525 + 1013904223) | 0 + return (s >>> 0) / 0xffffffff + } + + // Initial layout: focal at origin, neighbors on a small jittered ring. + // A tight ring start converges faster than pure-random init. + for (let i = 0; i < n; i++) { + const id = nodes[i] + if (id === anchorId) { + pos.set(id, { x: 0, y: 0 }) + continue + } + const angle = (i / n) * Math.PI * 2 + (rand() - 0.5) * 0.6 + const r = 1.1 + rand() * 0.4 + pos.set(id, { x: Math.cos(angle) * r, y: Math.sin(angle) * r }) + } + + const k = 1.5 // ideal edge length in normalized space + const iterations = 250 + + for (let it = 0; it < iterations; it++) { + const forces = new Map() + for (const id of nodes) forces.set(id, { x: 0, y: 0 }) + + // Repulsion: every pair pushes apart with k² / d magnitude. + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const a = pos.get(nodes[i])! + const b = pos.get(nodes[j])! + const dx = b.x - a.x + const dy = b.y - a.y + const d = Math.sqrt(dx * dx + dy * dy) || 0.001 + const f = (k * k) / d + const fx = (dx / d) * f + const fy = (dy / d) * f + const fa = forces.get(nodes[i])! + const fb = forces.get(nodes[j])! + fa.x -= fx + fa.y -= fy + fb.x += fx + fb.y += fy + } + } + + // Attraction along edges with d² / k magnitude. + for (const e of edges) { + const a = pos.get(e.a) + const b = pos.get(e.b) + if (!a || !b) continue + const dx = b.x - a.x + const dy = b.y - a.y + const d = Math.sqrt(dx * dx + dy * dy) || 0.001 + const f = (d * d) / k + const fx = (dx / d) * f + const fy = (dy / d) * f + forces.get(e.a)!.x += fx + forces.get(e.a)!.y += fy + forces.get(e.b)!.x -= fx + forces.get(e.b)!.y -= fy + } + + // Linear cooling — early iterations move freely, late iterations settle. + const temp = Math.max(0.05, 1 - it / iterations) * 0.5 + for (const id of nodes) { + if (id === anchorId) continue + const p = pos.get(id)! + const f = forces.get(id)! + const fmag = Math.sqrt(f.x * f.x + f.y * f.y) || 0.001 + p.x += (f.x / fmag) * Math.min(fmag, temp) + p.y += (f.y / fmag) * Math.min(fmag, temp) + } + } + + // Normalize so the farthest neighbor sits at radius ≈ 1. The caller + // multiplies by world units to scale the whole board. + let maxR = 0 + for (const id of nodes) { + if (id === anchorId) continue + const p = pos.get(id)! + const r = Math.sqrt(p.x * p.x + p.y * p.y) + if (r > maxR) maxR = r + } + if (maxR > 0) { + for (const id of nodes) { + if (id === anchorId) continue + const p = pos.get(id)! + p.x /= maxR + p.y /= maxR + } + } + + // 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 new file mode 100644 index 00000000..143efe85 --- /dev/null +++ b/src/components/case-board/node-morph.tsx @@ -0,0 +1,89 @@ +"use client" + +import { Html } from "@react-three/drei" +import type { GraphNode } from "@/lib/graph-api" +import { CaseCard } from "./case-card" + +interface NodeMorphProps { + // Card id (= focal/neighbor refId). + id: string + node: GraphNode + // Where the node sits in the 3D scene before any morph happened — usually + // its natural radial-layout position. + originPosition: [number, number, number] + // Where the card should land at full morph — for the focal this is the + // same as origin; for neighbors it's a slot on the case-board ring. + targetPosition: [number, number, number] + variant: "selected" | "neighbor" + morphProgress: number + onClick?: () => void + // Optional: where to portal the card DOM. Defaults to the canvas wrapper + // (drei default). We pass a board-layer ref so the cards live inside a + // 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 + // Focal-only: the node's attached images, embedded as a strip in the card. + attachedImages?: GraphNode[] +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} + +// Renders a case-board card at a node's world position, interpolating from +// the original 3D location to the case-board ring slot as morph progresses. +// Lives inside the R3F Canvas; drei's handles world→screen each +// frame so the card tracks the position as the camera moves. +export function NodeMorph({ + node, + originPosition, + targetPosition, + variant, + morphProgress, + onClick, + portal, + registerEl, + attachedImages, +}: NodeMorphProps) { + 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 ( + , but useRef(null) + // returns RefObject. Cast to satisfy the looser drei signature; + // drei reads .current at runtime and is fine with either. + portal={portal as React.RefObject | undefined} + position={pos} + center + // 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/feed/feed-view.tsx b/src/components/feed/feed-view.tsx index 6942f6dd..3bbd5661 100644 --- a/src/components/feed/feed-view.tsx +++ b/src/components/feed/feed-view.tsx @@ -8,6 +8,8 @@ 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" import { HotTakes } from "./hot-takes" import { cn } from "@/lib/utils" @@ -31,20 +33,59 @@ export function FeedView() { setActiveTypes(new Set()) }, [searchTerm, clearSelection]) - // Mocks mode seeds from fixtures so the Latest feed has content before any search. + // 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. + // + // Metro fixture seeding is opt-in via NEXT_PUBLIC_METRO_OVERLAY=1. When + // off, we only load whatever NEXT_PUBLIC_API_URL returns. useEffect(() => { if (useGraphStore.getState().nodes.length > 0) return if (isMocksEnabled()) { setGraphData(MOCK_NODES, MOCK_EDGES) return } + + const metroEnabled = process.env.NEXT_PUBLIC_METRO_OVERLAY === "1" + const fixtureNodes = metroEnabled ? (metroSeries.nodes as GraphNode[]) : [] + const fixtureEdges = metroEnabled ? (metroSeries.edges as GraphEdge[]) : [] + if (metroEnabled) { + setGraphData(fixtureNodes, fixtureEdges) + } + let cancelled = false setLoading(true) ;(async () => { try { const result = await getLatestNodes() if (cancelled) return - setGraphData(result.nodes ?? [], result.edges ?? []) + 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) => + (metroEnabled ? n.node_type !== "Station" : true) && + !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) { + if (!metroEnabled) setGraphData([], []) + return + } + setGraphData([...fixtureNodes, ...extraNodes], [...fixtureEdges, ...extraEdges]) } catch (err) { console.error("[feed-view] getLatestNodes failed:", err) } finally { diff --git a/src/components/layout/attachable-embeds.tsx b/src/components/layout/attachable-embeds.tsx index 389b785c..c587127d 100644 --- a/src/components/layout/attachable-embeds.tsx +++ b/src/components/layout/attachable-embeds.tsx @@ -14,16 +14,35 @@ * NOT derived from the full neighbourhood — a node may have thousands of edges. */ -import { useEffect, useMemo, useState, useCallback } from "react" +import { useEffect, useMemo, useState, useCallback, useRef } from "react" import { createPortal } from "react-dom" -import { Play, Clock, Image as ImageIcon, ChevronRight, X, ChevronLeft } from "lucide-react" +import { Play, Clock, Image as ImageIcon, ChevronRight, X, ChevronLeft, ImagePlus, Loader2, UploadCloud } from "lucide-react" -import { getAttachables } from "@/lib/graph-api" +import { + getAttachables, + addImageContent, + ALLOWED_IMAGE_TYPES, + MAX_IMAGE_UPLOAD_BYTES, +} from "@/lib/graph-api" import type { GraphNode } from "@/lib/graph-api" import { resolveNodeTitle, resolveNodeThumbnail, pickString } from "@/lib/node-display" 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[] @@ -35,6 +54,13 @@ export function AttachableEmbeds({ nodeRefId, schemas, onNavigate }: AttachableE // fetch is in flight, and so we never call setState synchronously in the effect. const [result, setResult] = useState<{ refId: string; peers: GraphNode[] } | null>(null) 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() @@ -53,7 +79,7 @@ export function AttachableEmbeds({ nodeRefId, schemas, onNavigate }: AttachableE if (!controller.signal.aborted) setResult({ refId: nodeRefId, peers: [] }) }) return () => controller.abort() - }, [nodeRefId]) + }, [nodeRefId, reloadNonce]) const peers = result && result.refId === nodeRefId ? result.peers : null @@ -66,9 +92,9 @@ export function AttachableEmbeds({ nodeRefId, schemas, onNavigate }: AttachableE } }, [peers]) - // Nothing fetched yet, or genuinely no attachables → render nothing (no label, - // no empty state — the section simply doesn't exist for nodes without them). - if (!peers || peers.length === 0) 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 (
@@ -84,6 +110,13 @@ export function AttachableEmbeds({ nodeRefId, schemas, onNavigate }: AttachableE onNavigate?.(n)} /> ))} + {canAttach && ( + setReloadNonce((n) => n + 1)} + /> + )} + {lightbox && ( {shown.map((im, i) => ( - + {i === 0 && n > 1 && ( - + {n} images )} {i === cap - 1 && extra > 0 && ( - + +{extra} )} - + {/* Current boost amount + trigger — always visible, FB/X-style. */} +
+ +
+
))}
) @@ -313,8 +359,9 @@ function Lightbox({
)} -
+
{resolveNodeTitle(im, schemas)} +
@@ -341,7 +388,17 @@ function Lightbox({ } /* ── Image thumb with graceful fallback ─────────────────────────────────── */ -function ImageThumb({ node }: { node: GraphNode }) { +// variant="cover" (default) center-crops to fill — fine for small uniform +// thumbnails. variant="fill" shows the WHOLE image (object-contain) over a +// blurred, zoomed copy of itself, so mismatched aspect ratios (e.g. a tall +// full-body shot next to a face crop) aren't cropped and leave no dead space. +function ImageThumb({ + node, + variant = "cover", +}: { + node: GraphNode + variant?: "cover" | "fill" +}) { const src = resolveNodeThumbnail(node) if (!src) { return ( @@ -350,6 +407,23 @@ function ImageThumb({ node }: { node: GraphNode }) { ) } + if (variant === "fill") { + return ( + + + + + ) + } return ( ) } + +/* ── Boost an image — shows current amount and lets anyone boost ─────────── */ +// 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 + 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 ( + + ) + } + // 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 + + ) + } + return null +} + +/* ── 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, +}: { + nodeRefId: string + onAttached: () => void +}) { + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [dragOver, setDragOver] = useState(false) + const inputRef = useRef(null) + + async function describeError(err: unknown): Promise { + if (err instanceof Response) { + const body = (await err.json().catch(() => null)) as { message?: string; errorCode?: string } | null + return body?.message || body?.errorCode || `Upload failed (${err.status})` + } + return err instanceof Error ? err.message : "Upload failed" + } + + const handleFile = useCallback( + async (file: File) => { + if (!ALLOWED_IMAGE_TYPE_SET.has(file.type)) { + setError("Use a JPEG, PNG, WebP, or GIF image") + return + } + if (file.size > MAX_IMAGE_UPLOAD_BYTES) { + setError(`Image must be under ${maxMb} MB`) + return + } + setBusy(true) + setError(null) + try { + // 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) { + setError(await describeError(err)) + } finally { + setBusy(false) + } + }, + [nodeRefId, onAttached] + ) + + // Paste an image anywhere while the panel is open (e.g. a screenshot). + useEffect(() => { + if (!open) return + const onPaste = (e: ClipboardEvent) => { + const imageItem = Array.from(e.clipboardData?.items ?? []).find( + (it) => it.kind === "file" && it.type.startsWith("image/") + ) + const f = imageItem?.getAsFile() + if (f) { + e.preventDefault() + handleFile(f) + } + } + window.addEventListener("paste", onPaste) + return () => window.removeEventListener("paste", onPaste) + }, [open, handleFile]) + + function onDrop(e: React.DragEvent) { + e.preventDefault() + setDragOver(false) + if (busy) return + const file = e.dataTransfer.files?.[0] + if (file) handleFile(file) + } + + if (!open) { + return ( + + ) + } + + 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/components/layout/my-content-panel.tsx b/src/components/layout/my-content-panel.tsx index 20879592..0cf4610a 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" @@ -360,8 +361,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 e57a26b9..75710927 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, useMemo } 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" @@ -33,9 +34,24 @@ import type { AgentChatContext } from "../agent/transcript-chat" import { AttachableEmbeds } from "./attachable-embeds" import { formatDateAbsolute, formatDateRelative } from "@/lib/date-format" import { useGraphStore } from "@/stores/graph-store" +import { metroSeries } from "@/data/metro" const DEEP_RESEARCH_NODE_TYPES = ["Topic"] +// 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" && !UUID_RE.test(n.ref_id)) + .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 @@ -993,6 +1009,11 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp const showThumbnail = !!thumbnail && !isThisNodePlayingHere && !isImageNode && !(unlockState === 'unlocked' && isVideoNode) 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) @@ -1062,6 +1083,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 @@ -1484,8 +1512,8 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp )}
)} @@ -1500,7 +1528,7 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp

Unlock failed — tap to retry

@@ -1559,9 +1587,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 e7115abd..7309a2fb 100644 --- a/src/components/layout/toolkit.tsx +++ b/src/components/layout/toolkit.tsx @@ -6,7 +6,6 @@ import { Layers, Plus, Settings, - Zap, Network, BookMarked, ClipboardList, @@ -16,6 +15,7 @@ import { MessageSquare, Cpu, } 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" @@ -127,7 +127,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() @@ -149,7 +149,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} @@ -301,8 +301,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-edge-form.tsx b/src/components/modals/add-edge-form.tsx index ad471a50..0a5905d5 100644 --- a/src/components/modals/add-edge-form.tsx +++ b/src/components/modals/add-edge-form.tsx @@ -178,7 +178,7 @@ export function AddEdgeForm() { {price !== null && price > 0 && ( - {price} sats + {price} bullets )}
diff --git a/src/components/modals/add-node-form.tsx b/src/components/modals/add-node-form.tsx index 3e2e6696..ebaa8361 100644 --- a/src/components/modals/add-node-form.tsx +++ b/src/components/modals/add-node-form.tsx @@ -531,7 +531,7 @@ export function AddNodeForm() { {/* 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.

)} @@ -540,7 +540,7 @@ export function AddNodeForm() { {statusHint} {price !== null && price > 0 && ( - {price} sats + {price} bullets )}
diff --git a/src/components/modals/add-source-form.tsx b/src/components/modals/add-source-form.tsx index 2bfe1048..d04ac0bb 100644 --- a/src/components/modals/add-source-form.tsx +++ b/src/components/modals/add-source-form.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 { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { MAX_LENGTHS } from "@/lib/input-limits" @@ -459,17 +460,17 @@ export function AddSourceForm() {
- + Cost
- {price} sats + {price} bullets
Budget - {formattedBudget} sats + {formattedBudget} bullets
@@ -488,7 +489,7 @@ export function AddSourceForm() { {/* 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.

)} @@ -543,7 +544,9 @@ export function AddSourceForm() { ) : ( <> - {(price && price > 0) || cacheStatus === "hit-completed" ? ( + {price && price > 0 ? ( + + ) : cacheStatus === "hit-completed" ? ( ) : null} {submitLabel} diff --git a/src/components/modals/budget-modal.tsx b/src/components/modals/budget-modal.tsx index b319566c..a449102d 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, ArrowDownLeft } from "lucide-react" +import { Copy, Check, Loader2, ArrowLeft, History, Key, RefreshCw, ArrowUpRight, Clock, ArrowDownLeft } from "lucide-react" +import { BulletIcon } from "@/components/ui/bullet-icon" import { QRCodeSVG } from "qrcode.react" import { Dialog, @@ -59,7 +60,7 @@ function WithdrawStep({
Amount - {decodedAmountSats !== null ? `${decodedAmountSats.toLocaleString()} sats` : "Amountless — not supported"} + {decodedAmountSats !== null ? `${decodedAmountSats.toLocaleString()} bullets` : "Amountless — not supported"}
{withdrawExpiresAt !== null && ( @@ -412,7 +413,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 } @@ -492,7 +493,7 @@ export function BudgetModal() { return } if (decodedAmountSats < MINIMUM_WITHDRAWAL_SATS) { - setError("Minimum withdrawal is 100 sats") + setError("Minimum withdrawal is 100 bullets") return } if (decodedAmountSats > (budget ?? 0)) { @@ -509,7 +510,7 @@ export function BudgetModal() { } catch (err: unknown) { const errorCode = (err as { errorCode?: string })?.errorCode if (errorCode === "BELOW_MINIMUM") { - setError("Minimum withdrawal is 100 sats") + setError("Minimum withdrawal is 100 bullets") } else if (errorCode === "INSUFFICIENT_BALANCE") { setError("Insufficient balance for withdrawal") } else if (errorCode === "INVOICE_EXPIRED") { @@ -620,10 +621,10 @@ export function BudgetModal() { {formattedBudget} - sats + bullets
- + @@ -640,7 +641,7 @@ export function BudgetModal() { {loading ? ( ) : ( - + )} {loading ? "Processing..." : "Top Up"} @@ -696,10 +697,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. @@ -714,7 +715,7 @@ export function BudgetModal() { {loading ? ( ) : ( - + )} {loading ? "Checking..." : "Pay Pending Invoice"} @@ -739,7 +740,7 @@ export function BudgetModal() { }`} > {preset} - sats + bullets ))}

@@ -756,7 +757,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}

} @@ -769,7 +770,7 @@ export function BudgetModal() { {loading ? ( ) : ( - + )} {loading ? "Generating Invoice..." : "Generate Invoice"} @@ -865,7 +866,7 @@ export function BudgetModal() { {preset} - sats + bullets ))} @@ -884,7 +885,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
@@ -900,7 +901,7 @@ export function BudgetModal() { {loading ? ( ) : ( - + )} {loading ? "Processing..." @@ -1021,7 +1022,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`}
))} @@ -1125,13 +1126,13 @@ export function BudgetModal() {

{!isWithdrawSuccess && 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..24024443 --- /dev/null +++ b/src/components/ui/bullet-icon.tsx @@ -0,0 +1,36 @@ +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/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index c701cbaa..84c1785c 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -2,1087 +2,62 @@ 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" -import type CameraControlsImpl from "camera-controls" +import CameraControlsImpl from "camera-controls" import { - adaptiveRadius, - buildGraph, - computeRadialLayout, - extractInitialSubgraph, extractSubgraph, - VIRTUAL_CENTER, GraphView, OffscreenIndicators, PrevNodeIndicator, } from "@/graph-viz-kit" -import type { - Graph, - ViewState, - RawNode, - RawEdge, - Vec3, - GraphNode as VizNode, - GraphEdge as VizEdge, -} 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" import { HoverPreviewCard } from "./hover-preview-card" -import { DISPLAY_KEY_FALLBACKS, resolveNodeThumbnail } from "@/lib/node-display" - -function nodeLabel(node: ApiNode, schemas: SchemaNode[]): string { - const props = node.properties - const schema = schemas.find((s) => s.type === node.node_type) - - if (schema?.title_key) { - const v = props?.[schema.title_key] - if (typeof v === "string" && v.length > 0) return v - } - if (schema?.index) { - const v = props?.[schema.index] - if (typeof v === "string" && v.length > 0) return v - } - if (props) { - for (const key of DISPLAY_KEY_FALLBACKS) { - const v = props[key] - if (typeof v === "string" && v.length > 0) return v - } - } - return node.ref_id -} - -const MAX_LABEL_LENGTH = 30 - -function truncateLabel(label: string): string { - return label.length > MAX_LABEL_LENGTH ? label.slice(0, MAX_LABEL_LENGTH) + "\u2026" : label -} - -// When a single source has this many neighbors of the same (edge_type, -// target_type), insert a synthetic cluster junction so the bundle reads as -// "source → cluster → 20 leaves" instead of 20 individual lines fanning out. -const CLUSTER_THRESHOLD = 5 +import { + NodeMorph, + CaseBoardAnimator, + useCaseBoardStore, + GroupMorph, + computeBalancedLayout, +} from "@/components/case-board" +import { + apiToGraph, + applyLayout, + appendToGraph, + recomputeDescendantLayout, + describeSubgraph, + DEPTH_SHRINK, + rescaleAroundAnchor, + restoreOriginalPositions, + type GraphModel, +} from "./graph-transform" +import { metroSeries } from "@/data/metro" +import { + MetroLinesLayer, + MetroStationBullets, + MetroLegend, + statusToState, + readStationLines, + type StationState, +} from "./metro-overlay" +import { + StationHudScene, + StationZonePlate, + type SceneNeighbor, +} from "./station-hud-scene" // 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 // stay as glyph-only spotlights and reveal their label on hover. const SEARCH_LABEL_CAP = 15 -// Edge types whose data direction is "child → parent" — flip them so the -// hierarchy reads parent → child. The container/originator should end up -// as the visual parent: -// SOURCE Claim → Chapter (Chapter is the source, parent of the claim) -// MENTIONED_IN Product/Topic → Section (Section is the container, parent of the mention) -const INVERT_FOR_HIERARCHY = new Set(["SOURCE", "MENTIONED_IN"]) - -export function apiToGraph( - nodes: ApiNode[], - edges: ApiEdge[], - schemas: SchemaNode[] -): { graph: Graph; indexMap: Map; refIdToIndex: Map } { - const rawNodes: RawNode[] = nodes.map((n) => ({ - id: n.ref_id, - label: truncateLabel(nodeLabel(n, schemas)), - })) - - const nodeTypeById = new Map(nodes.map((n) => [n.ref_id, n.node_type || "Unknown"])) - - // Rewrite child→parent edges (e.g. SOURCE: Claim→Chapter) into parent→child - // form so every downstream pass — incoming-count, bundles, rawEdges — sees - // the same hierarchy. The render arrow ends up pointing parent→child, which - // matches the visual we want. - edges = edges.map((e) => - INVERT_FOR_HIERARCHY.has(e.edge_type) - ? { ...e, source: e.target, target: e.source } - : e - ) - - // ─── 1. Roots + orphan reachability ──────────────────────────────────── - // Compute on the original `edges` (cluster routing happens later and - // doesn't change reachability). Only count incoming from known sources — - // edges referencing nodes outside the loaded subgraph would otherwise - // mark a real node as "non-root" without contributing to reachability, - // leaving its subgraph stranded as orphans. - const incomingCount = new Map() - for (const n of nodes) incomingCount.set(n.ref_id, 0) - for (const e of edges) { - if (incomingCount.has(e.target) && incomingCount.has(e.source)) { - incomingCount.set(e.target, (incomingCount.get(e.target) ?? 0) + 1) - } - } - const roots = nodes.filter((n) => (incomingCount.get(n.ref_id) ?? 0) === 0) - - const undAdj = new Map() - for (const n of nodes) undAdj.set(n.ref_id, []) - for (const e of edges) { - if (undAdj.has(e.source) && undAdj.has(e.target)) { - undAdj.get(e.source)!.push(e.target) - undAdj.get(e.target)!.push(e.source) - } - } - const reached = new Set() - const reachQ: string[] = [] - for (const r of roots) { - reached.add(r.ref_id) - reachQ.push(r.ref_id) - } - let reachI = 0 - while (reachI < reachQ.length) { - const cur = reachQ[reachI++] - for (const nb of undAdj.get(cur) ?? []) { - if (!reached.has(nb)) { - reached.add(nb) - reachQ.push(nb) - } - } - } - const orphans = nodes.filter((n) => !reached.has(n.ref_id)) - - // ─── 2. Decide which types get a __group_ ──────────────────────── - // A type gets its own synthetic group node when it has orphans, OR when it - // has ≥ CLUSTER_THRESHOLD top-level (parentless / root) nodes in the CURRENT - // payload. Crowd-grouping keys purely on count: no overall root-count gate - // and no "leaf-like" filter — a parentless node is top-level *right now* - // regardless of whether it has children loaded or a parent that exists only - // in the DB. (The DB hierarchy is irrelevant; only what's on screen counts.) - const orphanTypes = new Set(orphans.map((o) => o.node_type || "Unknown")) - const rootCountByType = new Map() - for (const r of roots) { - const type = r.node_type || "Unknown" - rootCountByType.set(type, (rootCountByType.get(type) ?? 0) + 1) - } - const crowdGroupedTypes = new Set() - for (const [type, count] of rootCountByType) { - if (count >= CLUSTER_THRESHOLD) crowdGroupedTypes.add(type) - } - const groupedTypes = new Set([...orphanTypes, ...crowdGroupedTypes]) - - // ─── 3. Bundle by (source, edge_type, target_type) ───────────────────── - const bundles = new Map() - for (const e of edges) { - const tgtType = nodeTypeById.get(e.target) - if (!tgtType) continue - const key = `${e.source}::${e.edge_type}::${tgtType}` - let arr = bundles.get(key) - if (!arr) { - arr = [] - bundles.set(key, arr) - } - arr.push(e) - } - - // ─── 4. Process bundles ──────────────────────────────────────────────── - // Bundles ≥ CLUSTER_THRESHOLD become a per-source cluster — the parent - // keeps ownership ("Episode → Chapter × 9 → 9 chapters"), and the type's - // own __group_ stays reserved for nodes with no real parent (roots - // and orphans). - const clusterizedEdges = new Set() - const clusteredTargets = new Set() - const extraNodes: RawNode[] = [] - const extraEdges: RawEdge[] = [] - - for (const [key, arr] of bundles) { - if (arr.length < CLUSTER_THRESHOLD) continue - const [source, edge_type, target_type] = key.split("::") - // Skip clusters whose source isn't in the loaded payload — buildGraph drops - // edges with unknown endpoints, so the cluster's parent edge would vanish - // and the proxy would end up as an orphan synthetic root with no visible - // parent. Let the targets fall back to __group_ grouping instead. - if (!nodeTypeById.has(source)) continue - const clusterId = `__cluster_${source}_${edge_type}_${target_type}` - extraNodes.push({ id: clusterId, label: `${target_type} × ${arr.length} · ${edge_type}` }) - extraEdges.push({ source, target: clusterId, label: edge_type }) - for (const e of arr) { - extraEdges.push({ source: clusterId, target: e.target, label: edge_type }) - clusterizedEdges.add(e) - clusteredTargets.add(e.target) - } - } - - // ─── 5. Build rawEdges (excluding clusterized) ───────────────────────── - const rawEdges: RawEdge[] = [] - for (const e of edges) { - if (clusterizedEdges.has(e)) continue - rawEdges.push({ source: e.source, target: e.target, label: e.edge_type }) - } - rawNodes.push(...extraNodes) - rawEdges.push(...extraEdges) - - // ─── 6. Add __group_ nodes + member edges ──────────────────────── - // Members = roots of type + orphans of type, minus anything already wired - // into a per-source cluster. Without that exclusion, when a cluster's - // source isn't in the loaded subgraph the cluster's children look like - // roots and end up double-bound: once under `__cluster_…_T × N` and again - // under `__group_T`, producing two visual representations of the same type. - if (groupedTypes.size > 0) { - const memberByType = new Map>() - for (const t of groupedTypes) memberByType.set(t, new Set()) - for (const r of roots) { - if (clusteredTargets.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 - const t = o.node_type || "Unknown" - if (groupedTypes.has(t)) memberByType.get(t)!.add(o.ref_id) - } - for (const [t, members] of memberByType) { - if (members.size === 0) continue - const groupId = `__group_${t}` - rawNodes.push({ id: groupId, label: t }) - for (const m of members) { - rawEdges.push({ source: groupId, target: m }) - } - } - } - - const graph = buildGraph(rawNodes, rawEdges) - - // Set nodeType (and thumbnail, when present) on real nodes - for (let i = 0; i < nodes.length; i++) { - graph.nodes[i].nodeType = nodes[i].node_type - const thumb = resolveNodeThumbnail(nodes[i]) - if (thumb) graph.nodes[i].imageUrl = thumb - } - // Mark synthetic nodes — clusters get their own marker so renderers can - // distinguish them from the older top-level type bundlers (`_group`). - // Also record the underlying member type so the shader can pick a - // type-specific glyph (Person clusters render differently from Tweet - // clusters, etc.). - for (let i = nodes.length; i < graph.nodes.length; i++) { - const id = rawNodes[i].id - if (id.startsWith("__cluster_")) { - graph.nodes[i].nodeType = "_cluster" - // id = __cluster___ — target_type is last. - const lastUnderscore = id.lastIndexOf("_") - graph.nodes[i].clusterMemberType = id.slice(lastUnderscore + 1) - } else { - graph.nodes[i].nodeType = "_group" - // id = __group_ - graph.nodes[i].clusterMemberType = id.slice("__group_".length) - } - } - - // Only map real nodes — synthetic nodes have no API counterpart - const indexMap = new Map() - const refIdToIndex = new Map() - for (let i = 0; i < nodes.length; i++) { - indexMap.set(i, nodes[i].ref_id) - refIdToIndex.set(nodes[i].ref_id, i) - } - - // Resolve cluster-absorbed edges against the same index map and stash them - // on the graph as `extraEdges` so the hover/select highlight can surface - // them without polluting the base render or layout. - const idToIndex = new Map() - for (let i = 0; i < rawNodes.length; i++) idToIndex.set(rawNodes[i].id, i) - graph.extraEdges = [] - for (const e of clusterizedEdges) { - const src = idToIndex.get(e.source) - const dst = idToIndex.get(e.target) - if (src === undefined || dst === undefined) continue - graph.extraEdges.push({ src, dst, label: e.edge_type }) - } - - return { graph, indexMap, refIdToIndex } -} - -function applyLayout(graph: Graph) { - // 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) - const { positions, treeEdgeSet, childrenOf } = computeRadialLayout( - sub.centerId, - sub.neighborsByDepth, - graph.edges, - { parentId: sub.parentId } - ) - - for (const [id, pos] of positions) { - if (id !== VIRTUAL_CENTER && id < graph.nodes.length) { - graph.nodes[id].position = pos - } - } - - // Anything BFS never reached (cycle-only components, synthetic nodes the - // layout missed) keeps the (0,0,0) default from buildGraph and piles at the - // 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 (stray.length > 0) { - let maxR = 0 - for (const [id, p] of positions) { - if (id === VIRTUAL_CENTER) continue - const r = Math.hypot(p.x, p.z) - if (r > maxR) maxR = r - } - const ringR = (maxR || 22) * 1.5 + 30 - const angleStep = (Math.PI * 2) / stray.length - for (let i = 0; i < stray.length; i++) { - const angle = i * angleStep - graph.nodes[stray[i]].position = { - x: Math.cos(angle) * ringR, - y: 0, - z: Math.sin(angle) * ringR, - } - } - } - - graph.initialDepthMap = sub.depthMap - graph.treeEdgeSet = treeEdgeSet - 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. - const snapshot = new Map() - for (let i = 0; i < graph.nodes.length; i++) { - const p = graph.nodes[i].position - snapshot.set(i, { x: p.x, y: p.y, z: p.z }) - } - graph.originalPositions = snapshot -} - -// Default ring radius for appended children when their parent has no existing -// children to size against — matches MIN_R1 / the R1 ring a freshly clicked -// node's children land on after rescale, so a fetch attaches at the same scale. -const APPEND_CHILD_R = 33 -// Small per-depth vertical drop so appended children tier below their parent, -// mirroring computeRadialLayout's y-offset feel without recomputing it. -const APPEND_Y_DROP = 4.5 - -// Place a batch of freshly-appended `kids` on a ring around their already-placed -// `parent`, updating the derived layout structures (depth, originals, tree -// edges, childrenOf) so the new nodes behave like first-class layout members on -// subsequent clicks/rescales. -function placeChildren( - nodes: VizNode[], - parent: number, - kids: number[], - initialDepthMap: Map, - originalPositions: Map, - childrenOf: Map, - treeEdgeSet: Set -): void { - const pPos = nodes[parent].position - const childDepth = (initialDepthMap.get(parent) ?? 0) + 1 - - const existingKids = childrenOf.get(parent) ?? [] - const existingCount = existingKids.length - const total = existingCount + kids.length - - // Radius sized by the total child count — same adaptiveRadius hop-1 uses — so - // a parent that fetches many neighbors gets a proportionally larger ring - // instead of crowding them all onto a fixed-radius circle (the center-clump - // bug). If the parent already has placed children, average their radius - // instead so appended nodes stay on the subtree's established ring. - let R = Math.max(APPEND_CHILD_R, adaptiveRadius(total)) - if (existingCount > 0) { - let sum = 0 - let cnt = 0 - for (const k of existingKids) { - const kp = nodes[k]?.position - if (!kp) continue - sum += Math.hypot(kp.x - pPos.x, kp.z - pPos.z) - cnt++ - } - if (cnt > 0) R = sum / cnt - } - - // Fan outward (away from origin), continuing past any existing children so - // new arrivals don't stack on top of them. - const outward = Math.atan2(pPos.z, pPos.x) || 0 - const step = (Math.PI * 2) / Math.max(total, 1) - - if (!childrenOf.has(parent)) childrenOf.set(parent, []) - const kidList = childrenOf.get(parent)! - - for (let j = 0; j < kids.length; j++) { - const v = kids[j] - const phi = outward + (existingCount + j) * step - const pos: Vec3 = { - x: pPos.x + Math.cos(phi) * R, - y: pPos.y - APPEND_Y_DROP, - z: pPos.z + Math.sin(phi) * R, - } - nodes[v].position = pos - originalPositions.set(v, { ...pos }) - initialDepthMap.set(v, childDepth) - kidList.push(v) - treeEdgeSet.add(parent < v ? `${parent}-${v}` : `${v}-${parent}`) - } -} - -interface GraphModel { - graph: Graph - indexMap: Map - refIdToIndex: Map -} - -interface AppendResult { - model: GraphModel - /** Indices of the nodes added by this append. */ - newNodeIds: number[] - /** For each appended node, the node it was placed under (absent for strays). */ - parentOf: Map -} - -// Fold freshly-fetched nodes/edges into an existing graph WITHOUT re-running -// apiToGraph or the global radial layout. Existing node objects are reused -// verbatim (same positions, same indices) so a click-driven 1-hop fetch just -// attaches the new nodes around their parent — no reshuffle, no camera jump. -// This is the path GraphView's `nodeCountGrew` snap branch was written for. -// -// Grouping IS applied, but only to the freshly-arriving batch: new children of -// the same (source, edge_type, target_type) that cross CLUSTER_THRESHOLD get a -// synthetic `_cluster` proxy (source → proxy → members), mirroring apiToGraph. -// Because append never rebuilds, a proxy created here becomes a permanent node -// with a fixed index — no cross-rebuild identity drift, which is what sank the -// old position-cache attempts. Existing nodes (and their existing clustering) -// are never re-evaluated. -export function appendToGraph( - model: GraphModel, - apiNodes: ApiNode[], - apiEdges: ApiEdge[], - schemas: SchemaNode[] -): AppendResult | null { - const prev = model.graph - const oldCount = prev.nodes.length - - const refIdToIndex = new Map(model.refIdToIndex) - const indexMap = new Map(model.indexMap) - - // ── New real nodes (members). Append-only: existing indices stay put. ── - // Drill-down rule: a fetched node is kept ONLY if it can attach as a - // descendant of something already on screen — i.e. it is reachable, through - // the freshly-fetched edges, from an existing node. New nodes that connect - // only to other new nodes with no path back into the current graph are - // strays that don't belong under the selected node's hierarchy, so they are - // dropped outright (not parked on an outer ring). Reachability is undirected - // — we are grafting the new material below the selection, regardless of the - // original edge direction. - const existingRefIds = new Set(refIdToIndex.keys()) - const refAdj = new Map() - const linkRef = (a: string, b: string) => { - const l = refAdj.get(a) - if (l) l.push(b) - else refAdj.set(a, [b]) - } - for (const e of apiEdges) { - linkRef(e.source, e.target) - linkRef(e.target, e.source) - } - const reachableNew = new Set() - const visited = new Set(existingRefIds) - const queue: string[] = [] - for (const ref of existingRefIds) if (refAdj.has(ref)) queue.push(ref) - for (let qi = 0; qi < queue.length; qi++) { - for (const nb of refAdj.get(queue[qi]) ?? []) { - if (visited.has(nb)) continue - visited.add(nb) - reachableNew.add(nb) // existing refs were pre-seeded, so nb is always new - queue.push(nb) - } - } - const newApiNodes = apiNodes.filter( - (n) => !refIdToIndex.has(n.ref_id) && reachableNew.has(n.ref_id) - ) - const memberObjs: VizNode[] = newApiNodes.map((n, k) => { - const thumb = resolveNodeThumbnail(n) - return { - id: oldCount + k, - label: truncateLabel(nodeLabel(n, schemas)), - position: { x: 0, y: 0, z: 0 }, - degree: 0, - nodeType: n.node_type, - ...(thumb != null && { imageUrl: thumb }), - } - }) - for (let k = 0; k < newApiNodes.length; k++) { - const idx = oldCount + k - refIdToIndex.set(newApiNodes[k].ref_id, idx) - indexMap.set(idx, newApiNodes[k].ref_id) - } - - const typeOf = (i: number): string => - (i < oldCount ? prev.nodes[i].nodeType : newApiNodes[i - oldCount]?.node_type) || "Unknown" - const isNew = (i: number): boolean => i >= oldCount - - // ── Resolve candidate new edges (hierarchy rewrite, resolve, dedupe) ── - interface Cand { - src: number - dst: number - edge_type: string - } - // Dedup against BOTH live edges and cluster-absorbed originals. The absorbed - // source→member edges live in extraEdges (pulled out of graph.edges when the - // cluster formed); if we don't count them here, a re-fetch that returns the - // same source→member edge re-adds it as a live direct edge, bypassing the - // proxy and flattening the hierarchy (chapters/locations jump back to hop-1). - const seen = new Set([ - ...prev.edges.map((e) => `${e.src} ${e.dst}`), - ...(prev.extraEdges ?? []).map((e) => `${e.src} ${e.dst}`), - ]) - const candidates: Cand[] = [] - for (const raw of apiEdges) { - const e = INVERT_FOR_HIERARCHY.has(raw.edge_type) - ? { source: raw.target, target: raw.source, edge_type: raw.edge_type } - : raw - const src = refIdToIndex.get(e.source) - const dst = refIdToIndex.get(e.target) - if (src === undefined || dst === undefined || src === dst) continue - const key = `${src} ${dst}` - if (seen.has(key)) continue - seen.add(key) - candidates.push({ src, dst, edge_type: e.edge_type }) - } - - if (memberObjs.length === 0 && candidates.length === 0) return null - - // ── Bundle fresh child edges by (source, edge_type, target_type). ── - const bundles = new Map< - string, - { src: number; edge_type: string; tgtType: string; edges: Cand[] } - >() - for (const c of candidates) { - if (!isNew(c.dst)) continue - const tgtType = typeOf(c.dst) - const key = `${c.src} ${c.edge_type} ${tgtType}` - let b = bundles.get(key) - if (!b) { - b = { src: c.src, edge_type: c.edge_type, tgtType, edges: [] } - bundles.set(key, b) - } - b.edges.push(c) - } - - // ── Reconcile each bundle against what the source ALREADY has for the same - // key, so a relationship never ends up split across direct edges + one-or- - // more proxies (the cluster-bypass bug). Two things get merged in: - // • an existing `_cluster` proxy for the key → reuse it (no second - // proxy); new members route through it. - // • the source's existing *direct leaf* children of the key → absorb - // them: their direct edge moves to extraEdges and they re-home onto - // the proxy ring (a localized move of just those leaves). - // A key clusters when existing-direct + existing-proxy-members + new - // members together cross the threshold — not just the fresh batch. ── - const edgeLabelOf = new Map() - for (const e of prev.edges) edgeLabelOf.set(`${e.src} ${e.dst}`, e.label ?? "") - const isSynthetic = (i: number): boolean => { - const t = i < oldCount ? prev.nodes[i].nodeType : typeOf(i) - return t === "_cluster" || t === "_group" - } - const isProxyChild = (i: number): boolean => - (prev.inAdj[i] ?? []).some((p) => p < oldCount && prev.nodes[p].nodeType === "_cluster") - // Real, non-synthetic leaf (no real children of its own) — safe to re-home - // onto a proxy without stranding a subtree underneath it. - const isAbsorbableLeaf = (i: number): boolean => { - if (i >= oldCount || isSynthetic(i)) return false - for (const ch of prev.outAdj[i] ?? []) if (!isSynthetic(ch)) return false - return true - } - - // Existing same-key proxies in the prev graph, keyed exactly like `bundles`. - const existingProxyByKey = new Map() - for (let i = 0; i < oldCount; i++) { - if (prev.nodes[i].nodeType !== "_cluster") continue - const psrc = (prev.inAdj[i] ?? [])[0] - if (psrc === undefined) continue - const et = edgeLabelOf.get(`${psrc} ${i}`) ?? "" - const tt = prev.nodes[i].clusterMemberType ?? "" - existingProxyByKey.set(`${psrc} ${et} ${tt}`, i) - } - - const absorbed = new Set() - const proxyObjs: VizNode[] = [] - const proxyRouting: { - proxy: number - src: number - members: number[] - absorb: number[] - edge_type: string - isExisting: boolean - }[] = [] - let proxyCursor = oldCount + memberObjs.length - for (const b of bundles.values()) { - const key = `${b.src} ${b.edge_type} ${b.tgtType}` - const existingProxy = existingProxyByKey.get(key) - - // Source's existing direct leaf children of the same key, eligible to absorb. - const absorb: number[] = [] - for (const m of prev.outAdj[b.src] ?? []) { - if (!isAbsorbableLeaf(m)) continue - if (typeOf(m) !== b.tgtType) continue - if ((edgeLabelOf.get(`${b.src} ${m}`) ?? "") !== b.edge_type) continue - if (isProxyChild(m)) continue - absorb.push(m) - } - - const existingMembers = - existingProxy !== undefined ? (prev.outAdj[existingProxy]?.length ?? 0) : 0 - const prospective = b.edges.length + absorb.length + existingMembers - - // No existing proxy and not enough to form one → leave as direct edges. - if (existingProxy === undefined && prospective < CLUSTER_THRESHOLD) continue - - let proxy: number - if (existingProxy !== undefined) { - proxy = existingProxy - } else { - proxy = proxyCursor++ - proxyObjs.push({ - id: proxy, - label: "", // finalized from the true member count once edges are wired - position: { x: 0, y: 0, z: 0 }, - degree: 0, - nodeType: "_cluster", - clusterMemberType: b.tgtType, - }) - } - - proxyRouting.push({ - proxy, - src: b.src, - members: b.edges.map((e) => e.dst), - absorb, - edge_type: b.edge_type, - isExisting: existingProxy !== undefined, - }) - for (const e of b.edges) absorbed.add(e) - } - - // Each clustered member → its cluster source. Used to drop ANY direct edge - // between the two (including the reciprocal member→source the backend often - // also returns) so it doesn't bypass the proxy with a direct line. - const memberSource = new Map() - for (const r of proxyRouting) for (const m of r.members) memberSource.set(m, r.src) - - const nodes: VizNode[] = [...prev.nodes, ...memberObjs, ...proxyObjs] - const total = nodes.length - - // Absorbed existing leaves get re-positioned, and reused existing proxies get - // a fresh label/degree — clone those node objects so prev's stay untouched. - for (const r of proxyRouting) { - if (r.isExisting) nodes[r.proxy] = { ...nodes[r.proxy] } - for (const m of r.absorb) nodes[m] = { ...nodes[m] } - } - - // ── Adjacency: copy existing rows, empty rows for new members + proxies ── - const adj: number[][] = new Array(total) - const outAdj: number[][] = new Array(total) - const inAdj: number[][] = new Array(total) - for (let i = 0; i < total; i++) { - adj[i] = i < oldCount ? prev.adj[i].slice() : [] - outAdj[i] = i < oldCount ? prev.outAdj[i].slice() : [] - inAdj[i] = i < oldCount ? prev.inAdj[i].slice() : [] - } - - const edges: VizEdge[] = prev.edges.slice() - const extraEdges: VizEdge[] = (prev.extraEdges ?? []).slice() - const addEdge = (src: number, dst: number, label: string) => { - edges.push({ src, dst, label }) - adj[src].push(dst) - adj[dst].push(src) - outAdj[src].push(dst) - inAdj[dst].push(src) - } - const removeOne = (arr: number[], val: number) => { - const k = arr.indexOf(val) - if (k !== -1) arr.splice(k, 1) - } - // Strip the direct edge between two nodes (either direction) from the live - // edge list + adjacency, so an absorbed leaf no longer connects to its old - // source — its relation lives on the proxy spoke + extraEdges instead. - const detachDirect = (s: number, d: number) => { - for (let k = edges.length - 1; k >= 0; k--) { - const e = edges[k] - if ((e.src === s && e.dst === d) || (e.src === d && e.dst === s)) edges.splice(k, 1) - } - removeOne(adj[s], d) - removeOne(adj[d], s) - removeOne(outAdj[s], d) - removeOne(inAdj[d], s) - removeOne(outAdj[d], s) - removeOne(inAdj[s], d) - } - - // Proxy routing: source → proxy → members in the layout; the absorbed - // source → member originals move to extraEdges (surfaced on hover/select, - // matching apiToGraph). GraphView drops the proxy → member spokes from the - // render automatically (edges out of a `_cluster` node). - for (const r of proxyRouting) { - // Reused proxies already carry the source → proxy edge; only new ones need it. - if (!r.isExisting) addEdge(r.src, r.proxy, r.edge_type) - for (const m of r.members) { - addEdge(r.proxy, m, r.edge_type) - extraEdges.push({ src: r.src, dst: m, label: r.edge_type }) - } - // Absorb existing direct leaves: cut the bypassing source → leaf edge and - // re-route it through the proxy (spoke + extraEdge), exactly like a new - // member, so nothing connects to the source except via the cluster. - for (const m of r.absorb) { - detachDirect(r.src, m) - addEdge(r.proxy, m, r.edge_type) - extraEdges.push({ src: r.src, dst: m, label: r.edge_type }) - } - } - // Non-clustered new edges go in directly — except a direct member↔source - // edge (either direction), which the proxy routing already represents. - for (const c of candidates) { - if (absorbed.has(c)) continue - if (memberSource.get(c.src) === c.dst || memberSource.get(c.dst) === c.src) continue - addEdge(c.src, c.dst, c.edge_type) - } - - for (let i = oldCount; i < total; i++) nodes[i].degree = adj[i].length - - // Proxy label + degree reflect the FINAL member count (existing + absorbed + - // new) — outAdj[proxy] now holds every spoke, so its length IS the count. - for (const r of proxyRouting) { - const p = nodes[r.proxy] - p.label = `${p.clusterMemberType ?? ""} × ${outAdj[r.proxy].length} · ${r.edge_type}` - p.degree = adj[r.proxy].length - } - - // ── Clone derived structures so prev stays intact ── - const childrenOf = new Map() - if (prev.childrenOf) for (const [k, v] of prev.childrenOf) childrenOf.set(k, v.slice()) - const treeEdgeSet = new Set(prev.treeEdgeSet ?? []) - const initialDepthMap = new Map(prev.initialDepthMap ?? []) - const originalPositions = new Map(prev.originalPositions ?? []) - - // Re-home absorbed leaves in the tree structures: drop their old source link - // so placeChildren re-attaches them under the proxy (with a fresh position) - // in the placement pass below. - const absorbSet = new Set() - for (const r of proxyRouting) { - for (const m of r.absorb) { - absorbSet.add(m) - const sibs = childrenOf.get(r.src) - if (sibs) removeOne(sibs, m) - treeEdgeSet.delete(r.src < m ? `${r.src}-${m}` : `${m}-${r.src}`) - } - } - - // ── Place new nodes around an already-placed parent, in waves so chains of - // new nodes (A→B→C) place parents before their children. Absorbed leaves - // are treated like new nodes here so they re-home onto the proxy ring. ── - const parentOf = new Map() - const placed = new Set() - for (let i = 0; i < oldCount; i++) if (!absorbSet.has(i)) placed.add(i) - - // A clustered member must hang off its proxy, never a cross-edge neighbor — - // so it waits for the proxy rather than falling back to inAdj/adj. - const forcedParent = new Map() - for (const r of proxyRouting) { - for (const m of r.members) forcedParent.set(m, r.proxy) - for (const m of r.absorb) forcedParent.set(m, r.proxy) - } - - const pickParent = (v: number): number | undefined => { - const forced = forcedParent.get(v) - if (forced !== undefined) return placed.has(forced) ? forced : undefined - for (const p of inAdj[v]) if (placed.has(p)) return p // directed parent first - for (const p of adj[v]) if (placed.has(p)) return p // else any placed neighbor - return undefined - } - - let pending = [ - ...proxyObjs.map((n) => n.id), - ...memberObjs.map((n) => n.id), - ...absorbSet, - ] - let progress = true - while (pending.length > 0 && progress) { - progress = false - const byParent = new Map() - const stillPending: number[] = [] - for (const v of pending) { - const p = pickParent(v) - if (p === undefined) { - stillPending.push(v) - continue - } - if (!byParent.has(p)) byParent.set(p, []) - byParent.get(p)!.push(v) - } - for (const [p, kids] of byParent) { - placeChildren(nodes, p, kids, initialDepthMap, originalPositions, childrenOf, treeEdgeSet) - for (const v of kids) { - placed.add(v) - parentOf.set(v, p) - } - progress = true - } - pending = stillPending - } - - // Defensive: true strays (new nodes with no path back to the graph) are now - // dropped upstream by the descendant-reachability filter, so this should be - // empty. Anything still here only reached the graph through a directed edge - // pickParent couldn't resolve — park it on an outer ring rather than lose it. - if (pending.length > 0) { - let maxR = APPEND_CHILD_R - for (const pos of originalPositions.values()) { - const r = Math.hypot(pos.x, pos.z) - if (r > maxR) maxR = r - } - const ringR = maxR * 1.2 + 20 - const step = (Math.PI * 2) / pending.length - for (let i = 0; i < pending.length; i++) { - const v = pending[i] - const pos: Vec3 = { x: Math.cos(i * step) * ringR, y: 0, z: Math.sin(i * step) * ringR } - nodes[v].position = pos - originalPositions.set(v, { ...pos }) - initialDepthMap.set(v, 1) - } - } - - const graph: Graph = { - ...prev, - nodes, - edges, - adj, - outAdj, - inAdj, - extraEdges, - childrenOf, - treeEdgeSet, - initialDepthMap, - originalPositions, - } - return { - model: { graph, indexMap, refIdToIndex }, - newNodeIds: [...proxyObjs, ...memberObjs].map((n) => n.id), - parentOf, - } -} - - -// Matches DEPTH_SHRINK in computeRadialLayout. Click inflation is the -// inverse: 1/0.45^d makes the ring around a depth-d node land at R1 again. -const DEPTH_SHRINK = 0.45 - -// Re-scale the graph about a fixed anchor node. The anchor (the clicked -// node) stays at its current position; every other node's offset *from the -// anchor* in the original layout is multiplied by `scale`. With -// scale = 1/0.45^d, the anchor's children land on a true R1 ring while the -// anchor itself doesn't move on screen — no camera motion required. -function rescaleAroundAnchor(graph: Graph, anchorId: number, scale: number) { - if (!graph.originalPositions) return - const origAnchor = graph.originalPositions.get(anchorId) - const liveAnchor = graph.nodes[anchorId]?.position - if (!origAnchor || !liveAnchor) return - const ax = liveAnchor.x - const ay = liveAnchor.y - const az = liveAnchor.z - for (const [id, orig] of graph.originalPositions) { - if (id >= graph.nodes.length) continue - graph.nodes[id].position = { - x: ax + (orig.x - origAnchor.x) * scale, - y: ay + (orig.y - origAnchor.y) * scale, - z: az + (orig.z - origAnchor.z) * scale, - } - } -} - -function restoreOriginalPositions(graph: Graph) { - if (!graph.originalPositions) return - for (const [id, orig] of graph.originalPositions) { - if (id < graph.nodes.length) { - graph.nodes[id].position = { x: orig.x, y: orig.y, z: orig.z } - } - } -} - -// Debug helper: summarize a node's descendant subgraph (labels by depth) for -// logging select / merge / recalculate steps. -function describeSubgraph( - graph: Graph, - centerId: number, - useAdj: "directed" | "undirected" = "directed" -) { - const lbl = (id: number) => graph.nodes[id]?.label ?? `#${id}` - const sub = extractSubgraph(graph, centerId, 1000, { useAdj }) - return { - center: lbl(centerId), - total: sub.nodeIds.length, - depthCounts: sub.neighborsByDepth.map((ds) => ds.length), - byDepth: sub.neighborsByDepth.map( - (ds, i) => `d${i + 1} (${ds.length}): ${ds.map(lbl).join(", ")}` - ), - } -} - -// Recompute ONLY the selected node's descendant subgraph as a fresh radial, -// translated so the selected node stays exactly where it already is (camera -// doesn't move). Ancestors and unrelated branches are left untouched. This is -// the "add new node, recalculate the subgraph" model — after a fetch folds new -// descendants in, we relay them out cleanly instead of patching positions in -// place. Updates positions, originalPositions, the tree-edge set and depth map -// for the recomputed nodes so rescale/reset/edge-rendering stay consistent. -function recomputeDescendantLayout(graph: Graph, selectedId: number, oldCount: number) { - const anchorNode = graph.nodes[selectedId] - if (!anchorNode) return - - // Descendants only (directed BFS via outAdj) — never climbs to ancestors. - const sub = extractSubgraph(graph, selectedId, 1000, { useAdj: "directed" }) - // If the fetch surfaced the selected node's hierarchical PARENT for the first - // time (e.g. clicking a parentless Claim pulls in its Chapter, which is - // chapter→claim, so the chapter is the claim's parent / inAdj), hand it to - // computeRadialLayout as the parentId so it lands in the dedicated parent slot - // opposite the children — not grafted as a stray. Only a BRAND-NEW parent is - // placed; an ancestor already on screen is left where it is. - const newParentId = graph.inAdj[selectedId]?.find((p) => p >= oldCount) - const { positions, treeEdgeSet, childrenOf } = computeRadialLayout( - selectedId, - sub.neighborsByDepth, - graph.edges, - newParentId !== undefined ? { parentId: newParentId } : undefined - ) - - // Two scales coexist while a node is selected: - // • LIVE positions (what you see) — the spread-out view: computeRadialLayout - // already emits this at R1 ring scale, so new nodes match the existing - // spread-out children. - // • originalPositions (the collapse target on deselect) — the compact global - // layout, where a depth-`d` node's rings are shrunk by DEPTH_SHRINK^d. - // Writing the spread value to BOTH (the old bug) bakes the spread in so - // deselect can't collapse. So: live = full spread, original = spread × shrink. - const depth = Math.max(0, graph.initialDepthMap?.get(selectedId) ?? 0) - const shrink = Math.pow(DEPTH_SHRINK, depth) - - console.log( - "[recalc] recomputed descendant subgraph", - { depth, shrink, ...describeSubgraph(graph, selectedId, "directed") } - ) - - // Stable placement so existing nodes are *trackable* across the relayout. - // Walk the recomputed tree from the selected node (which stays fixed at its - // current spot). For each parent, its children share one ring — common radius - // + y-offset, evenly-spaced angle slots. Assign each EXISTING child to the - // slot nearest its CURRENT angle, and give NEW children the leftover slots. - // Existing nodes drift to the closest spot (small, followable move) instead of - // being reshuffled; new nodes fall into the gaps and fly in. - const anchor = { ...anchorNode.position } - const angDiff = (a: number, b: number) => - Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b))) - type P3 = { x: number; y: number; z: number } - const live = new Map() - live.set(selectedId, anchor) - - const queue: number[] = [selectedId] - while (queue.length > 0) { - const P = queue.shift()! - const kids = childrenOf.get(P) ?? [] - if (kids.length === 0) continue - const Pnew = positions.get(P) - const Pfin = live.get(P) - if (!Pnew || !Pfin) continue - - // Each kid's recompute offset from its parent → (radius, y-delta, slot angle). - const slot = kids.map((k) => { - const pk = positions.get(k) ?? Pnew - const dx = pk.x - Pnew.x, dy = pk.y - Pnew.y, dz = pk.z - Pnew.z - return { r: Math.hypot(dx, dz), y: dy, angle: Math.atan2(dz, dx) } - }) - - const assigned = new Array(kids.length).fill(-1) - const freeSlots = new Set(kids.map((_, i) => i)) - const existing: number[] = [] - kids.forEach((k, i) => { - if (k < oldCount) existing.push(i) - }) - - // Greedy global nearest-slot match for existing kids (minimizes total angular - // movement); whatever's left goes to new kids in order. - const pairs: { ki: number; si: number; d: number }[] = [] - for (const ki of existing) { - const c = graph.nodes[kids[ki]].position - const a = Math.atan2(c.z - Pfin.z, c.x - Pfin.x) - for (let si = 0; si < kids.length; si++) { - pairs.push({ ki, si, d: angDiff(a, slot[si].angle) }) - } - } - pairs.sort((p, q) => p.d - q.d) - for (const { ki, si } of pairs) { - if (assigned[ki] !== -1 || !freeSlots.has(si)) continue - assigned[ki] = si - freeSlots.delete(si) - } - const leftovers = [...freeSlots] - let li = 0 - for (let i = 0; i < kids.length; i++) { - if (assigned[i] === -1) assigned[i] = leftovers[li++] - } - - kids.forEach((k, i) => { - const s = slot[assigned[i]] - live.set(k, { - x: Pfin.x + Math.cos(s.angle) * s.r, - y: Pfin.y + s.y, - z: Pfin.z + Math.sin(s.angle) * s.r, - }) - queue.push(k) - }) - } - - // Place the brand-new parent (if any) at its dedicated back slot, translated - // to the anchor like everything else, so it reads as "above" the selection. - if (newParentId !== undefined) { - const pp = positions.get(newParentId) - const origin = positions.get(selectedId) ?? { x: 0, y: 0, z: 0 } - if (pp) { - live.set(newParentId, { - x: anchor.x + (pp.x - origin.x), - y: anchor.y + (pp.y - origin.y), - z: anchor.z + (pp.z - origin.z), - }) - } - } - - // Two scales coexist while selected: LIVE = the stabilized spread-out layout; - // originalPositions = the same layout scaled toward the selected node by the - // layer's DEPTH_SHRINK^depth factor (the compact view deselect collapses to). - for (const [id, p] of live) { - if (id < 0 || id >= graph.nodes.length) continue - graph.nodes[id].position = { x: p.x, y: p.y, z: p.z } - graph.originalPositions?.set(id, { - x: anchor.x + (p.x - anchor.x) * shrink, - y: anchor.y + (p.y - anchor.y) * shrink, - z: anchor.z + (p.z - anchor.z) * shrink, - }) - } - - // Tree edges within the recomputed subgraph: drop the stale ones touching - // these nodes, add the fresh set, so straight-vs-curved edge rendering tracks - // the new hierarchy. - if (graph.treeEdgeSet) { - const inSub = new Set(positions.keys()) - for (const k of [...graph.treeEdgeSet]) { - const [a, b] = k.split("-").map(Number) - if (inSub.has(a) && inSub.has(b)) graph.treeEdgeSet.delete(k) - } - for (const k of treeEdgeSet) graph.treeEdgeSet.add(k) - } - - // Global depth = selected node's global depth + local subgraph depth, so a - // later click's rescale keys off the right tier. - if (graph.initialDepthMap) { - const baseDepth = graph.initialDepthMap.get(selectedId) ?? 0 - for (const [id, d] of sub.depthMap) { - if (id >= 0 && id < graph.nodes.length) graph.initialDepthMap.set(id, baseDepth + d) - } - } - - if (graph.childrenOf) for (const [k, v] of childrenOf) graph.childrenOf.set(k, v) -} - interface CamTarget { posX: number posY: number @@ -1126,6 +101,36 @@ 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 + function smoothstep(x: number) { return x * x * (3 - 2 * x) } @@ -1176,10 +181,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 @@ -1201,6 +218,132 @@ function CameraSync({ return null } +// Press-and-hold target on the selected node. Pressing starts filling a +// circular progress ring; holding it to completion opens the in-3D case board. +// Replaces the old ⤢ button and the dolly-in auto-open — holding the node IS +// the gesture now. A quick click just flickers and cancels, so it can't open +// accidentally. +function CaseViewTrigger({ + graph, + selectedNodeId, + selectedApiNode, + onOpen, + disabled, +}: { + graph: Graph + selectedNodeId: number | null + selectedApiNode: ApiNode | null + onOpen: (node: ApiNode) => void + disabled: boolean +}) { + const [progress, setProgress] = useState(0) + const rafRef = useRef(0) + const startRef = useRef(0) + const holdingRef = useRef(false) + + const stop = useCallback(() => { + holdingRef.current = false + cancelAnimationFrame(rafRef.current) + setProgress(0) + }, []) + + const start = useCallback(() => { + if (!selectedApiNode) return + holdingRef.current = true + startRef.current = performance.now() + const tick = () => { + if (!holdingRef.current) return + const t = Math.min(1, (performance.now() - startRef.current) / CASE_VIEW_HOLD_MS) + setProgress(t) + if (t >= 1) { + holdingRef.current = false + setProgress(0) + onOpen(selectedApiNode) + return + } + rafRef.current = requestAnimationFrame(tick) + } + rafRef.current = requestAnimationFrame(tick) + }, [selectedApiNode, onOpen]) + + // Cancel any in-flight hold when the selection changes, the board opens, or + // the component unmounts. Done in the effect CLEANUP (not the body) so the + // reset's setState doesn't run synchronously inside the effect. + useEffect(() => stop, [selectedNodeId, disabled, stop]) + + if (disabled || selectedNodeId === null || !selectedApiNode) return null + const node = graph.nodes[selectedNodeId] + if (!node) return null + const p = node.position + + const SIZE = 64 + const R = 26 + const C = 2 * Math.PI * R + const holding = progress > 0 + return ( + +
{ + 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. */} + + +
+ + ) +} + // 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 @@ -1323,6 +466,624 @@ function DebugMarkers({ ) } +// Camera placement for the case-board view, expressed as an offset from the +// focal node. The same vector is used by CaseBoardAnimator (to setLookAt the +// 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) +// 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. A deck only earns its container when there are enough +// same-(type, relationship) neighbors that loose cards would clutter — so a +// pair or a trio stays as plain cards and stacking begins at 4+. (1 = group +// every pair, which over-grouped a 2-neighbor station into a "STATION 2" deck.) +const HYBRID_THRESHOLD = 3 + +// 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: defaults to the STACKED deck, whose footprint is roughly a + // single member tile plus the offset backs + header + meta row. The real size + // is measured once rendered (and re-measured when unstacked), so this only + // needs to be close for the first pre-measurement frame. + return { w: 220, h: 250 } +} + +// 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 +// hard cut. Lower = more visible 3D ghost; 1 = fully opaque cream. +export const CASE_BOARD_BACKDROP_OPACITY = 0.92 + +// Z-index layering for the case-board overlays. drei's defaults to +// zIndexRange [16777271, 0] for its label portals, so anything that has to +// 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 lines + dots: between cream + cards (tunnel under) + cardFar: 16777400, + cardNear: 16777500, + connectorLabels: 16777700, // edge pills: ABOVE cards so they never clip + button: 16778000, +} + +// World→screen projection shared between the in-Canvas emitter and the +// out-of-Canvas connectors SVG. Mutable ref so the SVG can update path d +// attributes via its own rAF without re-rendering React 60fps. One entry +// per visible node (focal + neighbors); the SVG looks each end up by refId +// when drawing per-edge connectors. +export type ProjectionsRef = { + positions: Map +} + +// Inside Canvas: each frame, project every visible (focal + neighbor) +// interpolated world position to screen coords and write into the shared +// ref. Renders nothing — purely a side-effect bridge to the SVG outside +// the Canvas. +function ProjectionEmitter({ + projectionsRef, + items, + morphProgress, +}: { + projectionsRef: React.RefObject + items: { id: string; origin: [number, number, number]; target: [number, number, number] }[] + morphProgress: number +}) { + const camera = useThree((s) => s.camera) + const size = useThree((s) => s.size) + useFrame(() => { + const r = projectionsRef.current + if (!r) return + const t = Math.max(0, Math.min(1, morphProgress)) + const next = new Map() + for (const item of items) { + const wx = item.origin[0] + (item.target[0] - item.origin[0]) * t + const wy = item.origin[1] + (item.target[1] - item.origin[1]) * t + const wz = item.origin[2] + (item.target[2] - item.origin[2]) * t + const v = new Vector3(wx, wy, wz).project(camera) + next.set(item.id, { + x: (v.x + 1) * 0.5 * size.width, + y: (-v.y + 1) * 0.5 * size.height, + }) + } + r.positions = next + }) + return null +} + +// Renders the in-3D case board. The focal node + 1-hop neighbors get laid +// out via a force-directed sim in the plane perpendicular to the case- +// board camera direction — neighbors that share edges cluster naturally +// instead of all hanging off a perfect star. Mounts the camera animator +// + projection emitter alongside the cards. +function CaseBoardMorphLayer({ + graph, + refIdToIndex, + nodes, + selectedRefId, + morphProgress, + cameraRef, + projectionsRef, + cardPortalRef, + cardElsRef, + items, +}: { + graph: Graph + refIdToIndex: Map + nodes: ApiNode[] + selectedRefId: string + morphProgress: number + cameraRef: React.RefObject + projectionsRef: React.RefObject + cardPortalRef: React.RefObject + cardElsRef: React.RefObject> + items: BoardItem[] +}) { + 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) + + // 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 + if (!p) return null + 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. + 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() + // 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, ...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 * worldPerPx) + .add(up.clone().multiplyScalar(pos.y * worldPerPx)) + const target = focal.clone().add(offset) + entries.push({ + item, + origin: focalWorld, + target: [target.x, target.y, target.z], + }) + } + return entries + }, [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. + 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 itemTargets) { + list.push({ id: e.item.id, origin: e.origin, target: e.target }) + } + return list + }, [focalWorld, selectedRefId, itemTargets]) + + // Which groups are unstacked (members spread as tiles) vs stacked (deck). + // Local to the open session — resets on close since the layer unmounts. + // Groups default to STACKED so the board opens tidy; the user unstacks a + // group to inspect its members. Tracking the expanded set keeps "stacked" the + // default without seeding state from the (changing) group list. + const [expandedKeys, setExpandedKeys] = useState>(() => new Set()) + const toggleGroup = useCallback((key: string) => { + setExpandedKeys((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + + return ( + <> + + + {selectedNode && focalWorld && ( + registerCard(selectedRefId, el)} + attachedImages={attachedImages} + /> + )} + {itemTargets.map(({ item, origin, target }) => + item.kind === "node" ? ( + useCaseBoardStore.getState().open(item.id)} + portal={cardPortalRef} + registerEl={(el) => registerCard(item.id, el)} + /> + ) : ( + toggleGroup(item.id)} + onMemberClick={(refId) => useCaseBoardStore.getState().open(refId)} + originPosition={origin} + targetPosition={target} + morphProgress={morphProgress} + portal={cardPortalRef} + registerEl={(el) => registerCard(item.id, el)} + /> + ), + )} + + ) +} + +// SVG overlay that hosts the connector graphics — dashed lines, endpoint +// dots, mid-edge "linked to" pills. Each frame, an in-Canvas projection +// emitter writes focal + neighbor screen coords to a shared ref; this +// component's own rAF reads that ref and updates the SVG element attrs +// imperatively (no React re-renders during pan/zoom/morph). +const CONNECTOR_COLOR = "#4a90e2" +const CONNECTOR_COLOR_DIM = "rgba(74, 144, 226, 0.55)" +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. a/b are refIds (a = focal/source). + edges: { id: string; a: string; b: string; label: string }[] + morphProgress: number +}) { + const pathRefs = useRef>(new Map()) + const dotARefs = useRef>(new Map()) + const dotBRefs = useRef>(new Map()) + const labelGRefs = useRef>(new Map()) + + useEffect(() => { + let raf = 0 + function tick() { + const proj = projectionsRef.current?.positions + const els = cardElsRef.current + 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 (!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 + + // 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 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) { + path.setAttribute( + "d", + `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", ax.toFixed(1)) + da.setAttribute("cy", ay.toFixed(1)) + } + const db = dotBRefs.current.get(e.id) + if (db) { + db.setAttribute("cx", bx.toFixed(1)) + db.setAttribute("cy", by.toFixed(1)) + } + const labelG = labelGRefs.current.get(e.id) + if (labelG) { + // 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)})`) + } + } + } + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [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) => { + const label = (e.label || "linked to").toUpperCase() + const pillW = Math.max(48, label.length * 7 + 18) + return ( + + { + if (el) pathRefs.current.set(e.id, el) + else pathRefs.current.delete(e.id) + }} + stroke={CONNECTOR_COLOR_DIM} + strokeWidth={1.4} + strokeDasharray="6 5" + fill="none" + strokeLinecap="round" + /> + { + if (el) dotARefs.current.set(e.id, el) + else dotARefs.current.delete(e.id) + }} + r={4} + fill={CONNECTOR_COLOR} + stroke={CARD_BG_FOR_DOT} + strokeWidth={1.5} + /> + { + if (el) dotBRefs.current.set(e.id, el) + else dotBRefs.current.delete(e.id) + }} + r={4} + fill={CONNECTOR_COLOR} + stroke={CARD_BG_FOR_DOT} + strokeWidth={1.5} + /> + { + if (el) labelGRefs.current.set(e.id, el) + else labelGRefs.current.delete(e.id) + }} + > + + + {label} + + + + ) + })} + + ) +} + +// Small dark border around endpoint dots so they read as distinct chips +// rather than blending into the dashed line. Matches the case-board's +// 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[] @@ -1337,26 +1098,79 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const dataVersion = useGraphStore((s) => s.dataVersion) const searchTerm = useAppStore((s) => s.searchTerm) - // Declared early so the incremental-append effect below can reveal newly - // attached nodes inside the active subgraph focus. - const [viewState, setViewState] = useState({ mode: "overview" }) + // Metro overlay is opt-in via NEXT_PUBLIC_METRO_OVERLAY=1. When off, no + // fixture data is spliced into the graph and the schematic layers don't + // render — useful when pointing at a non-metro backend dataset. + const metroEnabled = process.env.NEXT_PUBLIC_METRO_OVERLAY === "1" + + // 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[]) : [] + + // 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 (!metroEnabled) 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 + }, [nodes, metroEnabled]) + + const effectiveEdges = useMemo(() => { + if (!metroEnabled) 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 + }, [edges, effectiveNodes, metroEnabled]) - // Current selection, mirrored into a ref so the append effect can recompute - // the right subgraph without taking viewState as a dependency (which would - // re-fire it on every camera/visibility change). + // Selection mirrored into a ref so the append effect can recompute the right + // subgraph without taking viewState as a dependency (which would re-fire it + // on every camera/visibility change). viewState is declared here, above the + // data layer, because the incremental-append effect below reads it. + const [viewState, setViewState] = useState({ mode: "overview" }) const selectedIdRef = useRef(null) selectedIdRef.current = viewState.mode === "subgraph" ? viewState.selectedNodeId : null // Full rebuild (apiToGraph + global radial layout) only on a NEW dataset // (dataVersion bump from setGraphData) or a schema change — never on addNodes - // appends. Rebuilding on every nodes/edges change is the reshuffle we avoid; - // appends are folded in incrementally below via appendToGraph. + // appends. Rebuilding on every nodes/edges change was the reshuffle that made + // the camera jump when related data loaded; appends are now folded in + // incrementally below via appendToGraph. effectiveNodes/effectiveEdges (metro + // fixture splice) are read at build time, and the fork's applyLayout + // signature keeps the lore Y-lift + fixed station positions. const baseModel = useMemo(() => { - const result = apiToGraph(nodes, edges, schemas) - applyLayout(result.graph) + const result = apiToGraph(effectiveNodes, effectiveEdges, schemas) + applyLayout(result.graph, result.fixedPositions, true) return result - // eslint-disable-next-line react-hooks/exhaustive-deps -- nodes/edges read at build time but intentionally NOT deps; see comment above + // eslint-disable-next-line react-hooks/exhaustive-deps -- effectiveNodes/Edges read at build time but intentionally NOT deps; rebuild only on dataset/schema swap, appends fold in incrementally }, [dataVersion, schemas]) // Bumps once per full rebuild (new baseModel). GraphView uses it to snap on @@ -1397,23 +1211,20 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const res = appendToGraph(model, nodes, edges, schemas) if (!res) return - console.log( - "[merge] appended", - res.newNodeIds.length, - "nodes:", - res.newNodeIds.map((id) => res.model.graph.nodes[id]?.label), - "| clusters now:", - res.model.graph.nodes - .filter((n) => n.nodeType === "_cluster" || n.nodeType === "_group") - .map((n) => n.label) - ) - // "Add new node, recalculate": once the new descendants are folded in, // 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) } @@ -1441,10 +1252,43 @@ 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. const { graph, indexMap, refIdToIndex } = model + // 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(() => { @@ -1455,8 +1299,282 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas return map }, [schemas]) + // In-3D case board state — subscribed via the case-board store. Open is + // triggered by either the close-up zoom (CaseViewTrigger) or the manual + // "open case board" button. The whole transition stays in the 3D scene: + // cards appear as Html overlays at node world positions, camera tilts to + // a front-of-node view, morphProgress drives card opacity + scale-in. + const morphSelectedRefId = useCaseBoardStore((s) => s.selectedRefId) + const morphProgress = useCaseBoardStore((s) => s.morphProgress) + const morphTarget = useCaseBoardStore((s) => s.morphTarget) + const morphOpen = morphTarget > 0.001 || morphProgress > 0.001 + + // Shared between the in-Canvas ProjectionEmitter (writer) and the + // out-of-Canvas CaseBoardConnectorsSvg (reader). Holds the most recent + // world→screen projection of the focal + each neighbor's interpolated + // position. Mutable ref so the SVG can repaint via its own rAF without + // touching React state per frame. + const projectionsRef = useRef({ 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) + // 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 + // 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 p = boardPanRef.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) => { + boardPanRef.current = pan + boardZoomRef.current = zoom + applyBoardTransform() + }, + [applyBoardTransform], + ) + const dragStateRef = useRef<{ + startX: number + startY: number + startPanX: number + startPanY: number + moved: boolean + } | null>(null) + const [isDragging, setIsDragging] = useState(false) + + // Snap pan/zoom back to identity whenever the morph closes so the next + // open always starts centered. Without this, the board would remember + // 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 — no setState, no re-render. + setBoard({ x: 0, y: 0 }, 1) + }, [morphOpen, setBoard]) + + const handleBoardMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only start a pan gesture on left button. Right-click / middle stay + // available for browser context menu / future tools. + if (e.button !== 0) return + dragStateRef.current = { + startX: e.clientX, + startY: e.clientY, + startPanX: boardPanRef.current.x, + startPanY: boardPanRef.current.y, + moved: false, + } + }, + [], + ) + + const handleBoardMouseMove = useCallback( + (e: React.MouseEvent) => { + const drag = dragStateRef.current + if (!drag) return + const dx = e.clientX - drag.startX + const dy = e.clientY - drag.startY + // 3px threshold so a clean click on a card doesn't register as a + // micro-drag and consume the click event. + if (!drag.moved && Math.abs(dx) < 3 && Math.abs(dy) < 3) return + if (!drag.moved) { + drag.moved = true + setIsDragging(true) + } + setBoard( + { x: drag.startPanX + dx, y: drag.startPanY + dy }, + boardZoomRef.current, + ) + }, + [setBoard], + ) + + const handleBoardMouseUp = useCallback(() => { + dragStateRef.current = null + setIsDragging(false) + }, []) + + 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 + // viewport center. + e.stopPropagation() + // Normalize across deltaMode: pixels (0), lines (1), pages (2). + const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1 + const delta = e.deltaY * unit + const factor = Math.exp(-delta * 0.0015) + + const z = boardZoomRef.current + 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 + + // 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). + const p = boardPanRef.current + setBoard( + { + x: cx - applied * (cx - p.x), + y: cy - applied * (cy - p.y), + }, + next, + ) + }, + [setBoard], + ) + + // 1-hop neighbor ref_ids of the morph-selected node. Hoisted out of + // CaseBoardMorphLayer so the connectors SVG (sibling, not child) can + // share the same set without duplicating the edge scan. + const morphNeighborIds = useMemo(() => { + if (!morphSelectedRefId) return [] + const out: string[] = [] + const seen = new Set([morphSelectedRefId]) + for (const e of effectiveEdges) { + let nb: string | null = null + if (e.source === morphSelectedRefId) nb = e.target + else if (e.target === morphSelectedRefId) nb = e.source + if (nb && !seen.has(nb)) { + seen.add(nb) + out.push(nb) + } + } + return out + }, [morphSelectedRefId, effectiveEdges]) + + // Edges to render on the case board: every edge whose both endpoints + // are part of the visible set (focal + 1-hop neighbors). Includes + // neighbor-to-neighbor edges so the board reads as a network instead of + // a star. De-duped by canonical key. + const morphVisibleEdges = useMemo(() => { + if (!morphSelectedRefId) return [] + const visible = new Set([morphSelectedRefId, ...morphNeighborIds]) + const out: { id: string; a: string; b: string; label: string }[] = [] + const seen = new Set() + for (const e of effectiveEdges) { + if (!visible.has(e.source) || !visible.has(e.target)) continue + const lo = e.source < e.target ? e.source : e.target + const hi = e.source < e.target ? e.target : e.source + const key = `${lo}|${hi}|${e.edge_type}` + if (seen.has(key)) continue + seen.add(key) + out.push({ id: key, a: e.source, b: e.target, label: e.edge_type }) + } + 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 = effectiveNodeByRefId.get(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, effectiveNodeByRefId]) + + // 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 + // 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) // True while the user is actively dragging/rotating/dollying the camera. // Suppresses hover firing on whatever nodes happen to sweep under the @@ -1511,14 +1629,126 @@ 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. useEffect(() => { + // 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) + useCaseBoardStore.getState().close() }, [dataVersion, setCamTarget]) + const selectedApiNode = useMemo(() => { + if (viewState.mode !== "subgraph") return null + const refId = indexMap.get(viewState.selectedNodeId) + if (!refId) return null + // Resolve from effectiveNodeByRefId so metro Station nodes — which only + // live in the spliced fixture set, not the graph store `nodes` — can open + // the 2D case view. nodeByRefId (built from `nodes` only) returns null for + // them, which left CaseViewTrigger disabled on every station. The board + // itself already operates on effectiveNodes, so opening on a station works. + 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]) + + // 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 + // need it since cards are sized in screen-space via distanceFactor. + const handleOpenCaseView = useCallback( + (node: ApiNode) => { + useCaseBoardStore.getState().open(node.ref_id) + }, + [], + ) + + // Closes the in-3D case board: drops morph state and pulls the camera back + // to the selected node's rest distance so the user has room to maneuver. + const handleCloseCaseBoard = useCallback(() => { + useCaseBoardStore.getState().close() + if (viewState.mode === "subgraph") { + 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, flyCamTo, metroEnabled, selectedApiNode]) + const externalHoveredId = sidebarHoveredNode ? (refIdToIndex.get(sidebarHoveredNode.ref_id) ?? null) : null const externalSelectedId = sidebarSelectedNode ? (refIdToIndex.get(sidebarSelectedNode.ref_id) ?? null) : null @@ -1532,6 +1762,93 @@ 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). + const stateHoverMatches = useMemo(() => { + if (!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]) + + // Hovering a metro line spotlights every node tagged with that line. + const lineHoverMatches = useMemo(() => { + if (!hoveredLine) return null + const set = new Set() + for (let i = 0; i < nodes.length; i++) { + const p = nodes[i].properties as Record | undefined + if (readStationLines(p).includes(hoveredLine)) set.add(i) + } + return set.size > 0 ? set : null + }, [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(() => { + const stationLines = new Map>() + for (const n of nodes) { + if (n.node_type !== "Station") continue + const p = n.properties as Record | undefined + const lines = new Set(readStationLines(p)) + 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]) + + // 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 (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() + }, [ + hoveredLine, + hoveredCardNode, + sidebarHoveredNode, + sidebarSelectedNode, + viewState, + indexMap, + nodeToLines, + ]) + // All search hits, sorted by descending score. Drives both the top-3 // amplification ranks and the label cap below, so they stay consistent. const sortedHits = useMemo(() => { @@ -1594,10 +1911,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) => { @@ -1614,7 +1931,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) } @@ -1692,7 +2009,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 @@ -1700,7 +2059,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, effectiveNodeByRefId, effectiveEdges, refIdToIndex, metroEnabled, onNodeSelect, setCamTarget, flyCamTo, searchTerm] ) const handleReset = useCallback(() => { @@ -1713,25 +2072,77 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas useGraphStore.getState().setHoveredNode(null) }, [graph, setCamTarget]) + // Lock CameraControls into a 2D-feeling pan + zoom mode while the case + // board is open: drag = truck (parallel to view plane), wheel = dolly, + // rotate disabled. Reverts to the default 3D orbit controls on close. + useEffect(() => { + const cc = cameraRef.current + if (!cc) return + if (morphOpen) { + // 3D camera fully locked while the board is up. Pan + zoom for the + // board happen on a separate DOM layer (BoardPanZoom below) so they + // don't move the underlying 3D scene at all — true Miro-style + // independent whiteboard. + cc.mouseButtons.left = CameraControlsImpl.ACTION.NONE + cc.mouseButtons.right = CameraControlsImpl.ACTION.NONE + cc.mouseButtons.middle = CameraControlsImpl.ACTION.NONE + cc.mouseButtons.wheel = CameraControlsImpl.ACTION.NONE + cc.touches.one = CameraControlsImpl.ACTION.NONE + cc.touches.two = CameraControlsImpl.ACTION.NONE + cc.touches.three = CameraControlsImpl.ACTION.NONE + } else { + cc.mouseButtons.left = CameraControlsImpl.ACTION.ROTATE + cc.mouseButtons.right = CameraControlsImpl.ACTION.TRUCK + cc.mouseButtons.middle = CameraControlsImpl.ACTION.DOLLY + cc.mouseButtons.wheel = CameraControlsImpl.ACTION.DOLLY + cc.touches.one = CameraControlsImpl.ACTION.TOUCH_ROTATE + cc.touches.two = CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK + cc.touches.three = CameraControlsImpl.ACTION.TOUCH_TRUCK + } + }, [morphOpen]) + useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && viewState.mode === "subgraph") handleReset() + if (e.key !== "Escape") return + // Two-stage Esc: while the case board is open it closes the board + // first (keeping the subgraph view); a second Esc resets to overview. + if (morphOpen) { + handleCloseCaseBoard() + return + } + if (viewState.mode === "subgraph") handleReset() } window.addEventListener("keydown", onKeyDown) return () => window.removeEventListener("keydown", onKeyDown) - }, [viewState.mode, handleReset]) + }, [viewState.mode, handleReset, morphOpen, handleCloseCaseBoard]) return (
+ {metroEnabled && ( + <> + + + + )} { useGraphStore.getState().setSidebarSelectedNode(null) useGraphStore.getState().setHoveredNode(null) }} /> + {hudSceneActive && viewState.mode === "subgraph" && selectedApiNode && ( + + )} {debugMarkers && ( + + {morphOpen && morphSelectedRefId && ( + + )} @@ -1821,7 +2266,126 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas
)} + {/* Cream whiteboard backdrop — fades in to cover the 3D scene. Must + outrank drei's default Html zIndexRange (~16.77M) so GraphView's + node + edge labels disappear behind it, not bleed through. The + case-board cards + SVG sit above this layer. */} + {morphOpen && ( +
+ )} + {/* Persistent layer that hosts the case-board cards (Html portals + from NodeMorph) and the SVG connectors. Stays mounted so the + drei portal ref is always populated; only takes pointer events + + applies pan/zoom transform when the morph is open. Translate + + scale happen here so the 3D camera underneath stays still. */} +
+
+ {/* 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 && ( + + )} + + {hudSceneActive && selectedApiNode && ( + + )} + + + {metroEnabled && ( + + )} +
) } diff --git a/src/components/universe/graph-transform.ts b/src/components/universe/graph-transform.ts new file mode 100644 index 00000000..bd68863e --- /dev/null +++ b/src/components/universe/graph-transform.ts @@ -0,0 +1,1157 @@ +// Pure data → graph → layout pipeline for the universe view. +// +// This module is intentionally free of React/three: it maps the flat backend +// API payload (nodes + edges) into a `Graph` ready for the radial layout, runs +// the layout, and provides the click-time re-scale helpers. Keeping it pure +// makes the transform logic (grouping, clustering, hierarchy inversion) easy to +// reason about and test in isolation from the renderer. + +import { + buildGraph, + computeRadialLayout, + extractInitialSubgraph, + extractSubgraph, + adaptiveRadius, + VIRTUAL_CENTER, +} from "@/graph-viz-kit" +import type { Graph, RawNode, RawEdge, Vec3, GraphNode as VizNode, GraphEdge as VizEdge } from "@/graph-viz-kit" +import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/graph-api" +import type { SchemaNode } from "@/app/ontology/page" +import { DISPLAY_KEY_FALLBACKS, resolveNodeThumbnail } from "@/lib/node-display" +import { METRO_FORCE_GROUPED_TYPES, LORE_Y_LIFT } from "./metro-overlay" + +function nodeLabel(node: ApiNode, schemas: SchemaNode[]): string { + const props = node.properties + const schema = schemas.find((s) => s.type === node.node_type) + + if (schema?.title_key) { + const v = props?.[schema.title_key] + if (typeof v === "string" && v.length > 0) return v + } + if (schema?.index) { + const v = props?.[schema.index] + if (typeof v === "string" && v.length > 0) return v + } + if (props) { + for (const key of DISPLAY_KEY_FALLBACKS) { + const v = props[key] + if (typeof v === "string" && v.length > 0) return v + } + } + return node.ref_id +} + +const MAX_LABEL_LENGTH = 30 + +function truncateLabel(label: string): string { + return label.length > MAX_LABEL_LENGTH ? label.slice(0, MAX_LABEL_LENGTH) + "…" : label +} + +// When a single source has this many neighbors of the same (edge_type, +// target_type), insert a synthetic cluster junction so the bundle reads as +// "source → cluster → 20 leaves" instead of 20 individual lines fanning out. +const CLUSTER_THRESHOLD = 5 + +// Edge types whose data direction is "child → parent" — flip them so the +// hierarchy reads parent → child. The container/originator should end up +// as the visual parent: +// SOURCE Claim → Chapter (Chapter is the source, parent of the claim) +// MENTIONED_IN Product/Topic → Section (Section is the container, parent of the mention) +const INVERT_FOR_HIERARCHY = new Set(["SOURCE", "MENTIONED_IN"]) + +export function apiToGraph( + nodes: ApiNode[], + edges: ApiEdge[], + schemas: SchemaNode[] +): { + graph: Graph + indexMap: Map + refIdToIndex: Map + fixedPositions: Map +} { + const rawNodes: RawNode[] = nodes.map((n) => ({ + id: n.ref_id, + label: truncateLabel(nodeLabel(n, schemas)), + })) + + const nodeTypeById = new Map(nodes.map((n) => [n.ref_id, n.node_type || "Unknown"])) + + // Rewrite child→parent edges (e.g. SOURCE: Claim→Chapter) into parent→child + // form so every downstream pass — incoming-count, bundles, rawEdges — sees + // the same hierarchy. The render arrow ends up pointing parent→child, which + // matches the visual we want. + edges = edges.map((e) => + INVERT_FOR_HIERARCHY.has(e.edge_type) + ? { ...e, source: e.target, target: e.source } + : e + ) + + // ─── 1. Roots + orphan reachability ──────────────────────────────────── + // Compute on the original `edges` (cluster routing happens later and + // doesn't change reachability). Only count incoming from known sources — + // edges referencing nodes outside the loaded subgraph would otherwise + // mark a real node as "non-root" without contributing to reachability, + // leaving its subgraph stranded as orphans. + const incomingCount = new Map() + for (const n of nodes) incomingCount.set(n.ref_id, 0) + for (const e of edges) { + if (incomingCount.has(e.target) && incomingCount.has(e.source)) { + incomingCount.set(e.target, (incomingCount.get(e.target) ?? 0) + 1) + } + } + const roots = nodes.filter((n) => (incomingCount.get(n.ref_id) ?? 0) === 0) + + const undAdj = new Map() + for (const n of nodes) undAdj.set(n.ref_id, []) + for (const e of edges) { + if (undAdj.has(e.source) && undAdj.has(e.target)) { + undAdj.get(e.source)!.push(e.target) + undAdj.get(e.target)!.push(e.source) + } + } + const reached = new Set() + const reachQ: string[] = [] + for (const r of roots) { + reached.add(r.ref_id) + reachQ.push(r.ref_id) + } + let reachI = 0 + while (reachI < reachQ.length) { + const cur = reachQ[reachI++] + for (const nb of undAdj.get(cur) ?? []) { + if (!reached.has(nb)) { + reached.add(nb) + reachQ.push(nb) + } + } + } + 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 has orphans, OR when it + // has ≥ CLUSTER_THRESHOLD top-level (parentless / root) nodes in the CURRENT + // payload. Crowd-grouping keys purely on count: no overall root-count gate + // and no "leaf-like" filter — a parentless node is top-level *right now* + // regardless of whether it has children loaded or a parent that exists only + // in the DB. Fixed-position (metro) nodes never count toward or get folded + // into a hub — their position is data-driven. + const orphanTypes = new Set( + orphans.filter((o) => !fixedRefIds.has(o.ref_id)).map((o) => o.node_type || "Unknown") + ) + const rootCountByType = new Map() + for (const r of roots) { + if (fixedRefIds.has(r.ref_id)) continue + const type = r.node_type || "Unknown" + rootCountByType.set(type, (rootCountByType.get(type) ?? 0) + 1) + } + const crowdGroupedTypes = new Set() + for (const [type, count] of rootCountByType) { + if (count >= CLUSTER_THRESHOLD) crowdGroupedTypes.add(type) + } + // 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() + for (const e of edges) { + const tgtType = nodeTypeById.get(e.target) + if (!tgtType) continue + const key = `${e.source}::${e.edge_type}::${tgtType}` + let arr = bundles.get(key) + if (!arr) { + arr = [] + bundles.set(key, arr) + } + arr.push(e) + } + + // ─── 4. Process bundles ──────────────────────────────────────────────── + // Bundles ≥ CLUSTER_THRESHOLD become a per-source cluster — the parent + // keeps ownership ("Episode → Chapter × 9 → 9 chapters"), and the type's + // own __group_ stays reserved for nodes with no real parent (roots + // and orphans). + const clusterizedEdges = new Set() + const clusteredTargets = new Set() + const extraNodes: RawNode[] = [] + const extraEdges: RawEdge[] = [] + + for (const [key, arr] of bundles) { + if (arr.length < CLUSTER_THRESHOLD) continue + const [source, edge_type, target_type] = key.split("::") + // Skip clusters whose source isn't in the loaded payload — buildGraph drops + // edges with unknown endpoints, so the cluster's parent edge would vanish + // and the proxy would end up as an orphan synthetic root with no visible + // parent. Let the targets fall back to __group_ grouping instead. + if (!nodeTypeById.has(source)) continue + const clusterId = `__cluster_${source}_${edge_type}_${target_type}` + extraNodes.push({ id: clusterId, label: `${target_type} × ${arr.length} · ${edge_type}` }) + extraEdges.push({ source, target: clusterId, label: edge_type }) + for (const e of arr) { + extraEdges.push({ source: clusterId, target: e.target, label: edge_type }) + clusterizedEdges.add(e) + clusteredTargets.add(e.target) + } + } + + // ─── 5. Build rawEdges (excluding clusterized) ───────────────────────── + const rawEdges: RawEdge[] = [] + for (const e of edges) { + if (clusterizedEdges.has(e)) continue + rawEdges.push({ source: e.source, target: e.target, label: e.edge_type }) + } + rawNodes.push(...extraNodes) + rawEdges.push(...extraEdges) + + // ─── 6. Add __group_ nodes + member edges ──────────────────────── + // Members = roots of type + orphans of type, minus anything already wired + // into a per-source cluster. Without that exclusion, when a cluster's + // source isn't in the loaded subgraph the cluster's children look like + // roots and end up double-bound: once under `__cluster_…_T × N` and again + // under `__group_T`, producing two visual representations of the same type. + if (groupedTypes.size > 0) { + const memberByType = new Map>() + 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}` + rawNodes.push({ id: groupId, label: t }) + for (const m of members) { + rawEdges.push({ source: groupId, target: m }) + } + } + } + + const graph = buildGraph(rawNodes, rawEdges) + + // Set nodeType (and thumbnail, when present) on real nodes + for (let i = 0; i < nodes.length; i++) { + graph.nodes[i].nodeType = nodes[i].node_type + const thumb = resolveNodeThumbnail(nodes[i]) + if (thumb) graph.nodes[i].imageUrl = thumb + } + // Mark synthetic nodes — clusters get their own marker so renderers can + // distinguish them from the older top-level type bundlers (`_group`). + // Also record the underlying member type so the shader can pick a + // type-specific glyph (Person clusters render differently from Tweet + // clusters, etc.). + for (let i = nodes.length; i < graph.nodes.length; i++) { + const id = rawNodes[i].id + if (id.startsWith("__cluster_")) { + graph.nodes[i].nodeType = "_cluster" + // id = __cluster___ — target_type is last. + const lastUnderscore = id.lastIndexOf("_") + graph.nodes[i].clusterMemberType = id.slice(lastUnderscore + 1) + } else { + graph.nodes[i].nodeType = "_group" + // id = __group_ + graph.nodes[i].clusterMemberType = id.slice("__group_".length) + } + } + + // Only map real nodes — synthetic nodes have no API counterpart + const indexMap = new Map() + const refIdToIndex = new Map() + for (let i = 0; i < nodes.length; i++) { + indexMap.set(i, nodes[i].ref_id) + refIdToIndex.set(nodes[i].ref_id, i) + } + + // Resolve cluster-absorbed edges against the same index map and stash them + // on the graph as `extraEdges` so the hover/select highlight can surface + // them without polluting the base render or layout. + const idToIndex = new Map() + for (let i = 0; i < rawNodes.length; i++) idToIndex.set(rawNodes[i].id, i) + graph.extraEdges = [] + for (const e of clusterizedEdges) { + const src = idToIndex.get(e.source) + const dst = idToIndex.get(e.target) + if (src === undefined || dst === undefined) continue + graph.extraEdges.push({ src, dst, label: e.edge_type }) + } + + // 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 } +} + +export 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, + graph.edges, + { parentId: sub.parentId } + ) + + for (const [id, pos] of positions) { + if (id !== VIRTUAL_CENTER && id < graph.nodes.length) { + 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 + } + } + } + + // Anything BFS never reached (cycle-only components, synthetic nodes the + // layout missed) keeps the (0,0,0) default from buildGraph and piles at the + // 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)) continue + if (fixedPositions?.has(i)) continue + stray.push(i) + } + if (stray.length > 0) { + let maxR = 0 + for (const [id, p] of positions) { + if (id === VIRTUAL_CENTER) continue + const r = Math.hypot(p.x, p.z) + if (r > maxR) maxR = r + } + const ringR = (maxR || 22) * 1.5 + 30 + const angleStep = (Math.PI * 2) / stray.length + for (let i = 0; i < stray.length; i++) { + const angle = i * angleStep + graph.nodes[stray[i]].position = { + x: Math.cos(angle) * ringR, + y: loreLift, + z: Math.sin(angle) * ringR, + } + } + } + + graph.initialDepthMap = sub.depthMap + graph.treeEdgeSet = treeEdgeSet + 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. 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 }) + } + graph.originalPositions = snapshot +} + +// Matches DEPTH_SHRINK in computeRadialLayout. Click inflation is the +// inverse: 1/0.45^d makes the ring around a depth-d node land at R1 again. +export const DEPTH_SHRINK = 0.45 + +// Re-scale the graph about a fixed anchor node. The anchor (the clicked +// node) stays at its current position; every other node's offset *from the +// anchor* in the original layout is multiplied by `scale`. With +// scale = 1/0.45^d, the anchor's children land on a true R1 ring while the +// anchor itself doesn't move on screen — no camera motion required. +export function rescaleAroundAnchor(graph: Graph, anchorId: number, scale: number) { + if (!graph.originalPositions) return + const origAnchor = graph.originalPositions.get(anchorId) + const liveAnchor = graph.nodes[anchorId]?.position + if (!origAnchor || !liveAnchor) return + const ax = liveAnchor.x + const ay = liveAnchor.y + const az = liveAnchor.z + for (const [id, orig] of graph.originalPositions) { + if (id >= graph.nodes.length) continue + graph.nodes[id].position = { + x: ax + (orig.x - origAnchor.x) * scale, + y: ay + (orig.y - origAnchor.y) * scale, + z: az + (orig.z - origAnchor.z) * scale, + } + } +} + +export function restoreOriginalPositions(graph: Graph) { + if (!graph.originalPositions) return + for (const [id, orig] of graph.originalPositions) { + if (id < graph.nodes.length) { + graph.nodes[id].position = { x: orig.x, y: orig.y, z: orig.z } + } + } +} + +// ───────────────────────────────────────────────────────────────────── +// Incremental append + descendant relayout (ported from upstream +// reposition-on-load). Adds fetched nodes WITHOUT a global rebuild/rescale, +// then re-lays-out only the selected node's subtree. This is the camera fix. +// ───────────────────────────────────────────────────────────────────── +const APPEND_CHILD_R = 33 +// Small per-depth vertical drop so appended children tier below their parent, +// mirroring computeRadialLayout's y-offset feel without recomputing it. +const APPEND_Y_DROP = 4.5 + +// Place a batch of freshly-appended `kids` on a ring around their already-placed +// `parent`, updating the derived layout structures (depth, originals, tree +// edges, childrenOf) so the new nodes behave like first-class layout members on +// subsequent clicks/rescales. +function placeChildren( + nodes: VizNode[], + parent: number, + kids: number[], + initialDepthMap: Map, + originalPositions: Map, + childrenOf: Map, + treeEdgeSet: Set +): void { + const pPos = nodes[parent].position + const childDepth = (initialDepthMap.get(parent) ?? 0) + 1 + + const existingKids = childrenOf.get(parent) ?? [] + const existingCount = existingKids.length + const total = existingCount + kids.length + + // Radius sized by the total child count — same adaptiveRadius hop-1 uses — so + // a parent that fetches many neighbors gets a proportionally larger ring + // instead of crowding them all onto a fixed-radius circle (the center-clump + // bug). If the parent already has placed children, average their radius + // instead so appended nodes stay on the subtree's established ring. + let R = Math.max(APPEND_CHILD_R, adaptiveRadius(total)) + if (existingCount > 0) { + let sum = 0 + let cnt = 0 + for (const k of existingKids) { + const kp = nodes[k]?.position + if (!kp) continue + sum += Math.hypot(kp.x - pPos.x, kp.z - pPos.z) + cnt++ + } + if (cnt > 0) R = sum / cnt + } + + // Fan outward (away from origin), continuing past any existing children so + // new arrivals don't stack on top of them. + const outward = Math.atan2(pPos.z, pPos.x) || 0 + const step = (Math.PI * 2) / Math.max(total, 1) + + if (!childrenOf.has(parent)) childrenOf.set(parent, []) + const kidList = childrenOf.get(parent)! + + for (let j = 0; j < kids.length; j++) { + const v = kids[j] + const phi = outward + (existingCount + j) * step + const pos: Vec3 = { + x: pPos.x + Math.cos(phi) * R, + y: pPos.y - APPEND_Y_DROP, + z: pPos.z + Math.sin(phi) * R, + } + nodes[v].position = pos + originalPositions.set(v, { ...pos }) + initialDepthMap.set(v, childDepth) + kidList.push(v) + treeEdgeSet.add(parent < v ? `${parent}-${v}` : `${v}-${parent}`) + } +} + +export interface GraphModel { + graph: Graph + indexMap: Map + refIdToIndex: Map +} + +interface AppendResult { + model: GraphModel + /** Indices of the nodes added by this append. */ + newNodeIds: number[] + /** For each appended node, the node it was placed under (absent for strays). */ + parentOf: Map +} + +// Fold freshly-fetched nodes/edges into an existing graph WITHOUT re-running +// apiToGraph or the global radial layout. Existing node objects are reused +// verbatim (same positions, same indices) so a click-driven 1-hop fetch just +// attaches the new nodes around their parent — no reshuffle, no camera jump. +// This is the path GraphView's `nodeCountGrew` snap branch was written for. +// +// Grouping IS applied, but only to the freshly-arriving batch: new children of +// the same (source, edge_type, target_type) that cross CLUSTER_THRESHOLD get a +// synthetic `_cluster` proxy (source → proxy → members), mirroring apiToGraph. +// Because append never rebuilds, a proxy created here becomes a permanent node +// with a fixed index — no cross-rebuild identity drift, which is what sank the +// old position-cache attempts. Existing nodes (and their existing clustering) +// are never re-evaluated. +export function appendToGraph( + model: GraphModel, + apiNodes: ApiNode[], + apiEdges: ApiEdge[], + schemas: SchemaNode[] +): AppendResult | null { + const prev = model.graph + const oldCount = prev.nodes.length + + const refIdToIndex = new Map(model.refIdToIndex) + const indexMap = new Map(model.indexMap) + + // ── New real nodes (members). Append-only: existing indices stay put. ── + // Drill-down rule: a fetched node is kept ONLY if it can attach as a + // descendant of something already on screen — i.e. it is reachable, through + // the freshly-fetched edges, from an existing node. New nodes that connect + // only to other new nodes with no path back into the current graph are + // strays that don't belong under the selected node's hierarchy, so they are + // dropped outright (not parked on an outer ring). Reachability is undirected + // — we are grafting the new material below the selection, regardless of the + // original edge direction. + const existingRefIds = new Set(refIdToIndex.keys()) + const refAdj = new Map() + const linkRef = (a: string, b: string) => { + const l = refAdj.get(a) + if (l) l.push(b) + else refAdj.set(a, [b]) + } + for (const e of apiEdges) { + linkRef(e.source, e.target) + linkRef(e.target, e.source) + } + const reachableNew = new Set() + const visited = new Set(existingRefIds) + const queue: string[] = [] + for (const ref of existingRefIds) if (refAdj.has(ref)) queue.push(ref) + for (let qi = 0; qi < queue.length; qi++) { + for (const nb of refAdj.get(queue[qi]) ?? []) { + if (visited.has(nb)) continue + visited.add(nb) + reachableNew.add(nb) // existing refs were pre-seeded, so nb is always new + queue.push(nb) + } + } + const newApiNodes = apiNodes.filter( + (n) => !refIdToIndex.has(n.ref_id) && reachableNew.has(n.ref_id) + ) + const memberObjs: VizNode[] = newApiNodes.map((n, k) => { + const thumb = resolveNodeThumbnail(n) + return { + id: oldCount + k, + label: truncateLabel(nodeLabel(n, schemas)), + position: { x: 0, y: 0, z: 0 }, + degree: 0, + nodeType: n.node_type, + ...(thumb != null && { imageUrl: thumb }), + } + }) + for (let k = 0; k < newApiNodes.length; k++) { + const idx = oldCount + k + refIdToIndex.set(newApiNodes[k].ref_id, idx) + indexMap.set(idx, newApiNodes[k].ref_id) + } + + const typeOf = (i: number): string => + (i < oldCount ? prev.nodes[i].nodeType : newApiNodes[i - oldCount]?.node_type) || "Unknown" + const isNew = (i: number): boolean => i >= oldCount + + // ── Resolve candidate new edges (hierarchy rewrite, resolve, dedupe) ── + interface Cand { + src: number + dst: number + edge_type: string + } + // Dedup against BOTH live edges and cluster-absorbed originals. The absorbed + // source→member edges live in extraEdges (pulled out of graph.edges when the + // cluster formed); if we don't count them here, a re-fetch that returns the + // same source→member edge re-adds it as a live direct edge, bypassing the + // proxy and flattening the hierarchy (chapters/locations jump back to hop-1). + const seen = new Set([ + ...prev.edges.map((e) => `${e.src} ${e.dst}`), + ...(prev.extraEdges ?? []).map((e) => `${e.src} ${e.dst}`), + ]) + const candidates: Cand[] = [] + for (const raw of apiEdges) { + const e = INVERT_FOR_HIERARCHY.has(raw.edge_type) + ? { source: raw.target, target: raw.source, edge_type: raw.edge_type } + : raw + const src = refIdToIndex.get(e.source) + const dst = refIdToIndex.get(e.target) + if (src === undefined || dst === undefined || src === dst) continue + const key = `${src} ${dst}` + if (seen.has(key)) continue + seen.add(key) + candidates.push({ src, dst, edge_type: e.edge_type }) + } + + if (memberObjs.length === 0 && candidates.length === 0) return null + + // ── Bundle fresh child edges by (source, edge_type, target_type). ── + const bundles = new Map< + string, + { src: number; edge_type: string; tgtType: string; edges: Cand[] } + >() + for (const c of candidates) { + if (!isNew(c.dst)) continue + const tgtType = typeOf(c.dst) + const key = `${c.src} ${c.edge_type} ${tgtType}` + let b = bundles.get(key) + if (!b) { + b = { src: c.src, edge_type: c.edge_type, tgtType, edges: [] } + bundles.set(key, b) + } + b.edges.push(c) + } + + // ── Reconcile each bundle against what the source ALREADY has for the same + // key, so a relationship never ends up split across direct edges + one-or- + // more proxies (the cluster-bypass bug). Two things get merged in: + // • an existing `_cluster` proxy for the key → reuse it (no second + // proxy); new members route through it. + // • the source's existing *direct leaf* children of the key → absorb + // them: their direct edge moves to extraEdges and they re-home onto + // the proxy ring (a localized move of just those leaves). + // A key clusters when existing-direct + existing-proxy-members + new + // members together cross the threshold — not just the fresh batch. ── + const edgeLabelOf = new Map() + for (const e of prev.edges) edgeLabelOf.set(`${e.src} ${e.dst}`, e.label ?? "") + const isSynthetic = (i: number): boolean => { + const t = i < oldCount ? prev.nodes[i].nodeType : typeOf(i) + return t === "_cluster" || t === "_group" + } + const isProxyChild = (i: number): boolean => + (prev.inAdj[i] ?? []).some((p) => p < oldCount && prev.nodes[p].nodeType === "_cluster") + // Real, non-synthetic leaf (no real children of its own) — safe to re-home + // onto a proxy without stranding a subtree underneath it. + const isAbsorbableLeaf = (i: number): boolean => { + if (i >= oldCount || isSynthetic(i)) return false + for (const ch of prev.outAdj[i] ?? []) if (!isSynthetic(ch)) return false + return true + } + + // Existing same-key proxies in the prev graph, keyed exactly like `bundles`. + const existingProxyByKey = new Map() + for (let i = 0; i < oldCount; i++) { + if (prev.nodes[i].nodeType !== "_cluster") continue + const psrc = (prev.inAdj[i] ?? [])[0] + if (psrc === undefined) continue + const et = edgeLabelOf.get(`${psrc} ${i}`) ?? "" + const tt = prev.nodes[i].clusterMemberType ?? "" + existingProxyByKey.set(`${psrc} ${et} ${tt}`, i) + } + + const absorbed = new Set() + const proxyObjs: VizNode[] = [] + const proxyRouting: { + proxy: number + src: number + members: number[] + absorb: number[] + edge_type: string + isExisting: boolean + }[] = [] + let proxyCursor = oldCount + memberObjs.length + for (const b of bundles.values()) { + const key = `${b.src} ${b.edge_type} ${b.tgtType}` + const existingProxy = existingProxyByKey.get(key) + + // Source's existing direct leaf children of the same key, eligible to absorb. + const absorb: number[] = [] + for (const m of prev.outAdj[b.src] ?? []) { + if (!isAbsorbableLeaf(m)) continue + if (typeOf(m) !== b.tgtType) continue + if ((edgeLabelOf.get(`${b.src} ${m}`) ?? "") !== b.edge_type) continue + if (isProxyChild(m)) continue + absorb.push(m) + } + + const existingMembers = + existingProxy !== undefined ? (prev.outAdj[existingProxy]?.length ?? 0) : 0 + const prospective = b.edges.length + absorb.length + existingMembers + + // No existing proxy and not enough to form one → leave as direct edges. + if (existingProxy === undefined && prospective < CLUSTER_THRESHOLD) continue + + let proxy: number + if (existingProxy !== undefined) { + proxy = existingProxy + } else { + proxy = proxyCursor++ + proxyObjs.push({ + id: proxy, + label: "", // finalized from the true member count once edges are wired + position: { x: 0, y: 0, z: 0 }, + degree: 0, + nodeType: "_cluster", + clusterMemberType: b.tgtType, + }) + } + + proxyRouting.push({ + proxy, + src: b.src, + members: b.edges.map((e) => e.dst), + absorb, + edge_type: b.edge_type, + isExisting: existingProxy !== undefined, + }) + for (const e of b.edges) absorbed.add(e) + } + + // Each clustered member → its cluster source. Used to drop ANY direct edge + // between the two (including the reciprocal member→source the backend often + // also returns) so it doesn't bypass the proxy with a direct line. + const memberSource = new Map() + for (const r of proxyRouting) for (const m of r.members) memberSource.set(m, r.src) + + const nodes: VizNode[] = [...prev.nodes, ...memberObjs, ...proxyObjs] + const total = nodes.length + + // Absorbed existing leaves get re-positioned, and reused existing proxies get + // a fresh label/degree — clone those node objects so prev's stay untouched. + for (const r of proxyRouting) { + if (r.isExisting) nodes[r.proxy] = { ...nodes[r.proxy] } + for (const m of r.absorb) nodes[m] = { ...nodes[m] } + } + + // ── Adjacency: copy existing rows, empty rows for new members + proxies ── + const adj: number[][] = new Array(total) + const outAdj: number[][] = new Array(total) + const inAdj: number[][] = new Array(total) + for (let i = 0; i < total; i++) { + adj[i] = i < oldCount ? prev.adj[i].slice() : [] + outAdj[i] = i < oldCount ? prev.outAdj[i].slice() : [] + inAdj[i] = i < oldCount ? prev.inAdj[i].slice() : [] + } + + const edges: VizEdge[] = prev.edges.slice() + const extraEdges: VizEdge[] = (prev.extraEdges ?? []).slice() + const addEdge = (src: number, dst: number, label: string) => { + edges.push({ src, dst, label }) + adj[src].push(dst) + adj[dst].push(src) + outAdj[src].push(dst) + inAdj[dst].push(src) + } + const removeOne = (arr: number[], val: number) => { + const k = arr.indexOf(val) + if (k !== -1) arr.splice(k, 1) + } + // Strip the direct edge between two nodes (either direction) from the live + // edge list + adjacency, so an absorbed leaf no longer connects to its old + // source — its relation lives on the proxy spoke + extraEdges instead. + const detachDirect = (s: number, d: number) => { + for (let k = edges.length - 1; k >= 0; k--) { + const e = edges[k] + if ((e.src === s && e.dst === d) || (e.src === d && e.dst === s)) edges.splice(k, 1) + } + removeOne(adj[s], d) + removeOne(adj[d], s) + removeOne(outAdj[s], d) + removeOne(inAdj[d], s) + removeOne(outAdj[d], s) + removeOne(inAdj[s], d) + } + + // Proxy routing: source → proxy → members in the layout; the absorbed + // source → member originals move to extraEdges (surfaced on hover/select, + // matching apiToGraph). GraphView drops the proxy → member spokes from the + // render automatically (edges out of a `_cluster` node). + for (const r of proxyRouting) { + // Reused proxies already carry the source → proxy edge; only new ones need it. + if (!r.isExisting) addEdge(r.src, r.proxy, r.edge_type) + for (const m of r.members) { + addEdge(r.proxy, m, r.edge_type) + extraEdges.push({ src: r.src, dst: m, label: r.edge_type }) + } + // Absorb existing direct leaves: cut the bypassing source → leaf edge and + // re-route it through the proxy (spoke + extraEdge), exactly like a new + // member, so nothing connects to the source except via the cluster. + for (const m of r.absorb) { + detachDirect(r.src, m) + addEdge(r.proxy, m, r.edge_type) + extraEdges.push({ src: r.src, dst: m, label: r.edge_type }) + } + } + // Non-clustered new edges go in directly — except a direct member↔source + // edge (either direction), which the proxy routing already represents. + for (const c of candidates) { + if (absorbed.has(c)) continue + if (memberSource.get(c.src) === c.dst || memberSource.get(c.dst) === c.src) continue + addEdge(c.src, c.dst, c.edge_type) + } + + for (let i = oldCount; i < total; i++) nodes[i].degree = adj[i].length + + // Proxy label + degree reflect the FINAL member count (existing + absorbed + + // new) — outAdj[proxy] now holds every spoke, so its length IS the count. + for (const r of proxyRouting) { + const p = nodes[r.proxy] + p.label = `${p.clusterMemberType ?? ""} × ${outAdj[r.proxy].length} · ${r.edge_type}` + p.degree = adj[r.proxy].length + } + + // ── Clone derived structures so prev stays intact ── + const childrenOf = new Map() + if (prev.childrenOf) for (const [k, v] of prev.childrenOf) childrenOf.set(k, v.slice()) + const treeEdgeSet = new Set(prev.treeEdgeSet ?? []) + const initialDepthMap = new Map(prev.initialDepthMap ?? []) + const originalPositions = new Map(prev.originalPositions ?? []) + + // Re-home absorbed leaves in the tree structures: drop their old source link + // so placeChildren re-attaches them under the proxy (with a fresh position) + // in the placement pass below. + const absorbSet = new Set() + for (const r of proxyRouting) { + for (const m of r.absorb) { + absorbSet.add(m) + const sibs = childrenOf.get(r.src) + if (sibs) removeOne(sibs, m) + treeEdgeSet.delete(r.src < m ? `${r.src}-${m}` : `${m}-${r.src}`) + } + } + + // ── Place new nodes around an already-placed parent, in waves so chains of + // new nodes (A→B→C) place parents before their children. Absorbed leaves + // are treated like new nodes here so they re-home onto the proxy ring. ── + const parentOf = new Map() + const placed = new Set() + for (let i = 0; i < oldCount; i++) if (!absorbSet.has(i)) placed.add(i) + + // A clustered member must hang off its proxy, never a cross-edge neighbor — + // so it waits for the proxy rather than falling back to inAdj/adj. + const forcedParent = new Map() + for (const r of proxyRouting) { + for (const m of r.members) forcedParent.set(m, r.proxy) + for (const m of r.absorb) forcedParent.set(m, r.proxy) + } + + const pickParent = (v: number): number | undefined => { + const forced = forcedParent.get(v) + if (forced !== undefined) return placed.has(forced) ? forced : undefined + for (const p of inAdj[v]) if (placed.has(p)) return p // directed parent first + for (const p of adj[v]) if (placed.has(p)) return p // else any placed neighbor + return undefined + } + + let pending = [ + ...proxyObjs.map((n) => n.id), + ...memberObjs.map((n) => n.id), + ...absorbSet, + ] + let progress = true + while (pending.length > 0 && progress) { + progress = false + const byParent = new Map() + const stillPending: number[] = [] + for (const v of pending) { + const p = pickParent(v) + if (p === undefined) { + stillPending.push(v) + continue + } + if (!byParent.has(p)) byParent.set(p, []) + byParent.get(p)!.push(v) + } + for (const [p, kids] of byParent) { + placeChildren(nodes, p, kids, initialDepthMap, originalPositions, childrenOf, treeEdgeSet) + for (const v of kids) { + placed.add(v) + parentOf.set(v, p) + } + progress = true + } + pending = stillPending + } + + // Defensive: true strays (new nodes with no path back to the graph) are now + // dropped upstream by the descendant-reachability filter, so this should be + // empty. Anything still here only reached the graph through a directed edge + // pickParent couldn't resolve — park it on an outer ring rather than lose it. + if (pending.length > 0) { + let maxR = APPEND_CHILD_R + for (const pos of originalPositions.values()) { + const r = Math.hypot(pos.x, pos.z) + if (r > maxR) maxR = r + } + const ringR = maxR * 1.2 + 20 + const step = (Math.PI * 2) / pending.length + for (let i = 0; i < pending.length; i++) { + const v = pending[i] + const pos: Vec3 = { x: Math.cos(i * step) * ringR, y: 0, z: Math.sin(i * step) * ringR } + nodes[v].position = pos + originalPositions.set(v, { ...pos }) + initialDepthMap.set(v, 1) + } + } + + const graph: Graph = { + ...prev, + nodes, + edges, + adj, + outAdj, + inAdj, + extraEdges, + childrenOf, + treeEdgeSet, + initialDepthMap, + originalPositions, + } + return { + model: { graph, indexMap, refIdToIndex }, + newNodeIds: [...proxyObjs, ...memberObjs].map((n) => n.id), + parentOf, + } +} + +// logging select / merge / recalculate steps. +export function describeSubgraph( + graph: Graph, + centerId: number, + useAdj: "directed" | "undirected" = "directed" +) { + const lbl = (id: number) => graph.nodes[id]?.label ?? `#${id}` + const sub = extractSubgraph(graph, centerId, 1000, { useAdj }) + return { + center: lbl(centerId), + total: sub.nodeIds.length, + depthCounts: sub.neighborsByDepth.map((ds) => ds.length), + byDepth: sub.neighborsByDepth.map( + (ds, i) => `d${i + 1} (${ds.length}): ${ds.map(lbl).join(", ")}` + ), + } +} + +// Recompute ONLY the selected node's descendant subgraph as a fresh radial, +// translated so the selected node stays exactly where it already is (camera +// doesn't move). Ancestors and unrelated branches are left untouched. This is +// the "add new node, recalculate the subgraph" model — after a fetch folds new +// descendants in, we relay them out cleanly instead of patching positions in +// place. Updates positions, originalPositions, the tree-edge set and depth map +// for the recomputed nodes so rescale/reset/edge-rendering stay consistent. +export function recomputeDescendantLayout(graph: Graph, selectedId: number, oldCount: number) { + const anchorNode = graph.nodes[selectedId] + if (!anchorNode) return + + // Descendants only (directed BFS via outAdj) — never climbs to ancestors. + const sub = extractSubgraph(graph, selectedId, 1000, { useAdj: "directed" }) + // If the fetch surfaced the selected node's hierarchical PARENT for the first + // time (e.g. clicking a parentless Claim pulls in its Chapter, which is + // chapter→claim, so the chapter is the claim's parent / inAdj), hand it to + // computeRadialLayout as the parentId so it lands in the dedicated parent slot + // opposite the children — not grafted as a stray. Only a BRAND-NEW parent is + // placed; an ancestor already on screen is left where it is. + const newParentId = graph.inAdj[selectedId]?.find((p) => p >= oldCount) + const { positions, treeEdgeSet, childrenOf } = computeRadialLayout( + selectedId, + sub.neighborsByDepth, + graph.edges, + newParentId !== undefined ? { parentId: newParentId } : undefined + ) + + // Two scales coexist while a node is selected: + // • LIVE positions (what you see) — the spread-out view: computeRadialLayout + // already emits this at R1 ring scale, so new nodes match the existing + // spread-out children. + // • originalPositions (the collapse target on deselect) — the compact global + // layout, where a depth-`d` node's rings are shrunk by DEPTH_SHRINK^d. + // Writing the spread value to BOTH (the old bug) bakes the spread in so + // deselect can't collapse. So: live = full spread, original = spread × shrink. + const depth = Math.max(0, graph.initialDepthMap?.get(selectedId) ?? 0) + const shrink = Math.pow(DEPTH_SHRINK, depth) + + console.log( + "[recalc] recomputed descendant subgraph", + { depth, shrink, ...describeSubgraph(graph, selectedId, "directed") } + ) + + // Stable placement so existing nodes are *trackable* across the relayout. + // Walk the recomputed tree from the selected node (which stays fixed at its + // current spot). For each parent, its children share one ring — common radius + // + y-offset, evenly-spaced angle slots. Assign each EXISTING child to the + // slot nearest its CURRENT angle, and give NEW children the leftover slots. + // Existing nodes drift to the closest spot (small, followable move) instead of + // being reshuffled; new nodes fall into the gaps and fly in. + const anchor = { ...anchorNode.position } + const angDiff = (a: number, b: number) => + Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b))) + type P3 = { x: number; y: number; z: number } + const live = new Map() + live.set(selectedId, anchor) + + const queue: number[] = [selectedId] + while (queue.length > 0) { + const P = queue.shift()! + const kids = childrenOf.get(P) ?? [] + if (kids.length === 0) continue + const Pnew = positions.get(P) + const Pfin = live.get(P) + if (!Pnew || !Pfin) continue + + // Each kid's recompute offset from its parent → (radius, y-delta, slot angle). + const slot = kids.map((k) => { + const pk = positions.get(k) ?? Pnew + const dx = pk.x - Pnew.x, dy = pk.y - Pnew.y, dz = pk.z - Pnew.z + return { r: Math.hypot(dx, dz), y: dy, angle: Math.atan2(dz, dx) } + }) + + const assigned = new Array(kids.length).fill(-1) + const freeSlots = new Set(kids.map((_, i) => i)) + const existing: number[] = [] + kids.forEach((k, i) => { + if (k < oldCount) existing.push(i) + }) + + // Greedy global nearest-slot match for existing kids (minimizes total angular + // movement); whatever's left goes to new kids in order. + const pairs: { ki: number; si: number; d: number }[] = [] + for (const ki of existing) { + const c = graph.nodes[kids[ki]].position + const a = Math.atan2(c.z - Pfin.z, c.x - Pfin.x) + for (let si = 0; si < kids.length; si++) { + pairs.push({ ki, si, d: angDiff(a, slot[si].angle) }) + } + } + pairs.sort((p, q) => p.d - q.d) + for (const { ki, si } of pairs) { + if (assigned[ki] !== -1 || !freeSlots.has(si)) continue + assigned[ki] = si + freeSlots.delete(si) + } + const leftovers = [...freeSlots] + let li = 0 + for (let i = 0; i < kids.length; i++) { + if (assigned[i] === -1) assigned[i] = leftovers[li++] + } + + kids.forEach((k, i) => { + const s = slot[assigned[i]] + live.set(k, { + x: Pfin.x + Math.cos(s.angle) * s.r, + y: Pfin.y + s.y, + z: Pfin.z + Math.sin(s.angle) * s.r, + }) + queue.push(k) + }) + } + + // Place the brand-new parent (if any) at its dedicated back slot, translated + // to the anchor like everything else, so it reads as "above" the selection. + if (newParentId !== undefined) { + const pp = positions.get(newParentId) + const origin = positions.get(selectedId) ?? { x: 0, y: 0, z: 0 } + if (pp) { + live.set(newParentId, { + x: anchor.x + (pp.x - origin.x), + y: anchor.y + (pp.y - origin.y), + z: anchor.z + (pp.z - origin.z), + }) + } + } + + // Two scales coexist while selected: LIVE = the stabilized spread-out layout; + // originalPositions = the same layout scaled toward the selected node by the + // layer's DEPTH_SHRINK^depth factor (the compact view deselect collapses to). + for (const [id, p] of live) { + if (id < 0 || id >= graph.nodes.length) continue + graph.nodes[id].position = { x: p.x, y: p.y, z: p.z } + graph.originalPositions?.set(id, { + x: anchor.x + (p.x - anchor.x) * shrink, + y: anchor.y + (p.y - anchor.y) * shrink, + z: anchor.z + (p.z - anchor.z) * shrink, + }) + } + + // Tree edges within the recomputed subgraph: drop the stale ones touching + // these nodes, add the fresh set, so straight-vs-curved edge rendering tracks + // the new hierarchy. + if (graph.treeEdgeSet) { + const inSub = new Set(positions.keys()) + for (const k of [...graph.treeEdgeSet]) { + const [a, b] = k.split("-").map(Number) + if (inSub.has(a) && inSub.has(b)) graph.treeEdgeSet.delete(k) + } + for (const k of treeEdgeSet) graph.treeEdgeSet.add(k) + } + + // Global depth = selected node's global depth + local subgraph depth, so a + // later click's rescale keys off the right tier. + if (graph.initialDepthMap) { + const baseDepth = graph.initialDepthMap.get(selectedId) ?? 0 + for (const [id, d] of sub.depthMap) { + if (id >= 0 && id < graph.nodes.length) graph.initialDepthMap.set(id, baseDepth + d) + } + } + + if (graph.childrenOf) for (const [k, v] of childrenOf) graph.childrenOf.set(k, v) +} + diff --git a/src/components/universe/metro-overlay/constants.ts b/src/components/universe/metro-overlay/constants.ts new file mode 100644 index 00000000..0035ed88 --- /dev/null +++ b/src/components/universe/metro-overlay/constants.ts @@ -0,0 +1,104 @@ +// 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. Bumped up to give the two layers +// clear vertical breathing room — at the smaller lift they read as one plane. +export const LORE_Y_LIFT = 34 + +// 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..a441d4f8 --- /dev/null +++ b/src/components/universe/metro-overlay/metro-lines-layer.tsx @@ -0,0 +1,188 @@ +"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] + // Lines rest at the dimmed look by default and only brighten to full + // opacity when in focus — i.e. the line itself is hovered, or a node + // sitting on it is hovered/selected (see `activeLines`). When nothing + // is in focus (activeLines === null) every line stays dimmed. + 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..5bf7dfde --- /dev/null +++ b/src/components/universe/metro-overlay/metro-station-bullets.tsx @@ -0,0 +1,107 @@ +"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) => { + // Bullets rest dimmed by default (matching the lines) and only brighten + // when in focus: either their line is active (line hovered, or a node + // on it hovered/selected) or their state is active (legend hover). When + // nothing is in focus every bullet stays dimmed. + const lineFocus = + activeLines !== null && b.lines.some((l) => activeLines.has(l)) + const stateFocus = activeState !== null && b.state === activeState + const opacity = lineFocus || stateFocus ? 1 : 0.12 + return ( + + + + + + + + + + + ) + })} + + ) +} diff --git a/src/components/universe/station-hud-scene.tsx b/src/components/universe/station-hud-scene.tsx new file mode 100644 index 00000000..9cd7f4f6 --- /dev/null +++ b/src/components/universe/station-hud-scene.tsx @@ -0,0 +1,682 @@ +"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" + +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) +} + +// Rotating sweep wedge — atmospheric "this station is in focus" cue. +function SweepWedge() { + const ref = useRef(null) + useFrame((_, delta) => { + if (ref.current) ref.current.rotation.z -= delta * 0.55 + }) + 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. +function HoloHero({ + node, + accent, + ghostSize, +}: { + node: ApiNode + accent: string + ghostSize: number +}) { + 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 }: { 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 + + return ( +
+ {/* Name strip */} +
+ STATION + + {name} + + {nameRu && {nameRu}} +
+ +
+ +
+
+ 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} + + + + {stateWord(STATION_STATE_LABEL[state])} + +
+ {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[] + onFocusNode: (nodeId: number) => void +} + +export function StationHudScene({ + graph, + selectedNodeId, + focal, + neighbors, + onFocusNode, +}: StationHudSceneProps) { + const p = graph.nodes[selectedNodeId]?.position + if (!p) return null + const ringY = p.y + RING_LIFT + + return ( + + {/* Radar rings on the map plane around the station */} + + + + + {/* Gold beam + central holo card */} + + + + + + +
+ +
+ +
+ + {/* Tunnel neighbors: ground link, anchor ring, stem, holo card */} + {neighbors.map((nb) => { + const np = graph.nodes[nb.idx]?.position + if (!np) return null + 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. +export function StationZonePlate({ node }: { node: ApiNode }) { + const props = node.properties as Record + 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 sector = `SEC-${String(hashCode(node.ref_id) % 999).padStart(3, "0")}` + const desc = pickString(props, "description") + + return ( +
+
+
+
+
+ {stateWord(STATION_STATE_LABEL[state])} ZONE + + {faction ? `${faction} · ${sector}` : sector} + +
+ {desc && ( +
+ {desc} +
+ )} +
+
+
+ ) +} diff --git a/src/data/metro.ts b/src/data/metro.ts new file mode 100644 index 00000000..aed048f4 --- /dev/null +++ b/src/data/metro.ts @@ -0,0 +1,652 @@ +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() + +// 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 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", + 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", +} + +// 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] ?? STATION_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 }, + { "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" }, + ], +} + +// 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/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/graph-viz-kit/GraphView.tsx b/src/graph-viz-kit/GraphView.tsx index 9623059f..dae295cd 100644 --- a/src/graph-viz-kit/GraphView.tsx +++ b/src/graph-viz-kit/GraphView.tsx @@ -73,6 +73,17 @@ interface GraphViewProps { * hover is ignored so nodes sweeping under a stationary cursor don't fire * hover effects. Any existing hover is cleared on the rising edge. */ suppressHover?: boolean; + /** Node indices that should rest muted in overview mode — dim glyph (below + * the bloom threshold, so no halo) and no label — until hovered/selected. + * Used for schematic nodes (e.g. metro stations) that are represented by a + * 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(); @@ -591,7 +602,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, layoutGeneration = 0, externalHoveredId, externalSelectedId, onGraphClick, nodeTypeIcons, onResetView, suppressHover }: GraphViewProps) { +export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minimap, whiteboardNodeId, onExitWhiteboard, onDetailNavigate, searchMatches, searchLabelMatches, topMatchRanks, searchTerm, pulses, recentNodes, expandedClusterId, layoutGeneration = 0, externalHoveredId, externalSelectedId, onGraphClick, nodeTypeIcons, onResetView, suppressHover, mutedNodeIds, suppressLabelIds }: GraphViewProps) { const meshRef = useRef(null); const linesRef = useRef(null); const highlightLinesRef = useRef(null); @@ -800,7 +811,11 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima for (let i = 0; i < nodeCount; i++) { const i3 = i * 3; - const depth = depthMap?.get(i) ?? 0; + // Nodes the initial BFS never reached (disconnected components — e.g. + // an isolated "lost" metro spur like Salaryevo↔Rumyantsevo) have no + // depth entry. Default them to a deep depth so they render dim, not at + // depth-0 brightness. Mirrors the subgraph branch's `?? 999`. + const depth = depthMap?.get(i) ?? 999; // Hide proxy glyph when its cluster is expanded (label stays via label layer) if (i === expandedClusterId) { @@ -814,6 +829,16 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima const a = 0.4; colors[i3] = BASE_R * a; colors[i3 + 1] = BASE_G * a; colors[i3 + 2] = BASE_B * a; alphas[i] = a; + } else if (mutedNodeIds?.has(i)) { + // Schematic node (e.g. a metro station): rest as a small, dim dot — + // alpha kept under the bloom threshold so it doesn't flare into a + // halo — and label-less (see the label filter). The dedicated metro + // overlay carries the visual; hover/select restores full prominence. + const a = 0.16; + scales[i] = NODE_SCALE * 0.55; + const c = colorForNodeType(graph.nodes[i].nodeType); + colors[i3] = c.r * a; colors[i3 + 1] = c.g * a; colors[i3 + 2] = c.b * a; + alphas[i] = a; } else { const w = graph.nodes[i].weight ?? 0; const baseScale = depth === 0 ? SELECTED_SCALE : NODE_SCALE; @@ -865,7 +890,7 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima } return { positions, scales, colors, alphas }; - }, [graph, viewState, nodeCount, expandedClusterId]); + }, [graph, viewState, nodeCount, expandedClusterId, mutedNodeIds]); const { treeEdges, crossEdges, targetEdges, edgeLaneInfo } = useMemo(() => { // Hide edges touching cloud members of COLLAPSED clusters. @@ -1215,7 +1240,6 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima // Assigned to mesh in useEffect and re-assigned whenever the mesh changes. const raycastFn = useRef(null); if (!raycastFn.current) { - let _raycastLogTimer = 0; raycastFn.current = function customRaycast(this: THREE.InstancedMesh, raycaster, intersects) { const count = Math.min(nodeCountRef.current, this.instanceMatrix.count); const g = graphRef.current; @@ -1232,14 +1256,13 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima proxyRadius.set(r.proxyNodeId, Math.max(r.radius, 3)); } } - let _skippedCloud = 0, _skippedAlpha = 0, _skippedScale = 0, _tested = 0, _hit = 0; for (let i = 0; i < count; i++) { - if (clouds.has(i)) { _skippedCloud++; continue; } - if (alphas[i] < 0.02) { _skippedAlpha++; continue; } + if (clouds.has(i)) continue; + if (alphas[i] < 0.02) continue; this.getMatrixAt(i, _mat4); _mat4.decompose(_pos, _quat, _scale); - if (_scale.x < 0.01) { _skippedScale++; continue; } + if (_scale.x < 0.01) continue; _sphere.center.copy(_pos); @@ -1252,11 +1275,9 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima _sphere.radius = baseScale * camDist * 0.08; } - _tested++; if (raycaster.ray.intersectSphere(_sphere, _hitPoint)) { const distance = raycaster.ray.origin.distanceTo(_hitPoint); if (distance >= raycaster.near && distance <= raycaster.far) { - _hit++; intersects.push({ distance, point: _hitPoint.clone(), @@ -1266,11 +1287,6 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima } } } - const now = Date.now(); - if (now - _raycastLogTimer > 2000) { - _raycastLogTimer = now; - console.log(`[GV] raycast: count=${count} tested=${_tested} hit=${_hit} cloud=${_skippedCloud} alpha=${_skippedAlpha} scale=${_skippedScale}`); - } }; } @@ -2210,6 +2226,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; @@ -2259,7 +2278,14 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima // Depth-based filter: allow depth 0-1, hide deeper unless prominent if (!isProminent) { if (viewState.mode === "overview") { - const depth = graph.initialDepthMap?.get(i) ?? 0; + // Muted schematic nodes (e.g. metro stations) carry no label at + // rest — the overlay represents them; hover/select (isProminent) + // brings the label back. + if (mutedNodeIds?.has(i)) return null; + // Unreached nodes (no depth entry) are treated as deep, so their + // labels are hidden too — matching the dimmed glyph (see the + // `?? 999` in the overview alpha pass). + const depth = graph.initialDepthMap?.get(i) ?? 999; if (depth > 1) return null; } else { const selectedId = viewState.selectedNodeId; @@ -2287,15 +2313,15 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima const labelColor = isHovered ? "rgba(255,255,255,0.98)" : isTopHit ? topTint : isSelected ? "rgba(100,220,255,0.98)" - : isHoverNeighbor ? "rgba(235,238,240,0.95)" + : isHoverNeighbor ? "rgba(210,215,222,0.95)" : isRecentNode ? `rgba(100,255,180,${(0.5 + 0.45 * recentOpacity).toFixed(2)})` - : "rgba(226,234,242,0.92)"; - const labelSize = isHovered || isSelected ? 15 - : isTopHit ? (topRank === 0 ? 17 : 15) - : isRecentNode ? 14 - : isHoverNeighbor ? 13 - : 13; - 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 @@ -2343,9 +2369,12 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima )} {isExpandedProxy ? { e.stopPropagation(); onNodeClick(i); }}>{node.label} diff --git a/src/lib/__tests__/add-content-modal.test.tsx b/src/lib/__tests__/add-content-modal.test.tsx index 9d68ef09..7562fba9 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 6656444c..ddd1088b 100644 --- a/src/lib/__tests__/budget-modal.test.tsx +++ b/src/lib/__tests__/budget-modal.test.tsx @@ -135,7 +135,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) @@ -161,11 +161,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) @@ -190,7 +190,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(() => { @@ -212,7 +212,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() }) @@ -570,9 +570,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 () => { @@ -590,11 +590,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() }) }) @@ -807,7 +807,7 @@ describe("BudgetModal withdraw flow", () => { expect(mockWithdraw).not.toHaveBeenCalled() }) - it("shows below-minimum error when decoded amount < 100 sats", async () => { + it("shows below-minimum error when decoded amount < 100 bullets", async () => { mockDecodeInvoiceAmountSats.mockReturnValue(50) render() fireEvent.click(screen.getByRole("button", { name: /Withdraw/i })) @@ -823,7 +823,7 @@ describe("BudgetModal withdraw flow", () => { fireEvent.click(screen.getByRole("button", { name: /Confirm Withdrawal/i })) await waitFor(() => - expect(screen.getByText(/Minimum withdrawal is 100 sats/i)).toBeInTheDocument() + expect(screen.getByText(/Minimum withdrawal is 100 bullets/i)).toBeInTheDocument() ) expect(mockWithdraw).not.toHaveBeenCalled() }) diff --git a/src/lib/__tests__/cluster-merge.test.ts b/src/lib/__tests__/cluster-merge.test.ts index 698594bf..9b49e34b 100644 --- a/src/lib/__tests__/cluster-merge.test.ts +++ b/src/lib/__tests__/cluster-merge.test.ts @@ -2,7 +2,7 @@ // children are split across direct edges and/or an existing proxy, a fresh // append must MERGE them into one `_cluster` so nothing bypasses the proxy. import { describe, it, expect } from "vitest" -import { apiToGraph, appendToGraph } from "@/components/universe/graph-canvas" +import { apiToGraph, appendToGraph } from "@/components/universe/graph-transform" import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/graph-api" const topic = (id: string): ApiNode => ({ ref_id: id, node_type: "topic", properties: {} }) diff --git a/src/lib/__tests__/edit-node-modal.test.tsx b/src/lib/__tests__/edit-node-modal.test.tsx index 48104ddf..02fafe4a 100644 --- a/src/lib/__tests__/edit-node-modal.test.tsx +++ b/src/lib/__tests__/edit-node-modal.test.tsx @@ -21,6 +21,9 @@ const { mockAdminUpdateNode } = vi.hoisted(() => ({ vi.mock("@/lib/graph-api", () => ({ adminUpdateNode: (...args: unknown[]) => mockAdminUpdateNode(...args), + uploadImageToNode: vi.fn(), + ALLOWED_IMAGE_TYPES: ["image/jpeg", "image/png", "image/webp", "image/gif"], + MAX_IMAGE_UPLOAD_BYTES: 20 * 1024 * 1024, })) vi.mock("@/lib/mock-data", () => ({ @@ -304,7 +307,11 @@ describe("EditNodeModal", () => { await waitFor(() => expect(mockClose).toHaveBeenCalledOnce()) expect(mockClearSelection).toHaveBeenCalledOnce() - expect(mockSetSelectedNode).toHaveBeenCalledWith(PERSON_NODE) + // The panel is refreshed with the freshly-rebuilt node (edits applied + + // name hoisted to the top level), not the original object reference. + expect(mockSetSelectedNode).toHaveBeenCalledWith( + expect.objectContaining({ ref_id: "node-abc", node_type: "Person" }) + ) }) it("shows inline error on save failure", async () => { diff --git a/src/lib/__tests__/graph-pane.test.tsx b/src/lib/__tests__/graph-pane.test.tsx index 76106c69..ec5a9d21 100644 --- a/src/lib/__tests__/graph-pane.test.tsx +++ b/src/lib/__tests__/graph-pane.test.tsx @@ -17,6 +17,9 @@ const graphState = { setSelectedNode: vi.fn(), setSidebarSelectedNode: vi.fn(), clearSelection: vi.fn(), + // Upstream's fetch-on-select added this set; graph-pane reads .size to show + // a "loading neighbours" indicator. + loadingNeighborRefs: new Set(), } vi.mock("@/stores/graph-store", () => ({ diff --git a/src/lib/__tests__/my-content-page.test.tsx b/src/lib/__tests__/my-content-page.test.tsx index 4b5a829d..d4300377 100644 --- a/src/lib/__tests__/my-content-page.test.tsx +++ b/src/lib/__tests__/my-content-page.test.tsx @@ -169,7 +169,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 () => { @@ -186,7 +186,7 @@ describe("MyContentPanel", () => { }) render( {}} />) await waitFor(() => { - expect(screen.queryByText("sats")).not.toBeInTheDocument() + expect(screen.queryByText("bullets")).not.toBeInTheDocument() }) }) @@ -203,7 +203,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: [ { @@ -225,10 +225,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: [ { @@ -250,7 +250,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 9f616cce..df8c3ad7 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), @@ -204,7 +205,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, @@ -215,7 +216,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() }) }) @@ -265,7 +266,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 @@ -279,11 +280,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() }) }) }) @@ -424,7 +425,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)) @@ -432,7 +433,7 @@ describe("NodePreviewPanel – core property rendering", () => { await waitFor(() => { expect(screen.getByText("50")).toBeInTheDocument() - expect(screen.getByText("sats")).toBeInTheDocument() + expect(screen.getByText("bullets")).toBeInTheDocument() }) }) @@ -449,7 +450,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() }) }) @@ -806,7 +807,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() }) }) @@ -823,10 +824,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") @@ -846,10 +847,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() @@ -870,10 +871,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") @@ -1503,7 +1504,7 @@ describe("NodePreviewPanel – pencil edit button", () => { ) }) - it("calls openEdit with fullNode when it is available", async () => { + it("calls openEdit with the live current node", async () => { const { fireEvent: fe } = await import("@testing-library/react") userStoreOverrides = { pubKey: "03admin", routeHint: "", isAdmin: true } mockApiGet.mockResolvedValue( @@ -1518,12 +1519,12 @@ describe("NodePreviewPanel – pencil edit button", () => { const editItem = await waitFor(() => screen.getByText("Edit node")) fe.click(editItem) + // The modal is seeded from the panel's live, in-sync node (one source of + // truth) rather than the separately-fetched full node, so reopening after + // an edit always reflects the latest values. expect(mockOpenEdit).toHaveBeenCalledOnce() expect(mockOpenEdit).toHaveBeenCalledWith( - expect.objectContaining({ - ref_id: BASE_NODE.ref_id, - properties: expect.objectContaining({ description: "Full description" }), - }) + expect.objectContaining({ ref_id: BASE_NODE.ref_id }) ) }) }) @@ -2171,7 +2172,7 @@ describe("NodePreviewPanel – TranscriptChatWidget visibility", () => { 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() }) expect(screen.queryByTestId("transcript-chat-widget")).toBeNull() }) diff --git a/src/lib/__tests__/toolkit-fab.test.tsx b/src/lib/__tests__/toolkit-fab.test.tsx index c67624c9..0d0bf379 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() }) it("Settings button navigates to /settings via router.push (not openModal)", async () => { diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index b97286b1..5f17b3c2 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -243,13 +243,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`) @@ -259,6 +262,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 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") + ) } 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