diff --git a/src/components/layout/app-layout.tsx b/src/components/layout/app-layout.tsx
index df4da20..435a1e8 100644
--- a/src/components/layout/app-layout.tsx
+++ b/src/components/layout/app-layout.tsx
@@ -5,6 +5,7 @@ import { AddModal } from "@/components/modals/add-modal"
import { BudgetModal } from "@/components/modals/budget-modal"
import { EditNodeModal } from "@/components/modals/edit-node-modal"
import { MediaPlayer } from "@/components/player/media-player"
+import { EpisodeBoardOverlay } from "@/components/playable/episode-board-overlay"
import { useDefaultLayout } from "react-resizable-panels"
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"
import { useSchemaStore } from "@/stores/schema-store"
@@ -60,6 +61,7 @@ export function AppLayout() {
+
>
)
}
diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx
index 5468f4b..1982e8e 100644
--- a/src/components/layout/node-preview-panel.tsx
+++ b/src/components/layout/node-preview-panel.tsx
@@ -1,7 +1,7 @@
"use client"
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, Search } from "lucide-react"
+import { ArrowLeft, Link, Zap, Loader2, Play, Film, ExternalLink, Heart, Repeat2, ChevronDown, ChevronUp, MessageCircle, Quote, Eye, BadgeCheck, AtSign, HeartOff, X, Pencil, FlaskConical, GitMerge, MoreHorizontal, Search, LayoutGrid } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { BoostButton } from "@/components/boost/boost-button"
@@ -34,6 +34,7 @@ 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 { useBoardStore } from "@/stores/board-store"
const DEEP_RESEARCH_NODE_TYPES = ["Topic"]
const ENRICH_NODE_TYPES = ["Person", "Organization", "Product", "Location", "Topic"] as const
@@ -1184,6 +1185,16 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
>
{displayNodeType(nodeType)}
+ {nodeType === "Episode" && (
+
+ )}
{/* Hidden BoostButton — clicked programmatically from the dropdown */}
{ownerReference && !hideBoost && (
diff --git a/src/components/playable/__tests__/fixture.ts b/src/components/playable/__tests__/fixture.ts
new file mode 100644
index 0000000..32ec4ad
--- /dev/null
+++ b/src/components/playable/__tests__/fixture.ts
@@ -0,0 +1,22 @@
+import rawData from "../../../../private/playable-data"
+import claimsData from "../../../../private/playable-claims.json"
+import crossEdgesData from "../../../../private/playable-cross-edges.json"
+import { setBoardData, type BoardEdge, type BoardNode } from "@/lib/board-dataset"
+
+/**
+ * Board test fixture: one real episode's graph slice, assembled from DB pulls
+ * kept under `private/`:
+ * - `playable-data.ts` — base export (episode, chapters, clips, entities)
+ * - `playable-claims.json` — Claim nodes + SOURCE/SUPPORTS/CONTRADICTS/
+ * MADE_CLAIM edges
+ * - `playable-cross-edges.json` — Chapter→Entity MENTIONS edges
+ */
+export function loadFixture() {
+ const data = rawData as { nodes: BoardNode[]; edges: BoardEdge[] }
+ const claims = claimsData as { nodes: BoardNode[]; edges: BoardEdge[] }
+ const cross = crossEdgesData as unknown as { edges: BoardEdge[] }
+ setBoardData(
+ [...data.nodes, ...claims.nodes],
+ [...data.edges, ...claims.edges, ...cross.edges]
+ )
+}
diff --git a/src/components/playable/__tests__/playable-explorer.test.tsx b/src/components/playable/__tests__/playable-explorer.test.tsx
new file mode 100644
index 0000000..3e37b4a
--- /dev/null
+++ b/src/components/playable/__tests__/playable-explorer.test.tsx
@@ -0,0 +1,90 @@
+import { describe, it, expect, beforeEach } from "vitest"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { PlayableExplorer } from "../playable-explorer"
+import {
+ getChapters,
+ getEntitiesByType,
+ getClaimRelations,
+ getChapterMentions,
+ getTypeCounts,
+ setBoardData,
+} from "@/lib/board-dataset"
+import { computeBoardLayout } from "../board-layout"
+import { loadFixture } from "./fixture"
+
+beforeEach(() => {
+ loadFixture()
+})
+
+describe("PlayableExplorer (board view)", () => {
+ it("lays out the full board: episode, chapters, clips and proximity entities", () => {
+ const layout = computeBoardLayout()
+ expect(layout.episode).not.toBeNull()
+ expect(layout.chapters).toHaveLength(getChapters().length)
+ expect(layout.clips.length).toBeGreaterThan(0)
+
+ // every entity got a chip anchored inside the chapter strip's x-span
+ const entityCount = [...getEntitiesByType().values()].flat().length
+ expect(layout.entities).toHaveLength(entityCount)
+ const first = layout.chapters[0].card
+ const last = layout.chapters[layout.chapters.length - 1].card
+ for (const e of layout.entities) {
+ expect(e.card.x).toBeGreaterThanOrEqual(first.x - 1)
+ expect(e.card.x + e.card.w).toBeLessThanOrEqual(last.x + last.w + 1)
+ // chips sit in the band above the chapter strip
+ expect(e.card.y + e.card.h).toBeLessThanOrEqual(layout.chapterBusY)
+ }
+
+ // claims stack under a valid chapter card
+ expect(layout.claims.length).toBe(21)
+ const chapterIds = new Set(layout.chapters.map((c) => c.card.id))
+ for (const c of layout.claims) expect(chapterIds.has(c.chapterId)).toBe(true)
+
+ // claim relations pulled from the DB stay within the pulled claim set
+ const rel = getClaimRelations()
+ expect(rel.supports.length).toBe(10)
+ expect(rel.contradicts.length).toBe(2)
+ expect(rel.madeBy.length).toBe(21)
+
+ // chapter→entity mention edges link chapters to nodes already on the board
+ const mentions = getChapterMentions()
+ expect(mentions.length).toBe(40)
+ for (const e of mentions) {
+ expect(chapterIds.has(e.source)).toBe(true)
+ }
+ })
+
+ it("renders the type legend and chapter cards", () => {
+ render(
)
+ expect(screen.getByText("Topic")).toBeInTheDocument()
+ expect(screen.getByText("Person")).toBeInTheDocument()
+ expect(screen.getByText(/chapters/)).toBeInTheDocument()
+ })
+
+ it("opens the detail panel on node click and closes on Escape", () => {
+ render(
)
+ // click a chapter card (index badge "00" belongs to first chapter)
+ const chapterBadge = screen.getByText("00")
+ fireEvent.click(chapterBadge.closest("button")!)
+ // detail panel exposes the chapter's source link
+ expect(screen.getByText("Source")).toBeInTheDocument()
+ fireEvent.keyDown(window, { key: "Escape" })
+ expect(screen.queryByText("Source")).not.toBeInTheDocument()
+ })
+
+ it("swaps the active dataset and can restore the fixture", () => {
+ const fixtureChapterCount = getChapters().length
+ try {
+ setBoardData(
+ [{ ref_id: "ep-x", node_type: "Episode", properties: { episode_title: "X" } }],
+ [],
+ "ep-x"
+ )
+ expect(getChapters()).toHaveLength(0)
+ expect(getTypeCounts()).toEqual([["Episode", 1]])
+ } finally {
+ loadFixture()
+ }
+ expect(getChapters()).toHaveLength(fixtureChapterCount)
+ })
+})
diff --git a/src/components/playable/board-layout.ts b/src/components/playable/board-layout.ts
new file mode 100644
index 0000000..1c604e7
--- /dev/null
+++ b/src/components/playable/board-layout.ts
@@ -0,0 +1,261 @@
+import {
+ episodeNode,
+ showNode,
+ getChapters,
+ getClips,
+ getEntitiesByType,
+ getHost,
+ getClaimsByChapter,
+ getChapterMentions,
+ parseTimestampMs,
+ type ChapterInfo,
+ type BoardNode,
+} from "@/lib/board-dataset"
+
+/**
+ * Proximity-based "Miro board" layout for the playable explorer.
+ * All coordinates are world pixels; the world origin (0,0) is the episode's
+ * center.
+ *
+ * The chapter strip is the temporal backbone. Entity chips (Person, Topic,
+ * Organization, Product, Location) are placed in the band above the strip at
+ * the barycenter of the chapters that mention them — so position encodes
+ * "where in the episode this thing matters" and mention edges stay short.
+ */
+
+export interface CardPlacement {
+ id: string
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+export interface BoardLayout {
+ episode: CardPlacement | null
+ show: CardPlacement | null
+ clips: { node: BoardNode; card: CardPlacement; ms: number | null }[]
+ chapters: { info: ChapterInfo; card: CardPlacement }[]
+ /** Claim cards stacked under their parent chapter card. */
+ claims: { node: BoardNode; card: CardPlacement; chapterId: string }[]
+ /** Entity chips anchored above the chapters that mention them. */
+ entities: { node: BoardNode; card: CardPlacement }[]
+ hostId: string | null
+ /** World bounds for fit-to-view. */
+ bounds: { minX: number; minY: number; maxX: number; maxY: number }
+ /** Y of the horizontal "bus" line feeding the chapter strip. */
+ chapterBusY: number
+}
+
+const EPISODE_W = 340
+const EPISODE_H = 190
+/** Extra height the episode card gets at detail zoom (media summary fits). */
+const EPISODE_DETAIL_GROW = 70
+const SHOW_W = 190
+const SHOW_H = 64
+const CLIP_W = 236
+const CLIP_H = 82
+/** Extra height clips with media get at detail zoom (video preview fits). */
+const CLIP_DETAIL_GROW = 60
+const CLIP_GAP = 32
+const CLIP_Y = -430
+const CHAPTER_W = 196
+const CHAPTER_H = 96
+const CHAPTER_GAP = 22
+const CLAIM_W = CHAPTER_W - 12
+const CLAIM_X_INSET = 6
+/** Claims are two-line cards (claim text + speaker/relation row), not chips. */
+const CLAIM_H = 58
+/** Extra height a claim card gets at detail zoom when it carries a
+ * triplicate (subject–predicate–object) worth showing on the card. */
+const CLAIM_DETAIL_GROW = 46
+
+function hasTriplicate(node: BoardNode): boolean {
+ const p = node.properties
+ return (
+ typeof p.triplicate_subject === "string" &&
+ p.triplicate_subject.length > 0 &&
+ typeof p.triplicate_predicate === "string" &&
+ p.triplicate_predicate.length > 0 &&
+ typeof p.triplicate_object === "string" &&
+ p.triplicate_object.length > 0
+ )
+}
+const CLAIM_GAP = 8
+const CLAIM_TOP_GAP = 14
+
+const CHIP_W = 150
+const CHIP_H = 32
+const CHIP_GAP = 10
+const BAND_GAP = 31 // space between the episode card bottom and the entity band
+const ENTITY_ROW_PITCH = 44
+const ENTITY_MAX_ROWS = 6
+const BUS_GAP = 46 // space between the entity band and the chapter bus
+
+function hasMedia(node: BoardNode): boolean {
+ return typeof node.properties.media_url === "string" && node.properties.media_url.length > 0
+}
+
+/**
+ * `detail` mirrors the board's semantic zoom: at detail level the episode and
+ * media-bearing clip cards are taller, and every row below them (entity band,
+ * chapter bus, strip, claims) shifts down accordingly — positions always
+ * derive from actual card heights, so cards can never overlap.
+ */
+export function computeBoardLayout(detail = false): BoardLayout {
+ const episodeH = detail ? EPISODE_H + EPISODE_DETAIL_GROW : EPISODE_H
+ const episode: CardPlacement | null = episodeNode
+ ? {
+ id: episodeNode.ref_id,
+ x: -EPISODE_W / 2,
+ y: -EPISODE_H / 2 - 30,
+ w: EPISODE_W,
+ h: episodeH,
+ }
+ : null
+
+ const show: CardPlacement | null = showNode
+ ? { id: showNode.ref_id, x: -EPISODE_W / 2 - SHOW_W - 70, y: -EPISODE_H / 2 - 30, w: SHOW_W, h: SHOW_H }
+ : null
+
+ const clipNodes = getClips()
+ const clipsTotal = clipNodes.length * CLIP_W + (clipNodes.length - 1) * CLIP_GAP
+ const clips = clipNodes.map((node, i) => ({
+ node,
+ card: {
+ id: node.ref_id,
+ x: -clipsTotal / 2 + i * (CLIP_W + CLIP_GAP),
+ y: CLIP_Y,
+ w: CLIP_W,
+ h: detail && hasMedia(node) ? CLIP_H + CLIP_DETAIL_GROW : CLIP_H,
+ },
+ ms: parseTimestampMs(node.properties.timestamp),
+ }))
+
+ const chapterInfos = getChapters()
+ const chaptersTotal = chapterInfos.length * CHAPTER_W + (chapterInfos.length - 1) * CHAPTER_GAP
+ const stripMin = -chaptersTotal / 2
+ const stripMax = chaptersTotal / 2
+
+ // Chapters are placed AFTER the entity band, whose height depends on how
+ // many rows the chips need — so compute entities first, then shift the
+ // strip down accordingly (see below).
+
+ // ─── Entity anchors: barycenter of mentioning chapters ─────────────────
+ const chapterX = (refId: string): number | null => {
+ const i = chapterInfos.findIndex((c) => c.node.ref_id === refId)
+ if (i === -1) return null
+ return stripMin + i * (CHAPTER_W + CHAPTER_GAP) + CHAPTER_W / 2
+ }
+ const xsByEntity = new Map
()
+ for (const e of getChapterMentions()) {
+ const x = chapterX(e.source)
+ if (x == null) continue
+ const xs = xsByEntity.get(e.target) ?? []
+ xs.push(x)
+ xsByEntity.set(e.target, xs)
+ }
+
+ const byType = getEntitiesByType()
+ const allEntities = [...byType.values()].flat()
+ const anchored = allEntities
+ .map((node) => {
+ const xs = xsByEntity.get(node.ref_id)
+ const anchor = xs ? xs.reduce((a, b) => a + b, 0) / xs.length : 0
+ return { node, anchor }
+ })
+ .sort((a, b) => a.anchor - b.anchor)
+
+ // The band hangs below the episode card — its top derives from the card's
+ // actual (possibly detail-grown) bottom, so the two never collide.
+ const entityBandTop = episode ? episode.y + episode.h + BAND_GAP : 96
+
+ // ─── Greedy row packing: first row where the chip doesn't overlap ──────
+ const rows: { x: number }[][] = []
+ const fits = (row: { x: number }[], x: number) =>
+ row.every((c) => x + CHIP_W + CHIP_GAP <= c.x || c.x + CHIP_W + CHIP_GAP <= x)
+ const entities: BoardLayout["entities"] = []
+ for (const { node, anchor } of anchored) {
+ const x = Math.min(Math.max(anchor - CHIP_W / 2, stripMin), stripMax - CHIP_W)
+ let rowIdx = rows.findIndex((row) => fits(row, x))
+ if (rowIdx === -1 && rows.length < ENTITY_MAX_ROWS) {
+ rows.push([])
+ rowIdx = rows.length - 1
+ }
+ if (rowIdx === -1) {
+ // Band full — tack onto the shortest row, right of its last chip.
+ rowIdx = rows.reduce((min, row, i) => (row.length < rows[min].length ? i : min), 0)
+ const last = rows[rowIdx].reduce((max, c) => Math.max(max, c.x), stripMin)
+ const fx = Math.min(last + CHIP_W + CHIP_GAP, stripMax - CHIP_W)
+ rows[rowIdx].push({ x: fx })
+ entities.push({
+ node,
+ card: { id: node.ref_id, x: fx, y: entityBandTop + rowIdx * ENTITY_ROW_PITCH, w: CHIP_W, h: CHIP_H },
+ })
+ continue
+ }
+ rows[rowIdx].push({ x })
+ entities.push({
+ node,
+ card: { id: node.ref_id, x, y: entityBandTop + rowIdx * ENTITY_ROW_PITCH, w: CHIP_W, h: CHIP_H },
+ })
+ }
+
+ const bandBottom = entityBandTop + Math.max(rows.length, 1) * ENTITY_ROW_PITCH
+ const busY = bandBottom + BUS_GAP
+ const chapterY = busY + 40
+
+ const chapters = chapterInfos.map((info, i) => ({
+ info,
+ card: {
+ id: info.node.ref_id,
+ x: stripMin + i * (CHAPTER_W + CHAPTER_GAP),
+ y: chapterY,
+ w: CHAPTER_W,
+ h: CHAPTER_H,
+ },
+ }))
+
+ // Claims stack under their parent chapter card, inset from the card's width.
+ // Heights vary (detail zoom grows triplicate-bearing cards), so stack with a
+ // running Y instead of a fixed pitch.
+ const claimsByChapter = getClaimsByChapter()
+ const claims: BoardLayout["claims"] = []
+ for (const { card } of chapters) {
+ const chapterClaims = claimsByChapter.get(card.id) ?? []
+ let claimY = card.y + card.h + CLAIM_TOP_GAP
+ for (const node of chapterClaims) {
+ const h = detail && hasTriplicate(node) ? CLAIM_H + CLAIM_DETAIL_GROW : CLAIM_H
+ claims.push({
+ node,
+ chapterId: card.id,
+ card: { id: node.ref_id, x: card.x + CLAIM_X_INSET, y: claimY, w: CLAIM_W, h },
+ })
+ claimY += h + CLAIM_GAP
+ }
+ }
+
+ const hostId = getHost()?.ref_id ?? null
+
+ const all = [
+ ...(episode ? [episode] : []),
+ ...(show ? [show] : []),
+ ...clips.map((c) => c.card),
+ ...chapters.map((c) => c.card),
+ ...claims.map((c) => c.card),
+ ...entities.map((c) => c.card),
+ ]
+ const bounds = {
+ minX: Math.min(...all.map((c) => c.x)) - 60,
+ minY: Math.min(...all.map((c) => c.y)) - 60,
+ maxX: Math.max(...all.map((c) => c.x + c.w)) + 60,
+ maxY: Math.max(...all.map((c) => c.y + c.h)) + 60,
+ }
+
+ return { episode, show, clips, chapters, claims, entities, hostId, bounds, chapterBusY: busY }
+}
+
+/** Center point of a card. */
+export function center(c: CardPlacement): { x: number; y: number } {
+ return { x: c.x + c.w / 2, y: c.y + c.h / 2 }
+}
diff --git a/src/components/playable/board-view.tsx b/src/components/playable/board-view.tsx
new file mode 100644
index 0000000..78874d3
--- /dev/null
+++ b/src/components/playable/board-view.tsx
@@ -0,0 +1,893 @@
+"use client"
+
+import { useEffect, useMemo, useRef, useState } from "react"
+import { zoom as d3Zoom, zoomIdentity, type ZoomTransform } from "d3-zoom"
+import { select as d3Select } from "d3-selection"
+import {
+ nodeById,
+ nodeLabel,
+ truncateLabel,
+ typeColor,
+ formatMs,
+ episodeNode,
+ getClaimRelations,
+ getChapterMentions,
+ getChapters,
+ boardEdges,
+} from "@/lib/board-dataset"
+import {
+ computeBoardLayout,
+ center,
+ type CardPlacement,
+} from "./board-layout"
+import type { ZoomApi } from "./view-types"
+
+// Semantic zoom thresholds: below FAR the board collapses to group summaries,
+// above DETAIL cards reveal descriptions / transcripts / edge labels.
+const ZOOM_FAR = 0.42
+const ZOOM_DETAIL = 1.25
+
+// Edge / surface palette — the app theme's border token (oklch 0.2) is nearly
+// invisible at 1px on this canvas, so the board uses brighter steps.
+const EDGE_COLOR = "oklch(0.48 0.03 260)"
+const EDGE_ACTIVE = "oklch(0.72 0.14 200)"
+const EDGE_LABEL = "oklch(0.72 0.02 260)"
+const SUPPORTS_COLOR = "#2ec4b6"
+const CONTRADICTS_COLOR = "#e63946"
+
+type Level = 0 | 1 | 2
+
+function levelFor(k: number): Level {
+ return k < ZOOM_FAR ? 0 : k > ZOOM_DETAIL ? 2 : 1
+}
+
+/** Entity nodes carry no images in the DB — for people we derive an initials
+ * avatar so Person chips read as people, not bare dots. */
+function initials(name: string): string {
+ return name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((w) => w[0]?.toUpperCase() ?? "")
+ .join("")
+}
+
+/** Muted inline video used as a visual thumbnail. pointer-events are off so it
+ * never interferes with board pan/select; preload="metadata" grabs the first
+ * frame without streaming the whole clip. */
+function MediaThumb({ src, className }: { src: string; className?: string }) {
+ return (
+
+ )
+}
+
+function mediaUrlOf(node: { properties: Record }): string | null {
+ const v = node.properties.media_url
+ return typeof v === "string" && v.length > 0 ? v : null
+}
+
+interface BoardViewProps {
+ /** Bumped when the underlying dataset is swapped — recomputes layout memos. */
+ dataVersion?: number
+ selectedId: string | null
+ hoveredId: string | null
+ /** Show all claim↔claim arcs at once; off = reveal on hover/select only. */
+ showRelations: boolean
+ onSelect: (id: string | null) => void
+ onHover: (id: string | null) => void
+ registerZoomApi: (api: ZoomApi | null) => void
+}
+
+export function BoardView({
+ dataVersion = 0,
+ selectedId,
+ hoveredId,
+ showRelations,
+ onSelect,
+ onHover,
+ registerZoomApi,
+}: BoardViewProps) {
+ const containerRef = useRef(null)
+ const worldRef = useRef(null)
+ const [level, setLevel] = useState(1)
+ const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null)
+ // Drag-distance guard so panning doesn't clear the selection on mouseup.
+ const downAt = useRef<{ x: number; y: number } | null>(null)
+
+ const layout = useMemo(() => {
+ void dataVersion // layout reads the module dataset, swapped by the explorer
+ return computeBoardLayout(level === 2)
+ }, [dataVersion, level])
+ // The zoom/fit effect must fit on data swap, NOT on level-driven relayout
+ // (zooming past the detail threshold would otherwise bounce the view back).
+ const layoutRef = useRef(layout)
+ layoutRef.current = layout
+ const claimCountByChapter = useMemo(() => {
+ const m = new Map()
+ for (const c of layout.claims) m.set(c.chapterId, (m.get(c.chapterId) ?? 0) + 1)
+ return m
+ }, [layout])
+ const claimCardById = useMemo(
+ () => new Map(layout.claims.map((c) => [c.card.id, c.card])),
+ [layout]
+ )
+ const claimRelations = useMemo(() => {
+ void dataVersion
+ return getClaimRelations()
+ }, [dataVersion])
+ /** Supports/contradicts involvement per claim — shown as ▲/▼ badges on the
+ * claim cards so the relation web is visible without hovering. */
+ const claimRelCounts = useMemo(() => {
+ const m = new Map()
+ const bump = (id: string, key: "sup" | "con") => {
+ const r = m.get(id) ?? { sup: 0, con: 0 }
+ r[key]++
+ m.set(id, r)
+ }
+ for (const e of claimRelations.supports) {
+ bump(e.source, "sup")
+ bump(e.target, "sup")
+ }
+ for (const e of claimRelations.contradicts) {
+ bump(e.source, "con")
+ bump(e.target, "con")
+ }
+ return m
+ }, [claimRelations])
+ const chapterMentions = useMemo(() => {
+ void dataVersion
+ return getChapterMentions()
+ }, [dataVersion])
+ const chapterCardById = useMemo(
+ () => new Map(layout.chapters.map((c) => [c.card.id, c.card])),
+ [layout]
+ )
+ const entityChipById = useMemo(() => {
+ const m = new Map()
+ for (const e of layout.entities) m.set(e.card.id, e.card)
+ return m
+ }, [layout])
+ // Host chip in the entity band — MADE_CLAIM lines target it.
+ const hostChip = useMemo(
+ () => (layout.hostId ? (entityChipById.get(layout.hostId) ?? null) : null),
+ [layout, entityChipById]
+ )
+ const activeId = hoveredId ?? selectedId
+
+ // Focus model: the active node + everything directly connected to it, over
+ // ALL edge types. Used to light up one node's web and dim the rest — the
+ // static view only draws the hierarchy, relations reveal on focus.
+ const focusSet = useMemo(() => {
+ if (!activeId) return null
+ const set = new Set([activeId])
+ for (const e of boardEdges) {
+ if (e.source === activeId) set.add(e.target)
+ if (e.target === activeId) set.add(e.source)
+ }
+ return set
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- boardEdges is a swapped module binding
+ }, [activeId, dataVersion])
+ const dimmed = (id: string) => (focusSet ? !focusSet.has(id) : false)
+
+ useEffect(() => {
+ const el = containerRef.current
+ const world = worldRef.current
+ if (!el || !world) return
+
+ const apply = (t: ZoomTransform) => {
+ world.style.transform = `translate(${t.x}px, ${t.y}px) scale(${t.k})`
+ setLevel((prev) => {
+ const next = levelFor(t.k)
+ return prev === next ? prev : next
+ })
+ }
+
+ const zoom = d3Zoom()
+ .scaleExtent([0.08, 5])
+ .on("zoom", (event: { transform: ZoomTransform }) => apply(event.transform))
+
+ const sel = d3Select(el)
+ sel.call(zoom)
+
+ const vw = el.clientWidth
+ const vh = el.clientHeight
+ const { bounds } = layoutRef.current
+ const bw = bounds.maxX - bounds.minX
+ const bh = bounds.maxY - bounds.minY
+ const k = Math.min(vw / bw, vh / bh) * 0.94
+ const cx = (bounds.minX + bounds.maxX) / 2
+ const cy = (bounds.minY + bounds.maxY) / 2
+ const fit = zoomIdentity.translate(vw / 2 - k * cx, vh / 2 - k * cy).scale(k)
+ sel.call(zoom.transform, fit)
+
+ registerZoomApi({
+ zoomIn: () => sel.transition().duration(200).call(zoom.scaleBy, 1.4),
+ zoomOut: () => sel.transition().duration(200).call(zoom.scaleBy, 1 / 1.4),
+ reset: () => sel.transition().duration(300).call(zoom.transform, fit),
+ })
+ return () => registerZoomApi(null)
+ }, [dataVersion, registerZoomApi])
+
+ const episode = layout.episode
+ const episodeCenter = episode ? center(episode) : { x: 0, y: 0 }
+
+ const cardState = (id: string) => ({
+ selected: selectedId === id,
+ active: activeId === id,
+ dim: dimmed(id),
+ halo: focusSet != null && focusSet.has(id) && id !== activeId,
+ })
+
+ const select = (id: string) => onSelect(selectedId === id ? null : id)
+
+ return (
+ {
+ downAt.current = { x: e.clientX, y: e.clientY }
+ }}
+ onClick={(e) => {
+ const d = downAt.current
+ const moved = d ? Math.hypot(e.clientX - d.x, e.clientY - d.y) : 0
+ if (moved < 4 && e.target === containerRef.current) onSelect(null)
+ }}
+ onPointerMove={(e) => {
+ if (hoveredId) setTipPos({ x: e.clientX, y: e.clientY })
+ }}
+ >
+
+ {/* ─── Edge layer ─── */}
+
+
+ {/* ─── Episode card ─── */}
+ {episode && episodeNode && (
+
+ {mediaUrlOf(episodeNode) && (
+
+ )}
+
+ {String(episodeNode.properties.show_title ?? "Episode")}
+
+
+ {nodeLabel(episodeNode)}
+
+
+ {formatMs((Number(episodeNode.properties.duration) || 0) * 1000)} ·{" "}
+ {layout.chapters.length} chapters · {layout.clips.length} clips
+
+ {level === 2 && (
+
+ {String(episodeNode.properties.summary ?? "")}
+
+ )}
+
+ )}
+
+ {/* ─── Show card ─── */}
+ {layout.show && (
+
+
+ {nodeLabel(nodeById.get(layout.show.id)!)}
+
+
+ show
+
+
+ )}
+
+ {/* ─── Clip cards ─── */}
+ {layout.clips.map(({ node, card, ms }) => {
+ const media = mediaUrlOf(node)
+ return (
+
+
+ {nodeLabel(node)}
+
+
+ {ms != null ? `at ${formatMs(ms)}` : ""}
+
+ {level === 2 && media && }
+ {level === 2 && (
+
+ {String(node.properties.description ?? "")}
+
+ )}
+
+ )
+ })}
+
+ {/* ─── Chapter strip ─── */}
+ {layout.chapters.map(({ info, card }) => (
+
+
+
+ {String(info.index).padStart(2, "0")}
+
+
+ {formatMs(info.startMs)}–{formatMs(info.endMs)}
+
+
+
+ {nodeLabel(info.node)}
+
+ {(claimCountByChapter.get(card.id) ?? 0) > 0 && (
+
+ {claimCountByChapter.get(card.id)} claims ↓
+
+ )}
+ {level === 2 && (
+
+ {truncateLabel(String(info.node.properties.description ?? ""), 90)}
+
+ )}
+
+ ))}
+
+ {/* ─── Claim cards (under their chapter) ─── */}
+ {level >= 1 &&
+ layout.claims.map(({ node, card }) => {
+ const st = cardState(card.id)
+ const p = node.properties
+ const text =
+ (typeof p.claim_text === "string" && p.claim_text) || nodeLabel(node)
+ const speaker =
+ typeof p.speaker_name === "string" && p.speaker_name ? p.speaker_name : null
+ const tri =
+ level === 2 &&
+ typeof p.triplicate_subject === "string" &&
+ p.triplicate_subject &&
+ typeof p.triplicate_predicate === "string" &&
+ p.triplicate_predicate &&
+ typeof p.triplicate_object === "string" &&
+ p.triplicate_object
+ ? { s: p.triplicate_subject, pr: p.triplicate_predicate, o: p.triplicate_object }
+ : null
+ const rel = claimRelCounts.get(card.id) ?? { sup: 0, con: 0 }
+ return (
+
+ )
+ })}
+
+ {/* ─── Entity chips (anchored above their chapters) ─── */}
+ {level >= 1 &&
+ layout.entities.map(({ node, card }) => {
+ const st = cardState(card.id)
+ const isHost = layout.hostId === card.id
+ const color = typeColor(node.node_type)
+ return (
+
+ )
+ })}
+
+
+ {/* Cursor-following hover card (screen space, outside the zoom world) */}
+ {hoveredId && tipPos && nodeById.has(hoveredId) && (
+
+ )}
+
+ )
+}
+
+/** Rich hover card: full text for the node plus, for claims, the web of
+ * supports/contradicts relations with names — so the arcs get context. */
+function HoverTip({ id, x, y }: { id: string; x: number; y: number }) {
+ const node = nodeById.get(id)
+ if (!node) return null
+ const p = node.properties
+ const body =
+ (typeof p.claim_text === "string" && p.claim_text) ||
+ (typeof p.description === "string" && p.description) ||
+ (typeof p.summary === "string" && p.summary) ||
+ null
+ const speaker = typeof p.speaker_name === "string" ? p.speaker_name : null
+ const media = mediaUrlOf(node)
+
+ const rel = getClaimRelations()
+ const names = (edges: { source: string; target: string }[], pick: "source" | "target") =>
+ edges
+ .filter((e) => (pick === "target" ? e.source === id : e.target === id))
+ .map((e) => truncateLabel(nodeLabel(nodeById.get(pick === "target" ? e.target : e.source)!), 32))
+ const supports = names(rel.supports, "target")
+ const supportedBy = names(rel.supports, "source")
+ const contradicts = names(rel.contradicts, "target")
+ const contradictedBy = names(rel.contradicts, "source")
+
+ // Chapter↔entity mention context, both directions.
+ const mentions = getChapterMentions()
+ const mentionsEntities = names(mentions, "target") // hovering a chapter
+ const chapters = getChapters()
+ const mentionedIn = mentions
+ .filter((e) => e.target === id)
+ .map((e) => {
+ const ch = chapters.find((c) => c.node.ref_id === e.source)
+ return ch ? `#${ch.index} ${truncateLabel(nodeLabel(ch.node), 24)}` : null
+ })
+ .filter((s): s is string => s != null)
+
+ const W = 300
+ const flipX = x > window.innerWidth - W - 40
+ const flipY = y > window.innerHeight - (media ? 420 : 260)
+
+ return (
+
+
+
+
+ {node.node_type}
+
+ {speaker && — {speaker}}
+
+
+ {nodeLabel(node)}
+
+ {media &&
}
+ {body && body !== nodeLabel(node) && (
+
{body}
+ )}
+ {(supports.length > 0 || supportedBy.length > 0 || contradicts.length > 0 || contradictedBy.length > 0 || mentionsEntities.length > 0 || mentionedIn.length > 0) && (
+
+ {supports.length > 0 && }
+ {supportedBy.length > 0 && }
+ {contradicts.length > 0 && }
+ {contradictedBy.length > 0 && }
+ {mentionsEntities.length > 0 && }
+ {mentionedIn.length > 0 && }
+
+ )}
+
+ )
+}
+
+function RelationRow({ color, label, items }: { color: string; label: string; items: string[] }) {
+ return (
+
+
+ {label}
+
+ {items.join(" · ")}
+
+ )
+}
+
+interface BoardCardProps {
+ card: CardPlacement
+ type: string
+ selected: boolean
+ active: boolean
+ /** Faded — outside the focused node's web. */
+ dim?: boolean
+ /** In the focused node's web (but not the focus itself). */
+ halo?: boolean
+ glow?: boolean
+ onSelect: (id: string) => void
+ onHover: (id: string | null) => void
+ children: React.ReactNode
+}
+
+function BoardCard({
+ card,
+ type,
+ selected,
+ active,
+ dim,
+ halo,
+ glow,
+ onSelect,
+ onHover,
+ children,
+}: BoardCardProps) {
+ return (
+
+ )
+}
diff --git a/src/components/playable/episode-board-overlay.tsx b/src/components/playable/episode-board-overlay.tsx
new file mode 100644
index 0000000..754cfc7
--- /dev/null
+++ b/src/components/playable/episode-board-overlay.tsx
@@ -0,0 +1,17 @@
+"use client"
+
+import { useBoardStore } from "@/stores/board-store"
+import { PlayableExplorer } from "./playable-explorer"
+
+/** Fullscreen overlay hosting the episode board on top of the 3D graph.
+ * Mounted globally in AppLayout; opened via useBoardStore.openBoard. */
+export function EpisodeBoardOverlay() {
+ const episodeRefId = useBoardStore((s) => s.episodeRefId)
+ const closeBoard = useBoardStore((s) => s.closeBoard)
+ if (!episodeRefId) return null
+ return (
+
+ )
+}
diff --git a/src/components/playable/playable-explorer.tsx b/src/components/playable/playable-explorer.tsx
new file mode 100644
index 0000000..b24e9b4
--- /dev/null
+++ b/src/components/playable/playable-explorer.tsx
@@ -0,0 +1,321 @@
+"use client"
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { ZoomIn, ZoomOut, Maximize2, X, ExternalLink, Loader2 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import {
+ nodeById,
+ nodeLabel,
+ typeColor,
+ getChapters,
+ formatMs,
+ parseTimestampMs,
+ boardNodes,
+ boardEdges,
+ getTypeCounts,
+ setBoardData,
+ resetBoardData,
+ type BoardNode,
+} from "@/lib/board-dataset"
+import { fetchEpisodeBoardData } from "@/lib/episode-board-data"
+import { BoardView } from "./board-view"
+import type { ZoomApi } from "./view-types"
+
+interface PlayableExplorerProps {
+ /** Pull this episode's graph from the backend. Omit only in tests (dataset preloaded). */
+ episodeRefId?: string
+ /** Overlay mode: called on Escape-with-nothing-selected and the close button. */
+ onClose?: () => void
+}
+
+export function PlayableExplorer({ episodeRefId, onClose }: PlayableExplorerProps = {}) {
+ const [selectedId, setSelectedId] = useState(null)
+ const [hoveredId, setHoveredId] = useState(null)
+ const [showRelations, setShowRelations] = useState(true)
+ // Bumped after every dataset swap so BoardView recomputes its layout memos.
+ const [dataVersion, setDataVersion] = useState(0)
+ const [status, setStatus] = useState<"loading" | "ready" | "error">(
+ episodeRefId ? "loading" : "ready"
+ )
+ const [error, setError] = useState(null)
+ const zoomApiRef = useRef(null)
+
+ const registerZoomApi = useCallback((api: ZoomApi | null) => {
+ zoomApiRef.current = api
+ }, [])
+
+ // Dataset switch, handled during render (React's adjust-state pattern):
+ // changing episodes resets selection/status; no episode clears the dataset.
+ const [prevEpisodeRef, setPrevEpisodeRef] = useState(episodeRefId)
+ if (prevEpisodeRef !== episodeRefId) {
+ setPrevEpisodeRef(episodeRefId)
+ setSelectedId(null)
+ if (episodeRefId) {
+ setStatus("loading")
+ setError(null)
+ } else {
+ resetBoardData()
+ setDataVersion((v) => v + 1)
+ setStatus("ready")
+ }
+ }
+
+ // Live pull for overlay mode (tests preload the dataset, no fetch needed).
+ useEffect(() => {
+ if (!episodeRefId) return
+ let cancelled = false
+ fetchEpisodeBoardData(episodeRefId)
+ .then(({ nodes, edges }) => {
+ if (cancelled) return
+ setBoardData(nodes, edges, episodeRefId)
+ setDataVersion((v) => v + 1)
+ setStatus("ready")
+ })
+ .catch((e: unknown) => {
+ if (cancelled) return
+ setError(e instanceof Error ? e.message : String(e))
+ setStatus("error")
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [episodeRefId])
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return
+ // Progressive dismiss: close the detail panel first, then the overlay.
+ if (selectedId) setSelectedId(null)
+ else onClose?.()
+ }
+ window.addEventListener("keydown", onKey)
+ return () => window.removeEventListener("keydown", onKey)
+ }, [selectedId, onClose])
+
+ const selectedNode = selectedId ? nodeById.get(selectedId) : undefined
+
+ return (
+
+
+ {/* Ambient color wash — keeps the canvas from feeling flat */}
+
+
+
+
+
+ {status === "ready" && (
+
+ )}
+
+ {status === "loading" && (
+
+
+
+ pulling episode graph…
+
+
+ )}
+
+ {status === "error" && (
+
+
+ failed to load episode
+
+
+ {error}
+
+ {onClose && (
+
+ )}
+
+ )}
+
+ {/* HUD header */}
+
+
+ {episodeRefId ? "Episode board · live" : "Episode board"}
+
+
+ {boardNodes.length} nodes · {boardEdges.length} edges
+
+
+
+ {/* Zoom controls + relations toggle */}
+
+
+
+
+
+ {onClose && (
+
+ )}
+
+
+ {/* Hint */}
+
+
drag to pan · scroll to zoom · zoom in for detail
+
+
+ supports
+
+
+ contradicts
+
+
+ made claim
+
+
+
+
+ {/* Type legend (frames are gone — chips are anchored by meaning, color = type) */}
+
+ {getTypeCounts().map(([type, count]) => (
+
+
+ {type} {count}
+
+ ))}
+
+
+ {/* Detail panel */}
+ {selectedNode &&
setSelectedId(null)} />}
+
+ )
+}
+
+function prop(node: BoardNode, key: string): string | null {
+ const v = node.properties?.[key]
+ return typeof v === "string" && v.length > 0 ? v : null
+}
+
+function DetailPanel({ node, onClose }: { node: BoardNode; onClose: () => void }) {
+ const chapter = useMemo(
+ () => getChapters().find((c) => c.node.ref_id === node.ref_id),
+ [node.ref_id]
+ )
+ const description = prop(node, "description") ?? prop(node, "summary") ?? prop(node, "claim_text")
+ const transcript = prop(node, "transcript")
+ const sourceLink = prop(node, "source_link")
+ const mediaUrl = prop(node, "media_url")
+ const clipMs = parseTimestampMs(node.properties.timestamp)
+ const speaker = prop(node, "speaker_name")
+ const triplicateSubject = prop(node, "triplicate_subject")
+ const triplicatePredicate = prop(node, "triplicate_predicate")
+ const triplicateObject = prop(node, "triplicate_object")
+
+ let timeLine: string | null = null
+ if (chapter) {
+ timeLine = `${formatMs(chapter.startMs)} – ${formatMs(chapter.endMs)}`
+ } else if (node.node_type === "Clip" && clipMs != null) {
+ timeLine = `at ${formatMs(clipMs)}`
+ } else if (node.node_type === "Episode") {
+ const dur = Number(node.properties.duration)
+ if (!Number.isNaN(dur) && dur > 0) timeLine = `duration ${formatMs(dur * 1000)}`
+ }
+
+ return (
+
+
+
+
+
+ {node.node_type}
+
+
+ {nodeLabel(node)}
+
+
+
+
+
+ {timeLine &&
{timeLine}
}
+ {speaker && (
+
— {speaker}
+ )}
+ {description && (
+
{description}
+ )}
+ {triplicateSubject && triplicatePredicate && triplicateObject && (
+
+ {triplicateSubject}
+ {triplicatePredicate}
+ {triplicateObject}
+
+ )}
+ {transcript && (
+
+ {transcript}
+
+ )}
+
+ {mediaUrl && (
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/playable/view-types.ts b/src/components/playable/view-types.ts
new file mode 100644
index 0000000..dd576e7
--- /dev/null
+++ b/src/components/playable/view-types.ts
@@ -0,0 +1,5 @@
+export interface ZoomApi {
+ zoomIn: () => void
+ zoomOut: () => void
+ reset: () => void
+}
diff --git a/src/lib/board-dataset.ts b/src/lib/board-dataset.ts
new file mode 100644
index 0000000..5e9c0c0
--- /dev/null
+++ b/src/lib/board-dataset.ts
@@ -0,0 +1,234 @@
+/**
+ * The episode board's active dataset: a module-level graph slice (nodes +
+ * edges) plus derived accessors (chapters, claims, mentions, relations).
+ *
+ * The dataset starts empty; `setBoardData` points the board at a graph slice
+ * (a live episode pull via `fetchEpisodeBoardData`, or a test fixture). Only
+ * one board is mounted at a time, so a module-level swap is safe — the
+ * explorer bumps a data version to re-render after swapping.
+ */
+
+export interface BoardNode {
+ ref_id: string
+ node_type: string
+ properties: Record
+}
+
+export interface BoardEdge {
+ ref_id: string
+ edge_type: string
+ source: string
+ target: string
+ weight?: number
+ properties: {
+ index?: number
+ timestamp_start?: string
+ timestamp_end?: string
+ [key: string]: unknown
+ }
+}
+
+export let boardNodes: BoardNode[] = []
+export let boardEdges: BoardEdge[] = []
+export let nodeById = new Map(boardNodes.map((n) => [n.ref_id, n]))
+
+/** Point the board at a new graph slice (e.g. a live episode pull).
+ * `anchorEpisodeId` picks which Episode card is the center when the slice
+ * contains more than one. */
+export function setBoardData(
+ nodes: BoardNode[],
+ edges: BoardEdge[],
+ anchorEpisodeId?: string
+) {
+ boardNodes = nodes
+ boardEdges = edges
+ nodeById = new Map(nodes.map((n) => [n.ref_id, n]))
+ const anchor = anchorEpisodeId ? nodeById.get(anchorEpisodeId) : undefined
+ episodeNode =
+ (anchor?.node_type === "Episode" ? anchor : undefined) ??
+ nodes.find((n) => n.node_type === "Episode")
+ showNode = nodes.find((n) => n.node_type === "Show")
+}
+
+/** Clear the dataset (board unmounted / episode closed). */
+export function resetBoardData() {
+ setBoardData([], [])
+}
+
+// ─── Labels ─────────────────────────────────────────────────────────────────
+
+const LABEL_KEYS = ["episode_title", "name", "show_title", "title"]
+
+export function nodeLabel(node: BoardNode): string {
+ for (const key of LABEL_KEYS) {
+ const v = node.properties?.[key]
+ if (typeof v === "string" && v.length > 0) return v
+ }
+ return node.ref_id
+}
+
+export function truncateLabel(label: string, max = 34): string {
+ return label.length > max ? `${label.slice(0, max - 1)}…` : label
+}
+
+// ─── Type colors ────────────────────────────────────────────────────────────
+// Vibrant accents per node type — same role as ICON_ACCENT_MAP in
+// schema-icons.ts (schema primary colors are too dark for small UI elements).
+
+export const TYPE_COLORS: Record = {
+ Episode: "#4cc9f0",
+ Show: "#7209b7",
+ Chapter: "#3a86ff",
+ Clip: "#fb8500",
+ Person: "#ff006e",
+ Topic: "#8ecae6",
+ Organization: "#8338ec",
+ Product: "#06d6a0",
+ Location: "#ef476f",
+ Claim: "#ffd166",
+}
+
+export function typeColor(nodeType: string): string {
+ return TYPE_COLORS[nodeType] ?? "#9aa5b1"
+}
+
+// ─── Timestamps ─────────────────────────────────────────────────────────────
+
+/** Chapter timestamps are ms-as-string ("13417"); clips use "m:ss" ("4:48"). */
+export function parseTimestampMs(value: unknown): number | null {
+ if (typeof value !== "string" || value.length === 0) return null
+ if (value.includes(":")) {
+ const parts = value.split(":").map((p) => Number(p))
+ if (parts.some((p) => Number.isNaN(p))) return null
+ const secs = parts.reduce((acc, p) => acc * 60 + p, 0)
+ return secs * 1000
+ }
+ const ms = Number(value)
+ return Number.isNaN(ms) ? null : ms
+}
+
+export function formatMs(ms: number): string {
+ const total = Math.round(ms / 1000)
+ const m = Math.floor(total / 60)
+ const s = total % 60
+ return `${m}:${String(s).padStart(2, "0")}`
+}
+
+// ─── Graph slices ───────────────────────────────────────────────────────────
+
+export let episodeNode = boardNodes.find((n) => n.node_type === "Episode")
+export let showNode = boardNodes.find((n) => n.node_type === "Show")
+
+export interface ChapterInfo {
+ node: BoardNode
+ edge: BoardEdge
+ index: number
+ startMs: number
+ endMs: number
+}
+
+/** Chapters of the episode with time ranges. Live HAS edges sometimes carry
+ * missing/duplicate indexes, so order by start time (index as tie-break) and
+ * renumber by sorted position — the displayed index is the ordinal. */
+export function getChapters(): ChapterInfo[] {
+ if (!episodeNode) return []
+ const out: ChapterInfo[] = []
+ for (const e of boardEdges) {
+ if (e.source !== episodeNode.ref_id || e.edge_type !== "HAS") continue
+ const node = nodeById.get(e.target)
+ if (!node || node.node_type !== "Chapter") continue
+ out.push({
+ node,
+ edge: e,
+ index: e.properties.index ?? 0,
+ startMs:
+ parseTimestampMs(e.properties.timestamp_start) ??
+ parseTimestampMs(node.properties.timestamp_start) ??
+ 0,
+ endMs:
+ parseTimestampMs(e.properties.timestamp_end) ??
+ parseTimestampMs(node.properties.timestamp_end) ??
+ parseTimestampMs(node.properties.timestamp) ??
+ 0,
+ })
+ }
+ out.sort((a, b) => a.startMs - b.startMs || a.index - b.index)
+ return out.map((c, i) => ({ ...c, index: i }))
+}
+
+export function getClips(): BoardNode[] {
+ return boardNodes.filter((n) => n.node_type === "Clip")
+}
+
+export const ENTITY_TYPES = ["Person", "Topic", "Organization", "Product", "Location"]
+
+/** Non-episode content nodes grouped by type (People / Topics / Orgs / ...). */
+export function getEntitiesByType(): Map {
+ const map = new Map()
+ for (const t of ENTITY_TYPES) map.set(t, [])
+ for (const n of boardNodes) {
+ const bucket = map.get(n.node_type)
+ if (bucket) bucket.push(n)
+ }
+ return map
+}
+
+export function getHost(): BoardNode | undefined {
+ const hostEdge = boardEdges.find((e) => e.edge_type === "IS_HOST")
+ return hostEdge ? nodeById.get(hostEdge.source) : undefined
+}
+
+/** Claims sourced from a chapter (Claim -SOURCE-> Chapter), per chapter ref_id. */
+export function getClaimsByChapter(): Map {
+ const map = new Map()
+ for (const e of boardEdges) {
+ if (e.edge_type !== "SOURCE") continue
+ const claim = nodeById.get(e.source)
+ if (!claim || claim.node_type !== "Claim") continue
+ const list = map.get(e.target)
+ if (list) list.push(claim)
+ else map.set(e.target, [claim])
+ }
+ return map
+}
+
+/** Claim-to-claim and claim authorship relations pulled from the DB. */
+export function getClaimRelations(): {
+ supports: BoardEdge[]
+ contradicts: BoardEdge[]
+ madeBy: BoardEdge[]
+} {
+ const supports: BoardEdge[] = []
+ const contradicts: BoardEdge[] = []
+ const madeBy: BoardEdge[] = []
+ for (const e of boardEdges) {
+ if (e.edge_type === "SUPPORTS") supports.push(e)
+ else if (e.edge_type === "CONTRADICTS") contradicts.push(e)
+ else if (e.edge_type === "MADE_CLAIM") madeBy.push(e)
+ }
+ return { supports, contradicts, madeBy }
+}
+
+/** Chapter→Entity MENTIONS edges (chapters reference people/topics/etc). */
+export function getChapterMentions(): BoardEdge[] {
+ return boardEdges.filter(
+ (e) => e.edge_type === "MENTIONS" && nodeById.get(e.source)?.node_type === "Chapter"
+ )
+}
+
+/** ref_ids directly connected to the given node (either direction). */
+export function neighborIds(refId: string): Set {
+ const out = new Set()
+ for (const e of boardEdges) {
+ if (e.source === refId) out.add(e.target)
+ if (e.target === refId) out.add(e.source)
+ }
+ return out
+}
+
+/** All type names present in the data with their node counts, for the legend. */
+export function getTypeCounts(): [string, number][] {
+ const counts = new Map()
+ for (const n of boardNodes) counts.set(n.node_type, (counts.get(n.node_type) ?? 0) + 1)
+ return [...counts.entries()]
+}
diff --git a/src/lib/episode-board-data.ts b/src/lib/episode-board-data.ts
new file mode 100644
index 0000000..449127a
--- /dev/null
+++ b/src/lib/episode-board-data.ts
@@ -0,0 +1,55 @@
+import { getNode, type GraphEdge, type GraphNode } from "./graph-api"
+import type { BoardEdge, BoardNode } from "./board-dataset"
+
+/**
+ * Pulls everything the episode board needs from the live backend:
+ * the episode's 1-hop neighbourhood (chapters, clips, show, entities) plus
+ * one extra hop per chapter (claims, chapter-level mentions, and whatever
+ * claim↔claim / authorship edges sit inside that neighbourhood set).
+ */
+
+function toBoardNode(n: GraphNode): BoardNode {
+ return { ref_id: n.ref_id, node_type: n.node_type, properties: n.properties ?? {} }
+}
+
+function edgeKey(e: GraphEdge): string {
+ return e.ref_id ?? `${e.source}|${e.edge_type}|${e.target}`
+}
+
+function toBoardEdge(e: GraphEdge): BoardEdge {
+ return {
+ ref_id: edgeKey(e),
+ edge_type: e.edge_type,
+ source: e.source,
+ target: e.target,
+ properties: (e.properties ?? {}) as BoardEdge["properties"],
+ }
+}
+
+export async function fetchEpisodeBoardData(
+ episodeRefId: string,
+ signal?: AbortSignal
+): Promise<{ nodes: BoardNode[]; edges: BoardEdge[] }> {
+ const nodes = new Map()
+ const edges = new Map()
+ const merge = (g: { nodes: GraphNode[]; edges: GraphEdge[] }) => {
+ for (const n of g.nodes) nodes.set(n.ref_id, toBoardNode(n))
+ for (const e of g.edges) edges.set(edgeKey(e), toBoardEdge(e))
+ }
+
+ const firstHop = await getNode(episodeRefId, "edges", signal)
+ merge(firstHop)
+
+ // Second hop: each chapter's neighbourhood carries its claims (SOURCE),
+ // chapter-level entity MENTIONS, and claim relations. A failed chapter hop
+ // is non-fatal — the board just shows that chapter without claims.
+ const chapterIds = [...nodes.values()]
+ .filter((n) => n.node_type === "Chapter")
+ .map((n) => n.ref_id)
+ const hops = await Promise.allSettled(chapterIds.map((id) => getNode(id, "edges", signal)))
+ for (const h of hops) {
+ if (h.status === "fulfilled") merge(h.value)
+ }
+
+ return { nodes: [...nodes.values()], edges: [...edges.values()] }
+}
diff --git a/src/stores/board-store.ts b/src/stores/board-store.ts
new file mode 100644
index 0000000..f8d187f
--- /dev/null
+++ b/src/stores/board-store.ts
@@ -0,0 +1,16 @@
+"use client"
+
+import { create } from "zustand"
+
+/** Drives the episode-board overlay: which episode is open (null = closed). */
+interface BoardState {
+ episodeRefId: string | null
+ openBoard: (refId: string) => void
+ closeBoard: () => void
+}
+
+export const useBoardStore = create((set) => ({
+ episodeRefId: null,
+ openBoard: (refId) => set({ episodeRefId: refId }),
+ closeBoard: () => set({ episodeRefId: null }),
+}))