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