diff --git a/app/timeline/page.tsx b/app/timeline/page.tsx index f7cc7c6c..2da3ffc3 100644 --- a/app/timeline/page.tsx +++ b/app/timeline/page.tsx @@ -1,11 +1,10 @@ import type { Metadata } from "next"; import { ParchmentLayout } from "@/components/ParchmentLayout"; import { PageHeading } from "@/components/PageHeading"; -import { TimelineChart } from "@/components/TimelineChart"; -import { TimelineMinimap } from "@/components/TimelineMinimap"; +import { TimelineExplorer } from "@/components/TimelineExplorer"; import { sectionGlyphs } from "@/components/SectionGlyphs/SectionGlyphs"; import { loadAllBattles, loadAllEvents } from "@/lib/content"; -import { buildTimeline } from "@/lib/timeline"; +import { prepareTimeline } from "@/lib/timeline"; import styles from "@/app/timeline/page.module.scss"; export const metadata: Metadata = { @@ -21,7 +20,7 @@ export default async function TimelinePage() { ]); const published = battles.filter((b) => !b.frontmatter.draft); const publishedEvents = events.filter((e) => !e.frontmatter.draft); - const model = buildTimeline({ + const source = prepareTimeline({ battles: published.map((b) => b.frontmatter), events: publishedEvents.map((e) => e.frontmatter), }); @@ -37,9 +36,8 @@ export default async function TimelinePage() { subtitle="Trace the centuries: the battles and great events of the Known World, laid out in time and place." />
- +
- {hasApproximate && (

* approximate or legendary date

)} diff --git a/components/FilteredHouseList/FilteredHouseList.module.scss b/components/FilteredHouseList/FilteredHouseList.module.scss index 26f64daa..19e39182 100644 --- a/components/FilteredHouseList/FilteredHouseList.module.scss +++ b/components/FilteredHouseList/FilteredHouseList.module.scss @@ -28,6 +28,15 @@ font-size: 0.85rem; color: var(--ink-faded); letter-spacing: 0.5px; + + // Match the 50px height of the sibling `SortToggle` / `ViewToggle` + // buttons and enlarge the option text. Scoped here so the shared + // `listSearch.pageSizeSelect` (used by pagination) stays compact. + .rankSelect { + height: 50px; + font-size: 1.1rem; + padding: 0 1.9rem 0 0.85rem; + } } .rankLabel { diff --git a/components/FilteredHouseList/FilteredHouseList.test.tsx b/components/FilteredHouseList/FilteredHouseList.test.tsx index b7a43eff..8c41ff29 100644 --- a/components/FilteredHouseList/FilteredHouseList.test.tsx +++ b/components/FilteredHouseList/FilteredHouseList.test.tsx @@ -617,116 +617,6 @@ describe("FilteredHouseList page persistence", () => { }); }); -describe("FilteredHouseList status filter", () => { - const mixed: HouseItem[] = [ - { slug: "stark", name: "Stark", region: "north", regionLabel: "The North" }, - { - slug: "reyne", - name: "Reyne", - region: "westerlands", - regionLabel: "The Westerlands", - extinct: true, - }, - { - slug: "gardener", - name: "Gardener", - region: "reach", - regionLabel: "The Reach", - extinct: true, - }, - ]; - - it("exposes a house-status filter group", () => { - renderWithNuqs(); - expect(screen.getByRole("group", { name: /house status/i })).toBeDefined(); - }); - - it("shows every house regardless of status by default", () => { - renderWithNuqs(); - expect(screen.getAllByRole("link").length).toBe(3); - }); - - it("shows only extinct houses when Extinct is selected", () => { - renderWithNuqs(); - fireEvent.click(screen.getByRole("button", { name: /extinct houses/i })); - const cards = screen.getAllByRole("link"); - expect(cards.map((c) => c.textContent)).toEqual( - expect.arrayContaining([ - expect.stringContaining("Reyne"), - expect.stringContaining("Gardener"), - ]), - ); - expect(cards.length).toBe(2); - }); - - it("shows only standing houses when Standing is selected", () => { - renderWithNuqs(); - fireEvent.click(screen.getByRole("button", { name: /standing houses/i })); - const cards = screen.getAllByRole("link"); - expect(cards.length).toBe(1); - expect(cards[0].textContent).toContain("Stark"); - }); - - it("reflects the status filter in the total count", () => { - renderWithNuqs(); - expect(screen.getByText("3 houses")).toBeDefined(); - fireEvent.click(screen.getByRole("button", { name: /extinct houses/i })); - expect(screen.getByText("2 houses")).toBeDefined(); - }); - - it("hydrates the filter from ?status=extinct on mount", () => { - renderWithNuqs(, { - searchParams: "?status=extinct", - }); - expect(screen.getAllByRole("link").length).toBe(2); - expect( - screen - .getByRole("button", { name: /extinct houses/i }) - .getAttribute("aria-pressed"), - ).toBe("true"); - }); - - it("writes ?status=extinct when Extinct is selected", async () => { - const { onUrlUpdate } = renderWithNuqs(); - fireEvent.click(screen.getByRole("button", { name: /extinct houses/i })); - await flushNuqs(); - expect(lastQueryString(onUrlUpdate)).toBe("?status=extinct"); - }); - - it("removes ?status= when the filter returns to Any status", async () => { - const { onUrlUpdate } = renderWithNuqs( - , - { - searchParams: "?status=extinct", - }, - ); - fireEvent.click(screen.getByRole("button", { name: /any status/i })); - await flushNuqs(); - expect(lastQueryString(onUrlUpdate)).toBe(""); - }); - - it("resets ?page= when the status filter changes", async () => { - const { onUrlUpdate } = renderWithNuqs( - , - { searchParams: "?page=2" }, - ); - fireEvent.click(screen.getByRole("button", { name: /standing houses/i })); - await flushNuqs(); - expect(lastQueryString(onUrlUpdate)).toBe("?status=standing"); - }); - - it("applies the status filter inside region grouping", () => { - renderWithNuqs(); - fireEvent.click(screen.getByRole("button", { name: /group by region/i })); - fireEvent.click(screen.getByRole("button", { name: /extinct houses/i })); - expect( - screen.getByRole("button", { name: /the westerlands/i }), - ).toBeDefined(); - expect(screen.getByRole("button", { name: /the reach/i })).toBeDefined(); - expect(screen.queryByRole("button", { name: /the north/i })).toBeNull(); - }); -}); - describe("FilteredHouseList rank filter", () => { const ranked: HouseItem[] = [ { @@ -778,17 +668,6 @@ describe("FilteredHouseList rank filter", () => { expect(cards.length).toBe(2); }); - it("combines the rank filter with the status filter", () => { - renderWithNuqs(); - fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { - target: { value: "lordly" }, - }); - fireEvent.click(screen.getByRole("button", { name: /extinct houses/i })); - const cards = screen.getAllByRole("link"); - expect(cards.length).toBe(1); - expect(cards[0].textContent).toContain("Reyne"); - }); - it("reflects the rank filter in the total count", () => { renderWithNuqs(); fireEvent.change(screen.getByRole("combobox", { name: /house rank/i }), { diff --git a/components/FilteredHouseList/FilteredHouseList.tsx b/components/FilteredHouseList/FilteredHouseList.tsx index 7f6592ba..e4f31f09 100644 --- a/components/FilteredHouseList/FilteredHouseList.tsx +++ b/components/FilteredHouseList/FilteredHouseList.tsx @@ -81,20 +81,6 @@ const GROUP_OPTIONS = [ }, ]; -type StatusFilter = "all" | "standing" | "extinct"; - -const STATUS_FILTERS = ["all", "standing", "extinct"] as const; - -const STATUS_OPTIONS = [ - { value: "all" as const, label: "Any status", icon: }, - { - value: "standing" as const, - label: "Standing houses", - icon: , - }, - { value: "extinct" as const, label: "Extinct houses", icon: }, -]; - type RankFilter = "all" | "royal" | "lordly" | "knightly" | "other" | "exiled" | "extinct"; @@ -133,10 +119,6 @@ export function FilteredHouseList({ const [{ search, dir, size, page: rawPage }, setParams] = useQueryStates(parsers); const page = Math.max(1, rawPage); - const [status, setStatus] = useQueryState( - "status", - parseAsStringLiteral(STATUS_FILTERS).withDefault("all"), - ); const [rank, setRank] = useQueryState( "rank", parseAsStringLiteral(RANK_FILTERS).withDefault("all"), @@ -209,16 +191,10 @@ export function FilteredHouseList({ }, [items, dir]); const facetFiltered = useMemo(() => { - const byStatus = - status === "all" - ? sorted - : sorted.filter((item) => - status === "extinct" ? !!item.extinct : !item.extinct, - ); return rank === "all" - ? byStatus - : byStatus.filter((item) => item.rank === rank); - }, [sorted, status, rank]); + ? sorted + : sorted.filter((item) => item.rank === rank); + }, [sorted, rank]); const filtered = filterByName(facetFiltered, debounced); const total = facetFiltered.length; @@ -261,11 +237,6 @@ export function FilteredHouseList({ setParams({ dir: next, page: 1 }); }; - const handleStatusChange = (next: StatusFilter) => { - setStatus(next === "all" ? null : next); - setParams({ page: 1 }); - }; - const handleRankChange = (next: string) => { if (!isRankFilter(next)) return; setRank(next === "all" ? null : next); @@ -379,7 +350,7 @@ export function FilteredHouseList({ - ); } - -function AllStatusIcon() { - return ( - - - - ); -} - -function StandingIcon() { - return ( - - - - - ); -} - -function ExtinctIcon() { - return ( - - - - - - ); -} diff --git a/components/TimelineExplorer/TimelineExplorer.module.scss b/components/TimelineExplorer/TimelineExplorer.module.scss new file mode 100644 index 00000000..e74c134d --- /dev/null +++ b/components/TimelineExplorer/TimelineExplorer.module.scss @@ -0,0 +1,76 @@ +@use "breakpoints" as bp; + +.explorer { + display: grid; +} + +.chartWrap { + display: grid; +} + +// Floating pill in the lower-right, clear of the sticky site header and the +// vertically-centred minimap. Fixed so it stays reachable down the long chart. +.zoom { + position: fixed; + right: 0.75rem; + bottom: 1.25rem; + z-index: 8; + display: grid; + justify-items: center; + gap: 0.25rem; + padding: 0.35rem; + background: rgba(248, 236, 208, 0.94); + border: 1px solid rgba(107, 68, 35, 0.3); + border-radius: 2px; + box-shadow: 0 2px 12px rgba(61, 40, 23, 0.15); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} + +.zoomButton { + appearance: none; + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + background: rgba(248, 236, 208, 0.6); + border: 1px solid rgba(107, 68, 35, 0.35); + border-radius: 2px; + color: var(--ink); + font-family: var(--font-heading); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + transition: + border-color 120ms ease, + color 120ms ease, + background 120ms ease; + + &:hover:not(:disabled), + &:focus-visible:not(:disabled) { + border-color: var(--gold-leaf); + color: var(--gold-leaf); + background: rgba(248, 236, 208, 0.9); + outline: none; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.zoomLevel { + min-width: 2.25rem; + font-family: var(--font-heading); + font-variant: small-caps; + letter-spacing: 1px; + font-size: 0.85rem; + color: var(--ink-faded); + text-align: center; +} diff --git a/components/TimelineExplorer/TimelineExplorer.test.tsx b/components/TimelineExplorer/TimelineExplorer.test.tsx new file mode 100644 index 00000000..b9bfd3be --- /dev/null +++ b/components/TimelineExplorer/TimelineExplorer.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { TimelineExplorer } from "@/components/TimelineExplorer"; +import { prepareTimeline } from "@/lib/timeline"; +import type { Battle } from "@/lib/schemas"; + +const makeBattle = ({ + slug, + year, + region, +}: { + slug: string; + year: number; + region?: Battle["region"]; +}): Battle => ({ + slug, + name: slug, + type: "battle", + start: { year, era: "AC", precision: "exact" }, + end: { year, era: "AC", precision: "exact" }, + region, + participants: [], + commanders: [], + casualties: [], + aliases: [], + mentions: [], + sources: [], + draft: false, +}); + +// Two battles six years apart: clustered at the base scale, pulled apart once +// the vertical scale grows past the cluster gap. +const source = prepareTimeline({ + battles: [ + makeBattle({ slug: "aaa", year: 300, region: "north" }), + makeBattle({ slug: "bbb", year: 306, region: "north" }), + ], +}); + +beforeEach(() => { + vi.spyOn(window, "scrollTo").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("TimelineExplorer", () => { + it("renders zoom controls at the default 1x level", () => { + render(); + expect(screen.getByRole("group", { name: /timeline zoom/i })).toBeDefined(); + expect(screen.getByRole("button", { name: /zoom in/i })).toBeDefined(); + expect(screen.getByRole("button", { name: /zoom out/i })).toBeDefined(); + expect(screen.getByText("1×")).toBeDefined(); + }); + + it("groups near-simultaneous events into a cluster by default", () => { + render(); + expect(screen.getByRole("button", { name: /2 events/i })).toBeDefined(); + expect(screen.queryByRole("link", { name: /aaa/i })).toBeNull(); + }); + + it("breaks the cluster into individual entries as the user zooms in", () => { + render(); + const zoomIn = screen.getByRole("button", { name: /zoom in/i }); + fireEvent.click(zoomIn); + fireEvent.click(zoomIn); + fireEvent.click(zoomIn); + expect(screen.queryByRole("button", { name: /2 events/i })).toBeNull(); + expect(screen.getByRole("link", { name: /aaa/i })).toBeDefined(); + expect(screen.getByRole("link", { name: /bbb/i })).toBeDefined(); + }); + + it("advances the readout and disables zoom-in at the 16× ceiling", () => { + render(); + const zoomIn = screen.getByRole("button", { name: /zoom in/i }); + // Default is 1× (index 1); step up through 2 / 4 / 8 / 16. + fireEvent.click(zoomIn); + fireEvent.click(zoomIn); + fireEvent.click(zoomIn); + fireEvent.click(zoomIn); + expect(screen.getByText("16×")).toBeDefined(); + expect( + screen.getByRole("button", { name: /zoom in/i }).hasAttribute("disabled"), + ).toBe(true); + }); + + it("disables zoom-out at the floor", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /zoom out/i })); + expect(screen.getByText("0.5×")).toBeDefined(); + expect( + screen + .getByRole("button", { name: /zoom out/i }) + .hasAttribute("disabled"), + ).toBe(true); + }); +}); diff --git a/components/TimelineExplorer/TimelineExplorer.tsx b/components/TimelineExplorer/TimelineExplorer.tsx new file mode 100644 index 00000000..582d1bf7 --- /dev/null +++ b/components/TimelineExplorer/TimelineExplorer.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { TimelineChart } from "@/components/TimelineChart"; +import { TimelineMinimap } from "@/components/TimelineMinimap"; +import { + DEFAULT_ZOOM_INDEX, + PX_PER_YEAR, + ZOOM_LEVELS, + layoutTimeline, + yForYear, + yearForY, + type TimelineSource, +} from "@/lib/timeline"; +import styles from "@/components/TimelineExplorer/TimelineExplorer.module.scss"; + +type TimelineExplorerProps = { + source: TimelineSource; +}; + +const CHART_BODY_ID = "timeline-chart"; +const MAX_ZOOM_INDEX = ZOOM_LEVELS.length - 1; + +// `useLayoutEffect` warns during server rendering; the scroll-anchoring it +// drives only runs after a client interaction, so fall back to `useEffect` +// on the server where `window` is absent. +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +const clampZoom = (index: number): number => + Math.min(MAX_ZOOM_INDEX, Math.max(0, index)); + +export function TimelineExplorer({ source }: TimelineExplorerProps) { + const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); + const pxPerYear = PX_PER_YEAR * ZOOM_LEVELS[zoomIndex]; + const model = useMemo( + () => layoutTimeline({ source, pxPerYear }), + [source, pxPerYear], + ); + + // Pin the year under a focal point (viewport centre for the buttons, the + // cursor for wheel-zoom) so the view stays put across a re-layout. + const anchorRef = useRef<{ year: number; viewportY: number } | null>(null); + const chartRef = useRef(null); + + const zoomTo = (nextIndex: number, focalViewportY: number) => { + const index = clampZoom(nextIndex); + if (index === zoomIndex) return; + const body = document.getElementById(CHART_BODY_ID); + if (body) { + const bodyTop = body.getBoundingClientRect().top + window.scrollY; + const y = focalViewportY + window.scrollY - bodyTop; + anchorRef.current = { + year: yearForY({ y, minYear: source.minYear, pxPerYear }), + viewportY: focalViewportY, + }; + } + setZoomIndex(index); + }; + + useIsomorphicLayoutEffect(() => { + const anchor = anchorRef.current; + anchorRef.current = null; + if (!anchor) return; + const body = document.getElementById(CHART_BODY_ID); + if (!body) return; + const bodyTop = body.getBoundingClientRect().top + window.scrollY; + const y = yForYear({ + year: anchor.year, + minYear: source.minYear, + pxPerYear, + }); + window.scrollTo({ top: Math.max(0, bodyTop + y - anchor.viewportY) }); + }, [pxPerYear, source.minYear]); + + // Ctrl / Cmd + wheel zooms around the cursor, matching map conventions. + useEffect(() => { + const node = chartRef.current; + if (!node) return; + const onWheel = (event: WheelEvent) => { + if (!(event.ctrlKey || event.metaKey)) return; + event.preventDefault(); + zoomTo(zoomIndex + (event.deltaY < 0 ? 1 : -1), event.clientY); + }; + node.addEventListener("wheel", onWheel, { passive: false }); + return () => node.removeEventListener("wheel", onWheel); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zoomIndex, pxPerYear]); + + const viewportCentre = (): number => + typeof window !== "undefined" ? window.innerHeight / 2 : 0; + + return ( +
+
+ +
+ +
+ + + {ZOOM_LEVELS[zoomIndex]}× + + +
+
+ ); +} diff --git a/components/TimelineExplorer/index.ts b/components/TimelineExplorer/index.ts new file mode 100644 index 00000000..b2a18453 --- /dev/null +++ b/components/TimelineExplorer/index.ts @@ -0,0 +1 @@ +export { TimelineExplorer } from "@/components/TimelineExplorer/TimelineExplorer"; diff --git a/lib/timeline.test.ts b/lib/timeline.test.ts index 450587fa..6a3b7382 100644 --- a/lib/timeline.test.ts +++ b/lib/timeline.test.ts @@ -2,10 +2,16 @@ import { describe, it, expect } from "vitest"; import type { Battle, Event } from "@/lib/schemas"; import { CLUSTER_GAP_PX, + DEFAULT_ZOOM_INDEX, LANDMASSES, PX_PER_YEAR, + ZOOM_LEVELS, buildTimeline, landmassForBattle, + layoutTimeline, + prepareTimeline, + yForYear, + yearForY, } from "@/lib/timeline"; const makeBattle = ({ @@ -315,3 +321,98 @@ describe("buildTimeline", () => { } }); }); + +describe("timeline zoom", () => { + it("splits a cluster into individual nodes as pxPerYear grows", () => { + const source = prepareTimeline({ + battles: [ + makeBattle({ slug: "a", year: 300, region: "north" }), + makeBattle({ slug: "b", year: 306, region: "north" }), + ], + }); + // 6 years apart: 12px at the base scale (clustered), 96px at 8x (split). + expect( + layoutTimeline({ source, pxPerYear: PX_PER_YEAR }).columns.westeros.map( + (n) => n.kind, + ), + ).toEqual(["cluster"]); + expect( + layoutTimeline({ + source, + pxPerYear: PX_PER_YEAR * 8, + }).columns.westeros.map((n) => n.kind), + ).toEqual(["single", "single"]); + }); + + it("separates adjacent years at the top zoom level", () => { + const source = prepareTimeline({ + battles: [ + makeBattle({ slug: "a", year: 298, region: "north" }), + makeBattle({ slug: "b", year: 299, region: "north" }), + makeBattle({ slug: "c", year: 300, region: "north" }), + ], + }); + const topPx = PX_PER_YEAR * ZOOM_LEVELS[ZOOM_LEVELS.length - 1]; + expect( + layoutTimeline({ source, pxPerYear: topPx }).columns.westeros.map( + (n) => n.kind, + ), + ).toEqual(["single", "single", "single"]); + }); + + it("still groups same-year events at the top zoom level", () => { + const source = prepareTimeline({ + battles: [ + makeBattle({ slug: "a", year: 300, region: "north" }), + makeBattle({ slug: "b", year: 300, region: "north" }), + ], + }); + const topPx = PX_PER_YEAR * ZOOM_LEVELS[ZOOM_LEVELS.length - 1]; + const nodes = layoutTimeline({ source, pxPerYear: topPx }).columns.westeros; + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe("cluster"); + }); + + it("scales node positions with pxPerYear", () => { + const source = prepareTimeline({ + battles: [ + makeBattle({ slug: "a", year: 100, region: "north" }), + makeBattle({ slug: "b", year: 300, region: "north" }), + ], + }); + const base = layoutTimeline({ source, pxPerYear: PX_PER_YEAR }); + const doubled = layoutTimeline({ source, pxPerYear: PX_PER_YEAR * 2 }); + const gap = (m: typeof base): number => + m.columns.westeros[1].y - m.columns.westeros[0].y; + expect(gap(doubled)).toBe(gap(base) * 2); + }); + + it("composes into buildTimeline at the default scale", () => { + const input = { + battles: [ + makeBattle({ slug: "a", year: 299, region: "north" }), + makeBattle({ slug: "b", year: 299, region: "north" }), + ], + events: [makeEvent({ slug: "c", year: 300 })], + }; + expect(layoutTimeline({ source: prepareTimeline(input) })).toEqual( + buildTimeline(input), + ); + }); + + it("lays an event-free source out as an empty model", () => { + const model = layoutTimeline({ source: prepareTimeline({ battles: [] }) }); + expect(model.height).toBe(0); + expect(model.columns.westeros).toEqual([]); + }); + + it("round-trips a year through yForYear and yearForY", () => { + const frame = { minYear: -1000, pxPerYear: PX_PER_YEAR * 4 }; + const y = yForYear({ year: 250, ...frame }); + expect(yearForY({ y, ...frame })).toBeCloseTo(250); + }); + + it("keeps the default zoom level at 1x", () => { + expect(ZOOM_LEVELS[DEFAULT_ZOOM_INDEX]).toBe(1); + }); +}); diff --git a/lib/timeline.ts b/lib/timeline.ts index 65f72e4e..aa9598c2 100644 --- a/lib/timeline.ts +++ b/lib/timeline.ts @@ -19,6 +19,19 @@ export const PX_PER_YEAR = 2; export const CLUSTER_GAP_PX = 28; export const MAX_CLUSTER_SPAN_YEARS = 10; +/** + * Zoom multipliers applied to `PX_PER_YEAR`. A larger multiplier spreads + * events farther apart, so clusters that only formed because entries sat + * within `CLUSTER_GAP_PX` of one another break back into individual nodes. + * + * The top level puts each year more than `CLUSTER_GAP_PX` apart + * (`16 * PX_PER_YEAR = 32px` per year > `28px`), so at full zoom a cluster can + * only hold events sharing a single year: enough to read one year at a time. + */ +export const ZOOM_LEVELS = [0.5, 1, 2, 4, 8, 16] as const; +/** Index into `ZOOM_LEVELS` for the default 1x view (`PX_PER_YEAR`). */ +export const DEFAULT_ZOOM_INDEX = 1; + const TOP_PAD_PX = 48; /** Must fit a bottom cluster's expanded list — `.list` max-height 18rem in `TimelineCluster.module.scss`. */ const BOTTOM_PAD_PX = 400; @@ -70,6 +83,17 @@ export type TimelineModel = { columns: Record; }; +/** + * Zoom-independent, serializable placement: events already sorted into their + * landmass columns plus the overall year range. Computed once on the server, + * then laid out at any `pxPerYear` on the client. + */ +export type TimelineSource = { + minYear: number; + maxYear: number; + columns: Record; +}; + export function landmassForBattle({ battle }: { battle: Battle }): Landmass { if (!!battle.region) return "westeros"; return ESSOS_SLUGS.has(battle.slug) ? "essos" : "westeros"; @@ -131,15 +155,50 @@ const EMPTY_COLUMNS: Record = { "summer-isles": [], }; -export function buildTimeline({ +/** Pixel offset of a year within the chart body at a given vertical scale. */ +export function yForYear({ + year, + minYear, + pxPerYear, +}: { + year: number; + minYear: number; + pxPerYear: number; +}): number { + return (year - minYear) * pxPerYear + TOP_PAD_PX; +} + +/** Inverse of `yForYear`: the year at a pixel offset within the chart body. */ +export function yearForY({ + y, + minYear, + pxPerYear, +}: { + y: number; + minYear: number; + pxPerYear: number; +}): number { + return (y - TOP_PAD_PX) / pxPerYear + minYear; +} + +/** + * Sort battles and events into their landmass columns and compute the overall + * year range. The result is zoom-independent and serializable, so it can be + * built once on the server and laid out at any `pxPerYear` on the client. + */ +export function prepareTimeline({ battles, events = [], }: { battles: Battle[]; events?: Event[]; -}): TimelineModel { +}): TimelineSource { if (!battles.length && !events.length) { - return { height: 0, ticks: [], eras: [], columns: { ...EMPTY_COLUMNS } }; + return { + minYear: 0, + maxYear: 0, + columns: { westeros: [], essos: [], "summer-isles": [] }, + }; } const years = [ @@ -149,38 +208,6 @@ export function buildTimeline({ const minYear = Math.floor(Math.min(...years) / 1000) * 1000; const maxYear = Math.ceil(Math.max(...years) / 50) * 50; - const yFor = (year: number): number => - (year - minYear) * PX_PER_YEAR + TOP_PAD_PX; - const height = yFor(maxYear) + BOTTOM_PAD_PX; - - const bcMillennia = Array.from( - { length: Math.max(0, Math.floor(-minYear / 1000)) }, - (_, i) => minYear + i * 1000, - ); - const acHalfCenturies = Array.from( - { length: Math.max(0, Math.floor(maxYear / 50)) }, - (_, i) => (i + 1) * 50, - ); - const ticks: TimelineTick[] = [ - ...bcMillennia.map((year) => ({ y: yFor(year), label: `${-year} BC` })), - ...(minYear < 0 && maxYear >= 0 - ? [{ y: yFor(0), label: "Aegon's Conquest" }] - : []), - ...acHalfCenturies.map((year) => ({ y: yFor(year), label: `${year} AC` })), - ]; - - const eras: TimelineEra[] = ERA_BANDS.filter( - (band) => band.to > minYear && band.from < maxYear, - ).map((band) => { - const from = Math.max(band.from, minYear); - const to = Math.min(band.to, maxYear); - return { - label: band.label, - top: yFor(from), - height: (to - from) * PX_PER_YEAR, - }; - }); - const placed: Array<{ landmass: Landmass; event: TimelineEvent }> = [ ...battles.map((battle) => ({ landmass: landmassForBattle({ battle }), @@ -204,7 +231,7 @@ export function buildTimeline({ })), ]; - const byLandmass = placed.reduce>( + const columns = placed.reduce>( (acc, { landmass, event }) => ({ ...acc, [landmass]: [...acc[landmass], event], @@ -212,11 +239,83 @@ export function buildTimeline({ { westeros: [], essos: [], "summer-isles": [] }, ); + return { minYear, maxYear, columns }; +} + +/** + * Lay a prepared timeline out at a given vertical scale. Clustering keys off + * the pixel gap between entries, so a larger `pxPerYear` (zoom-in) pulls + * grouped events apart into individual nodes. + */ +export function layoutTimeline({ + source, + pxPerYear = PX_PER_YEAR, +}: { + source: TimelineSource; + pxPerYear?: number; +}): TimelineModel { + const { minYear, maxYear } = source; + const isEmpty = LANDMASSES.every( + (landmass) => source.columns[landmass].length === 0, + ); + if (isEmpty) { + return { height: 0, ticks: [], eras: [], columns: { ...EMPTY_COLUMNS } }; + } + + const yFor = (year: number): number => yForYear({ year, minYear, pxPerYear }); + const height = yFor(maxYear) + BOTTOM_PAD_PX; + + const bcMillennia = Array.from( + { length: Math.max(0, Math.floor(-minYear / 1000)) }, + (_, i) => minYear + i * 1000, + ); + const acHalfCenturies = Array.from( + { length: Math.max(0, Math.floor(maxYear / 50)) }, + (_, i) => (i + 1) * 50, + ); + const ticks: TimelineTick[] = [ + ...bcMillennia.map((year) => ({ y: yFor(year), label: `${-year} BC` })), + ...(minYear < 0 && maxYear >= 0 + ? [{ y: yFor(0), label: "Aegon's Conquest" }] + : []), + ...acHalfCenturies.map((year) => ({ y: yFor(year), label: `${year} AC` })), + ]; + + const eras: TimelineEra[] = ERA_BANDS.filter( + (band) => band.to > minYear && band.from < maxYear, + ).map((band) => { + const from = Math.max(band.from, minYear); + const to = Math.min(band.to, maxYear); + return { + label: band.label, + top: yFor(from), + height: (to - from) * pxPerYear, + }; + }); + const columns: Record = { - westeros: clusterColumn({ events: byLandmass.westeros, yFor }), - essos: clusterColumn({ events: byLandmass.essos, yFor }), - "summer-isles": clusterColumn({ events: byLandmass["summer-isles"], yFor }), + westeros: clusterColumn({ events: source.columns.westeros, yFor }), + essos: clusterColumn({ events: source.columns.essos, yFor }), + "summer-isles": clusterColumn({ + events: source.columns["summer-isles"], + yFor, + }), }; return { height, ticks, eras, columns }; } + +export function buildTimeline({ + battles, + events = [], + pxPerYear = PX_PER_YEAR, +}: { + battles: Battle[]; + events?: Event[]; + pxPerYear?: number; +}): TimelineModel { + return layoutTimeline({ + source: prepareTimeline({ battles, events }), + pxPerYear, + }); +} diff --git a/public/battles/the-muddy-mess.jpg b/public/battles/the-muddy-mess.jpg new file mode 100644 index 00000000..38c041e8 Binary files /dev/null and b/public/battles/the-muddy-mess.jpg differ