From 89e47b0f089a33747c4f3e1c932b48d75864e230 Mon Sep 17 00:00:00 2001 From: Rassl Date: Tue, 4 Aug 2026 16:10:07 +0400 Subject: [PATCH 1/2] feat: episode 2d view --- src/app/playable/page.tsx | 7 + src/components/layout/app-layout.tsx | 2 + .../__tests__/playable-explorer.test.tsx | 87 ++ src/components/playable/board-layout.ts | 227 +++++ src/components/playable/board-view.tsx | 832 ++++++++++++++++++ .../playable/episode-board-overlay.tsx | 17 + src/components/playable/playable-explorer.tsx | 321 +++++++ src/components/playable/view-types.ts | 5 + src/components/universe/graph-pane.tsx | 3 + src/lib/episode-board-data.ts | 55 ++ src/lib/playable-mock.ts | 250 ++++++ src/stores/board-store.ts | 16 + 12 files changed, 1822 insertions(+) create mode 100644 src/app/playable/page.tsx create mode 100644 src/components/playable/__tests__/playable-explorer.test.tsx create mode 100644 src/components/playable/board-layout.ts create mode 100644 src/components/playable/board-view.tsx create mode 100644 src/components/playable/episode-board-overlay.tsx create mode 100644 src/components/playable/playable-explorer.tsx create mode 100644 src/components/playable/view-types.ts create mode 100644 src/lib/episode-board-data.ts create mode 100644 src/lib/playable-mock.ts create mode 100644 src/stores/board-store.ts diff --git a/src/app/playable/page.tsx b/src/app/playable/page.tsx new file mode 100644 index 0000000..fc26ec1 --- /dev/null +++ b/src/app/playable/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { PlayableExplorer } from "@/components/playable/playable-explorer" + +export default function PlayablePage() { + return +} 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/playable/__tests__/playable-explorer.test.tsx b/src/components/playable/__tests__/playable-explorer.test.tsx new file mode 100644 index 0000000..1bca460 --- /dev/null +++ b/src/components/playable/__tests__/playable-explorer.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { PlayableExplorer } from "../playable-explorer" +import { + getChapters, + getEntitiesByType, + getClaimRelations, + getChapterMentions, + getTypeCounts, + setPlayableData, + resetPlayableData, +} from "@/lib/playable-mock" +import { computeBoardLayout } from "../board-layout" + +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() + }) + + // Runs last: mutates the module dataset, then restores the fixture. + it("swaps the active dataset and restores the fixture", () => { + const fixtureChapterCount = getChapters().length + try { + setPlayableData( + [{ ref_id: "ep-x", node_type: "Episode", properties: { episode_title: "X" } }], + [], + "ep-x" + ) + expect(getChapters()).toHaveLength(0) + expect(getTypeCounts()).toEqual([["Episode", 1]]) + } finally { + resetPlayableData() + } + 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..b4fc7a0 --- /dev/null +++ b/src/components/playable/board-layout.ts @@ -0,0 +1,227 @@ +import { + episodeNode, + showNode, + getChapters, + getClips, + getEntitiesByType, + getHost, + getClaimsByChapter, + getChapterMentions, + parseTimestampMs, + type ChapterInfo, + type PlayableNode, +} from "@/lib/playable-mock" + +/** + * 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: PlayableNode; card: CardPlacement; ms: number | null }[] + chapters: { info: ChapterInfo; card: CardPlacement }[] + /** Claim chips stacked under their parent chapter card. */ + claims: { node: PlayableNode; card: CardPlacement; chapterId: string }[] + /** Entity chips anchored above the chapters that mention them. */ + entities: { node: PlayableNode; 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 +const SHOW_W = 190 +const SHOW_H = 64 +const CLIP_W = 236 +const CLIP_H = 82 +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 - 16 +const CLAIM_X_INSET = 8 +const CLAIM_H = 30 +const CLAIM_GAP = 8 +const CLAIM_TOP_GAP = 14 + +const CHIP_W = 150 +const CHIP_H = 32 +const CHIP_GAP = 10 +const ENTITY_BAND_TOP = 96 +const ENTITY_ROW_PITCH = 44 +const ENTITY_MAX_ROWS = 6 +const BUS_GAP = 46 // space between the entity band and the chapter bus + +export function computeBoardLayout(): BoardLayout { + const episode: CardPlacement | null = episodeNode + ? { + id: episodeNode.ref_id, + x: -EPISODE_W / 2, + y: -EPISODE_H / 2 - 30, + w: EPISODE_W, + h: EPISODE_H, + } + : 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: 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) + + // ─── 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: ENTITY_BAND_TOP + 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: ENTITY_BAND_TOP + rowIdx * ENTITY_ROW_PITCH, w: CHIP_W, h: CHIP_H }, + }) + } + + const bandBottom = ENTITY_BAND_TOP + 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. + const claimsByChapter = getClaimsByChapter() + const claims: BoardLayout["claims"] = [] + for (const { card } of chapters) { + const chapterClaims = claimsByChapter.get(card.id) ?? [] + chapterClaims.forEach((node, i) => { + claims.push({ + node, + chapterId: card.id, + card: { + id: node.ref_id, + x: card.x + CLAIM_X_INSET, + y: card.y + card.h + CLAIM_TOP_GAP + i * (CLAIM_H + CLAIM_GAP), + w: CLAIM_W, + h: CLAIM_H, + }, + }) + }) + } + + 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..5ad3430 --- /dev/null +++ b/src/components/playable/board-view.tsx @@ -0,0 +1,832 @@ +"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, + playableEdges, +} from "@/lib/playable-mock" +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 ( +