From 0685922f7513ee0122cc8b5d1f1532239a0cc98c Mon Sep 17 00:00:00 2001 From: Rassl Date: Tue, 18 Aug 2026 19:03:16 +0400 Subject: [PATCH] feat: 2d view enhance for episode --- .../__tests__/playable-explorer.test.tsx | 73 +++++++++++- src/components/playable/board-layout.ts | 107 +++++++++++++----- 2 files changed, 153 insertions(+), 27 deletions(-) diff --git a/src/components/playable/__tests__/playable-explorer.test.tsx b/src/components/playable/__tests__/playable-explorer.test.tsx index 3e37b4a..22f04cf 100644 --- a/src/components/playable/__tests__/playable-explorer.test.tsx +++ b/src/components/playable/__tests__/playable-explorer.test.tsx @@ -9,7 +9,7 @@ import { getTypeCounts, setBoardData, } from "@/lib/board-dataset" -import { computeBoardLayout } from "../board-layout" +import { computeBoardLayout, type CardPlacement } from "../board-layout" import { loadFixture } from "./fixture" beforeEach(() => { @@ -88,3 +88,74 @@ describe("PlayableExplorer (board view)", () => { expect(getChapters()).toHaveLength(fixtureChapterCount) }) }) + +describe("entity band on a short episode", () => { + /** One chapter, ten entities — the shape that collapsed the band into a + * single overlapping column when chip x was clamped to the chapter strip. */ + function loadShortEpisode(entityCount: number) { + const nodes = [ + { ref_id: "ep-1", node_type: "Episode", properties: { episode_title: "Short" } }, + { ref_id: "ch-1", node_type: "Chapter", properties: { name: "Only chapter" } }, + ...Array.from({ length: entityCount }, (_, i) => ({ + ref_id: `t-${i}`, + node_type: "Topic", + properties: { name: `Topic ${i}` }, + })), + ] + const edges = [ + { ref_id: "e-ch", edge_type: "HAS", source: "ep-1", target: "ch-1", properties: { index: 0 } }, + ...Array.from({ length: entityCount }, (_, i) => ({ + ref_id: `e-m-${i}`, + edge_type: "MENTIONS", + source: "ch-1", + target: `t-${i}`, + properties: {}, + })), + ] + setBoardData(nodes, edges, "ep-1") + } + + function overlaps(a: CardPlacement, b: CardPlacement) { + return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h + } + + it("spreads chips across columns instead of one per row", () => { + try { + loadShortEpisode(10) + const layout = computeBoardLayout() + expect(layout.entities).toHaveLength(10) + + // a real grid: several chips share a row, so the band stays short + const perRow = new Map() + for (const e of layout.entities) { + perRow.set(e.card.y, (perRow.get(e.card.y) ?? 0) + 1) + } + expect(Math.max(...perRow.values())).toBeGreaterThanOrEqual(3) + expect(perRow.size).toBeLessThanOrEqual(4) + + // and the band still clears the chapter bus below it + for (const e of layout.entities) { + expect(e.card.y + e.card.h).toBeLessThanOrEqual(layout.chapterBusY) + } + } finally { + loadFixture() + } + }) + + it("never draws one chip on top of another", () => { + try { + for (const n of [1, 3, 6, 10, 25]) { + loadShortEpisode(n) + const cards = computeBoardLayout().entities.map((e) => e.card) + expect(cards).toHaveLength(n) + for (let i = 0; i < cards.length; i++) { + for (let j = i + 1; j < cards.length; j++) { + expect(overlaps(cards[i], cards[j])).toBe(false) + } + } + } + } finally { + loadFixture() + } + }) +}) diff --git a/src/components/playable/board-layout.ts b/src/components/playable/board-layout.ts index 1c604e7..0e7cc40 100644 --- a/src/components/playable/board-layout.ts +++ b/src/components/playable/board-layout.ts @@ -21,6 +21,9 @@ import { * 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. + * The band is sized independently of the strip: on a short episode, where the + * anchors all collapse onto one x and encode nothing, the chips fall back to a + * centered grid. */ export interface CardPlacement { @@ -90,6 +93,9 @@ 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 +/** Widest the band goes when chips are laid out as a grid — keeps a short + * episode's band in proportion with the episode card above it. */ +const ENTITY_MAX_COLS = 4 const BUS_GAP = 46 // space between the entity band and the chapter bus function hasMedia(node: BoardNode): boolean { @@ -135,7 +141,6 @@ export function computeBoardLayout(detail = false): BoardLayout { 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 @@ -170,38 +175,88 @@ export function computeBoardLayout(detail = false): BoardLayout { // 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) + // ─── Entity band geometry ───────────────────────────────────────────── + // Anchoring encodes "where in the episode this thing matters" only when the + // anchors actually differ. With a single chapter — or no mention edges — + // every anchor collapses onto the same x and the axis means nothing, so lay + // the chips out as a centered grid instead of pretending it's temporal. + const anchors = anchored.map((a) => a.anchor) + const anchorSpread = anchors.length ? Math.max(...anchors) - Math.min(...anchors) : 0 + const degenerate = anchorSpread < CHIP_W + CHIP_GAP + + // The band gets its OWN width rather than borrowing the chapter strip's. A + // short episode has a narrow strip — one chapter is 196px, narrower than a + // single 150px chip — which would clamp every chip to the same x, leave the + // row packer unable to ever share a row, and collapse the band into a + // vertical column. + const bandCols = Math.max( + 1, + degenerate ? Math.min(anchored.length, ENTITY_MAX_COLS) : 0, + Math.ceil(anchored.length / ENTITY_MAX_ROWS) + ) + const bandW = Math.max(chaptersTotal, EPISODE_W, bandCols * (CHIP_W + CHIP_GAP) - CHIP_GAP) + const bandMin = -bandW / 2 + const bandMax = bandW / 2 + 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 }) + let bandRows: number + + if (degenerate) { + // ─── Degenerate anchors: centered grid ────────────────────────────── + const perRow = bandCols + bandRows = Math.ceil(anchored.length / perRow) + anchored.forEach(({ node }, i) => { + const row = Math.floor(i / perRow) + const col = i % perRow + const inRow = Math.min(perRow, anchored.length - row * perRow) + const rowW = inRow * CHIP_W + (inRow - 1) * CHIP_GAP entities.push({ node, - card: { id: node.ref_id, x: fx, y: entityBandTop + rowIdx * ENTITY_ROW_PITCH, w: CHIP_W, h: CHIP_H }, + card: { + id: node.ref_id, + x: -rowW / 2 + col * (CHIP_W + CHIP_GAP), + y: entityBandTop + row * 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 }, }) + } else { + // ─── 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) + for (const { node, anchor } of anchored) { + const x = Math.min(Math.max(anchor - CHIP_W / 2, bandMin), bandMax - 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 — extend the shortest row past its last chip. Never clamp + // back inside the band: a clamped x lands on top of a chip that is + // already there, which reads as one pill drawn over another. + 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), bandMin) + const fx = last + CHIP_W + CHIP_GAP + 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 }, + }) + } + bandRows = rows.length } - const bandBottom = entityBandTop + Math.max(rows.length, 1) * ENTITY_ROW_PITCH + const bandBottom = entityBandTop + Math.max(bandRows, 1) * ENTITY_ROW_PITCH const busY = bandBottom + BUS_GAP const chapterY = busY + 40