diff --git a/CLAUDE.md b/CLAUDE.md index 230ca644..d0f54105 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,8 +4,8 @@ Operating rules for this repo. The README covers stack, layout, and routes; this ## Workflow -- Always create a branch for each feature or bug fix. -- Auto-commit each logical change without asking. Subject must start with `TKW:` (see the `tkw-commit-format` skill). +- Never commit directly to `main`; always create a branch for each feature or bug fix. Do not create a new branch unless I say so. +- Do not commit anything without explicit instruction from me first. When I do, subject must start with `TKW:` (see the `tkw-commit-format` skill). ## Tooling diff --git a/app/maps/page.tsx b/app/maps/page.tsx index 32bb29a7..690b3eeb 100644 --- a/app/maps/page.tsx +++ b/app/maps/page.tsx @@ -1,8 +1,9 @@ +import { Suspense } from "react"; import type { Metadata } from "next"; import { ParchmentLayout } from "@/components/ParchmentLayout"; import { PageHeading } from "@/components/PageHeading"; import { sectionGlyphs } from "@/components/SectionGlyphs/SectionGlyphs"; -import { WorldMap } from "@/components/WorldMap"; +import { WorldMap, WorldMapSkeleton } from "@/components/WorldMap"; export const metadata: Metadata = { title: "Maps · Atlas of the Known World", @@ -17,11 +18,13 @@ export default function MapsPage() { icon={sectionGlyphs.maps} subtitle="The Known World, from the Sunset Sea to the Shadow Lands." /> - + }> + + ); } diff --git a/components/MainMenu/MainMenu.test.tsx b/components/MainMenu/MainMenu.test.tsx index f9fbf654..9d905ff8 100644 --- a/components/MainMenu/MainMenu.test.tsx +++ b/components/MainMenu/MainMenu.test.tsx @@ -35,13 +35,6 @@ describe("MainMenu", () => { expect(hrefs).not.toContain("/dragons/"); }); - it("shows a coming-soon pill only on the Maps tile", () => { - render(); - const pills = screen.getAllByText(/coming soon/i); - expect(pills).toHaveLength(1); - expect(pills[0].closest("a")?.getAttribute("href")).toBe("/maps/"); - }); - it('wraps tiles in a nav landmark labelled "Atlas sections"', () => { render(); expect( diff --git a/components/MainMenu/MainMenu.tsx b/components/MainMenu/MainMenu.tsx index 0882dfd8..5007a1be 100644 --- a/components/MainMenu/MainMenu.tsx +++ b/components/MainMenu/MainMenu.tsx @@ -10,7 +10,6 @@ export function MainMenu() { subtitle="Survey the realm." glyph={sectionGlyphs.maps} href="/maps/" - status="coming-soon" /> { expect(link.textContent).toContain("Survey the realm."); expect(screen.getByTestId("glyph")).toBeDefined(); }); - - it('shows a "Coming soon" pill only when status is coming-soon', () => { - const { rerender } = render( - , - ); - expect(screen.queryByText(/coming soon/i)).toBeNull(); - - rerender( - , - ); - expect(screen.getByText(/coming soon/i)).toBeDefined(); - }); }); diff --git a/components/MainMenuTile/MainMenuTile.tsx b/components/MainMenuTile/MainMenuTile.tsx index 7aeab1d7..9bdd470a 100644 --- a/components/MainMenuTile/MainMenuTile.tsx +++ b/components/MainMenuTile/MainMenuTile.tsx @@ -7,7 +7,6 @@ export type MainMenuTileProps = { subtitle: string; glyph: ReactNode; href: string; - status?: "coming-soon"; visible?: boolean; }; @@ -16,7 +15,6 @@ export function MainMenuTile({ subtitle, glyph, href, - status, visible = true, }: MainMenuTileProps) { if (!visible) return null; @@ -28,11 +26,6 @@ export function MainMenuTile({ {title} {subtitle} - {status === "coming-soon" && ( - - )} ); } diff --git a/components/WorldMap/WorldMap.module.scss b/components/WorldMap/WorldMap.module.scss index d740fefa..e5384a0b 100644 --- a/components/WorldMap/WorldMap.module.scss +++ b/components/WorldMap/WorldMap.module.scss @@ -38,6 +38,13 @@ font-style: italic; color: var(--ink-faded); } + + &:fullscreen { + width: 100vw; + height: 100vh; + border: none; + border-radius: 0; + } } .canvas { @@ -45,6 +52,67 @@ touch-action: none; } +.marker { + cursor: pointer; + + circle { + fill: rgba(212, 162, 89, 0); + stroke: rgba(212, 162, 89, 0); + stroke-width: 3; + transition: + fill 120ms ease, + stroke 120ms ease; + } + + &:hover circle, + &:focus-visible circle { + fill: rgba(212, 162, 89, 0.18); + stroke: var(--gold-leaf); + } + + &:focus-visible { + outline: none; + } + + @media (prefers-reduced-motion: reduce) { + circle { + transition: none; + } + } +} + +.debug { + position: absolute; + inset-block-start: 0.75rem; + inset-inline-start: 0.75rem; + display: grid; + grid-template-columns: auto auto; + gap: 0.15rem 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid rgba(107, 68, 35, 0.35); + border-radius: 2px; + background: rgba(248, 236, 208, 0.85); + color: var(--ink-faded); + font-size: 0.85rem; + font-variant-numeric: tabular-nums; + pointer-events: none; + + dt { + font-weight: 600; + } + + dd { + margin: 0; + text-align: right; + } +} + +.fullscreenControl { + position: absolute; + inset-block-start: 0.75rem; + inset-inline-end: 0.75rem; +} + .dpad { position: absolute; inset-block-end: 0.75rem; diff --git a/components/WorldMap/WorldMap.test.tsx b/components/WorldMap/WorldMap.test.tsx index 9651d140..12d15ade 100644 --- a/components/WorldMap/WorldMap.test.tsx +++ b/components/WorldMap/WorldMap.test.tsx @@ -1,29 +1,44 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { act, fireEvent, render } from "@testing-library/react"; +import { act, fireEvent } from "@testing-library/react"; import type { ReactNode } from "react"; +import { renderWithNuqs } from "@/lib/testNuqs"; const spies = vi.hoisted(() => ({ pan: vi.fn(), zoomOnViewerCenter: vi.fn(), fitToViewer: vi.fn(), + setValue: vi.fn(), })); -// The runtime `UncontrolledReactSVGPanZoom` exposes no `getValue()` of its -// own (the published typedef wrongly declares one) — the live value hangs -// off its public `Viewer` field, the inner viewer instance. +type MockProps = { children: ReactNode; width: number; height: number }; +type MockValue = { a: number; e: number; f: number }; + +// Live value read by the debug-overlay poll; tests mutate this to simulate +// the user zooming/panning. `WorldMap` overwrites it on mount via +// `Viewer.setValue()` to seed its initial view. +let mockViewerValue: MockValue = { a: 0.5, e: 0, f: 0 }; + +// The runtime `UncontrolledReactSVGPanZoom` exposes no `getValue()`/`setValue()` +// of its own (the published typedef wrongly declares them) — the live value +// hangs off its public `Viewer` field, the inner viewer instance. It also +// swallows a consumer `onChangeValue` prop rather than forwarding it, so +// `WorldMap` polls `getValue()` instead of relying on that callback. vi.mock("react-svg-pan-zoom", async () => { const { Component } = await import("react"); - type MockProps = { children: ReactNode; width: number; height: number }; class UncontrolledReactSVGPanZoom extends Component { pan = spies.pan; zoomOnViewerCenter = spies.zoomOnViewerCenter; fitToViewer = spies.fitToViewer; Viewer = { getValue: () => ({ - a: 0.5, + ...mockViewerValue, viewerWidth: this.props.width, viewerHeight: this.props.height, }), + setValue: (value: MockValue) => { + spies.setValue(value); + mockViewerValue = { a: value.a, e: value.e, f: value.f }; + }, }; render() { return ( @@ -61,9 +76,57 @@ class FakeResizeObserver { unobserve() {} } +type RafCallback = (time: number) => void; +let rafCallbacks: RafCallback[] = []; + +// Captures scheduled frames instead of running them, so tests advance the +// debug-overlay poll deterministically via `flushRaf()`. +function stubRaf() { + vi.stubGlobal("requestAnimationFrame", (cb: RafCallback) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); +} + +function flushRaf() { + const callbacks = rafCallbacks; + rafCallbacks = []; + act(() => { + callbacks.forEach((cb) => cb(0)); + }); +} + +// jsdom implements none of the Fullscreen API; `requestFullscreen()` / +// `exitFullscreen()` are faked to track the current element and dispatch +// `fullscreenchange`, matching real browser behavior closely enough to +// drive `WorldMap`'s toggle state. +let fullscreenElement: Element | null = null; +Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => fullscreenElement, +}); +function markFullscreenElement(el: Element | null) { + fullscreenElement = el; +} +HTMLElement.prototype.requestFullscreen = vi.fn(function (this: HTMLElement) { + markFullscreenElement(this); + document.dispatchEvent(new Event("fullscreenchange")); + return Promise.resolve(); +}); +document.exitFullscreen = vi.fn(() => { + fullscreenElement = null; + document.dispatchEvent(new Event("fullscreenchange")); + return Promise.resolve(); +}); + beforeEach(() => { observers = []; + rafCallbacks = []; + mockViewerValue = { a: 0.5, e: 0, f: 0 }; + fullscreenElement = null; vi.stubGlobal("ResizeObserver", FakeResizeObserver); + stubRaf(); }); afterEach(() => { @@ -77,17 +140,41 @@ function stubSize(el: HTMLElement, w: number, h: number) { } // Viewer 800x600, map 7680x7680 → the map draws at 600x600, centered at -// x=100. Mocked live scale `a: 0.5` → pan step = 20% of the viewer / 0.5. -const PAN_STEP_X = (800 * 0.2) / 0.5; -const PAN_STEP_Y = (600 * 0.2) / 0.5; +// x=100. `WorldMap` seeds the view to zoom 5 on mount, so pan step = 20% +// of the viewer / 5. +const SEEDED_VIEW = { zoom: 5, x: -1495, y: -1940 }; +const PAN_STEP_X = (800 * 0.2) / SEEDED_VIEW.zoom; +const PAN_STEP_Y = (600 * 0.2) / SEEDED_VIEW.zoom; +const NATURAL_SIZE = 7680; +const KINGS_LANDING = { x: 1955, y: 4619, radius: 25 }; + +function fitScaleFor(size: { w: number; h: number }) { + return Math.min(size.w / NATURAL_SIZE, size.h / NATURAL_SIZE); +} -function renderMap() { - const utils = render( +// Inverts a pan/zoom transform back to the natural-map point currently +// centered on screen, given the viewer size and fit scale it applies to. +function centeredNaturalPoint( + value: { a: number; e: number; f: number }, + size: { w: number; h: number }, + fitScale: number, +) { + const drawnSize = NATURAL_SIZE * fitScale; + const offsetX = (size.w - drawnSize) / 2; + const offsetY = (size.h - drawnSize) / 2; + const svgX = (size.w / 2 - value.e) / value.a; + const svgY = (size.h / 2 - value.f) / value.a; + return { x: (svgX - offsetX) / fitScale, y: (svgY - offsetY) / fitScale }; +} + +function renderMap(searchParams?: string) { + const utils = renderWithNuqs( , + { searchParams }, ); const stage = utils.getByRole("application"); stubSize(stage, 800, 600); @@ -199,7 +286,7 @@ describe("WorldMap", () => { }); it("disconnects the observer on unmount", () => { - const { unmount } = render( + const { unmount } = renderWithNuqs( { unmount(); expect(observers).toHaveLength(0); }); + + it("hides the debug overlay by default", async () => { + const { findByTestId, queryByText } = renderMap(); + await findByTestId("pan-zoom"); + expect(queryByText("Zoom")).toBeNull(); + }); + + it("shows zoom/x/y debug info when `editMode=true`", async () => { + const { findByTestId, getByText } = renderMap("editMode=true"); + await findByTestId("pan-zoom"); + expect(getByText("Zoom")).not.toBeNull(); + expect(getByText("5.00×")).not.toBeNull(); + expect(getByText("X")).not.toBeNull(); + expect(getByText("-1495")).not.toBeNull(); + expect(getByText("Y")).not.toBeNull(); + expect(getByText("-1940")).not.toBeNull(); + + mockViewerValue = { a: 2.5, e: -120, f: 40 }; + flushRaf(); + expect(getByText("2.50×")).not.toBeNull(); + expect(getByText("-120")).not.toBeNull(); + expect(getByText("40")).not.toBeNull(); + }); + + it("does not poll for debug values when `editMode` is off", async () => { + const { findByTestId } = renderMap(); + await findByTestId("pan-zoom"); + expect(rafCallbacks).toHaveLength(0); + }); + + it("seeds the initial pan/zoom view on mount", async () => { + const { findByTestId } = renderMap(); + await findByTestId("pan-zoom"); + expect(spies.setValue).toHaveBeenCalledTimes(1); + expect(spies.setValue).toHaveBeenCalledWith( + expect.objectContaining({ + a: SEEDED_VIEW.zoom, + d: SEEDED_VIEW.zoom, + e: SEEDED_VIEW.x, + f: SEEDED_VIEW.y, + }), + ); + }); + + it("keeps the same map point centered, at the same effective zoom, across a resize", async () => { + const { findByTestId, stage } = renderMap(); + await findByTestId("pan-zoom"); + expect(spies.setValue).toHaveBeenCalledTimes(1); + + const size1 = { w: 800, h: 600 }; + const fitScale1 = fitScaleFor(size1); + const seeded = spies.setValue.mock.calls[0][0]; + const naturalBefore = centeredNaturalPoint(seeded, size1, fitScale1); + const effectiveZoomBefore = seeded.a * fitScale1; + + const size2 = { w: 900, h: 700 }; + stubSize(stage, size2.w, size2.h); + act(() => { + observers[0].cb(); + }); + + expect(spies.setValue).toHaveBeenCalledTimes(2); + const resized = spies.setValue.mock.calls[1][0]; + const fitScale2 = fitScaleFor(size2); + const naturalAfter = centeredNaturalPoint(resized, size2, fitScale2); + const effectiveZoomAfter = resized.a * fitScale2; + + expect(naturalAfter.x).toBeCloseTo(naturalBefore.x, 6); + expect(naturalAfter.y).toBeCloseTo(naturalBefore.y, 6); + expect(effectiveZoomAfter).toBeCloseTo(effectiveZoomBefore, 6); + }); + + it("does not adjust the transform when a resize reports the same size", async () => { + const { findByTestId, stage } = renderMap(); + await findByTestId("pan-zoom"); + expect(spies.setValue).toHaveBeenCalledTimes(1); + + stubSize(stage, 800, 600); + act(() => { + observers[0].cb(); + }); + expect(spies.setValue).toHaveBeenCalledTimes(1); + }); + + it("marks King's Landing with a link to its page, positioned by natural coordinates", async () => { + const { findByTestId, getByRole } = renderMap(); + await findByTestId("pan-zoom"); + + const link = getByRole("link", { name: "King's Landing" }); + expect(link.getAttribute("href")).toBe("/castles/kings-landing/"); + + const circle = link.querySelector("circle"); + const size = { w: 800, h: 600 }; + const fitScale = fitScaleFor(size); + const offsetX = (size.w - NATURAL_SIZE * fitScale) / 2; + const offsetY = (size.h - NATURAL_SIZE * fitScale) / 2; + expect(Number(circle?.getAttribute("cx"))).toBeCloseTo( + offsetX + KINGS_LANDING.x * fitScale, + 6, + ); + expect(Number(circle?.getAttribute("cy"))).toBeCloseTo( + offsetY + KINGS_LANDING.y * fitScale, + 6, + ); + expect(Number(circle?.getAttribute("r"))).toBeCloseTo( + KINGS_LANDING.radius * fitScale, + 6, + ); + }); + + it("requests fullscreen on the stage and flips to Exit fullscreen", async () => { + const { findByTestId, getByRole, stage } = renderMap(); + await findByTestId("pan-zoom"); + + fireEvent.click(getByRole("button", { name: "Enter fullscreen" })); + expect(stage.requestFullscreen).toHaveBeenCalledTimes(1); + expect(getByRole("button", { name: "Exit fullscreen" })).not.toBeNull(); + }); + + it("exits fullscreen from the same button once entered", async () => { + const { findByTestId, getByRole } = renderMap(); + await findByTestId("pan-zoom"); + + fireEvent.click(getByRole("button", { name: "Enter fullscreen" })); + fireEvent.click(getByRole("button", { name: "Exit fullscreen" })); + expect(document.exitFullscreen).toHaveBeenCalledTimes(1); + expect(getByRole("button", { name: "Enter fullscreen" })).not.toBeNull(); + }); }); diff --git a/components/WorldMap/WorldMap.tsx b/components/WorldMap/WorldMap.tsx index 62b90551..b0acf987 100644 --- a/components/WorldMap/WorldMap.tsx +++ b/components/WorldMap/WorldMap.tsx @@ -2,12 +2,24 @@ import { useEffect, useRef, useState } from "react"; import type { KeyboardEvent } from "react"; -import { TOOL_AUTO, UncontrolledReactSVGPanZoom } from "react-svg-pan-zoom"; +import Link from "next/link"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import { + TOOL_AUTO, + UncontrolledReactSVGPanZoom, + type Value, +} from "react-svg-pan-zoom"; import { cx } from "@/lib/cx"; import styles from "@/components/WorldMap/WorldMap.module.scss"; const ZOOM_STEP = 1.5; const PAN_STEP_RATIO = 0.2; +const INITIAL_VIEW = { zoom: 5, x: -1495, y: -1940 }; +// Natural-pixel position of the King's Landing capital icon printed on the +// map, and the radius of the (invisible) click target around it — kept +// tight since neighboring towns (Hayford, Rosby) sit only ~65-100 natural +// pixels away. +const KINGS_LANDING = { x: 1955, y: 4619, radius: 25 }; type PanDirection = "up" | "down" | "left" | "right"; @@ -28,7 +40,18 @@ type Props = { export function WorldMap({ src, naturalWidth, naturalHeight }: Props) { const stageRef = useRef(null); const viewerRef = useRef(null); + const hasSeededViewRef = useRef(false); + const geometryRef = useRef<{ + size: { w: number; h: number }; + fitScale: number; + } | null>(null); const [size, setSize] = useState<{ w: number; h: number } | null>(null); + const [editMode] = useQueryState( + "editMode", + parseAsBoolean.withDefault(false), + ); + const [debugValue, setDebugValue] = useState(INITIAL_VIEW); + const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { if (!stageRef.current) return; @@ -41,22 +64,130 @@ export function WorldMap({ src, naturalWidth, naturalHeight }: Props) { update(); const ro = new ResizeObserver(update); ro.observe(el); - return () => ro.disconnect(); + + // `ResizeObserver` doesn't reliably re-fire across a fullscreen + // transition, so re-measure directly once it completes — `fullscreenchange` + // fires after the element has settled at its final size. + const handleFullscreenChange = () => { + setIsFullscreen(document.fullscreenElement === el); + update(); + }; + document.addEventListener("fullscreenchange", handleFullscreenChange); + + return () => { + ro.disconnect(); + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }; }, []); // The SVG viewbox matches the viewer and the map image is drawn centered // at fitted size within it, so the library's default view (identity - // matrix) already shows the whole map: scale 1 = fit, no seeding needed. + // matrix) shows the whole map: scale 1 = fit. const fitScale = !!size ? Math.min(size.w / naturalWidth, size.h / naturalHeight) : 0; const drawnWidth = naturalWidth * fitScale; const drawnHeight = naturalHeight * fitScale; + // Opens on a specific region instead of the fit-to-viewer default on first + // paint. On every later resize (e.g. entering/exiting fullscreen), the + // underlying re-centers itself for the new viewer size, but the + // pan/zoom transform doesn't — so without compensating it here, the + // visible region would jump. Instead we recompute the transform so the + // same map point stays centered at the same effective zoom level. + // `Viewer.setValue()` is the inner viewer's real setter; the outer + // `UncontrolledReactSVGPanZoom` internally wires its own `onChangeValue` + // to it, so this still lands in the wrapper's state. + useEffect(() => { + if (!size) return; + const inner = viewerRef.current?.Viewer; + if (!inner) return; + + if (!hasSeededViewRef.current) { + hasSeededViewRef.current = true; + inner.setValue({ + ...inner.getValue(), + viewerWidth: size.w, + viewerHeight: size.h, + a: INITIAL_VIEW.zoom, + d: INITIAL_VIEW.zoom, + e: INITIAL_VIEW.x, + f: INITIAL_VIEW.y, + }); + geometryRef.current = { size, fitScale }; + return; + } + + const prev = geometryRef.current; + if (!prev || (prev.size.w === size.w && prev.size.h === size.h)) { + geometryRef.current = { size, fitScale }; + return; + } + + // `inner.getValue()` can still report the pre-resize `viewerWidth`/ + // `viewerHeight` here — the library syncs those itself off the same + // width/height prop change, via a separate cascading update that isn't + // guaranteed to have landed before this effect runs. `size` is already + // authoritative, so it's used directly below instead of trusting the + // spread for those two fields. + const current = inner.getValue(); + const prevOffsetX = (prev.size.w - naturalWidth * prev.fitScale) / 2; + const prevOffsetY = (prev.size.h - naturalHeight * prev.fitScale) / 2; + const centeredSVGX = (prev.size.w / 2 - current.e) / current.a; + const centeredSVGY = (prev.size.h / 2 - current.f) / current.a; + const centeredNaturalX = (centeredSVGX - prevOffsetX) / prev.fitScale; + const centeredNaturalY = (centeredSVGY - prevOffsetY) / prev.fitScale; + + const nextZoom = current.a * (prev.fitScale / fitScale); + const nextSVGX = (size.w - drawnWidth) / 2 + centeredNaturalX * fitScale; + const nextSVGY = (size.h - drawnHeight) / 2 + centeredNaturalY * fitScale; + + const value: Value = { + ...current, + viewerWidth: size.w, + viewerHeight: size.h, + a: nextZoom, + d: nextZoom, + e: size.w / 2 - nextZoom * nextSVGX, + f: size.h / 2 - nextZoom * nextSVGY, + }; + inner.setValue(value); + geometryRef.current = { size, fitScale }; + }, [size, naturalWidth, naturalHeight, fitScale, drawnWidth, drawnHeight]); + + // `UncontrolledReactSVGPanZoom` swallows a consumer `onChangeValue` prop + // (it destructures it away in favor of its own internal handler), so the + // live value can only be read by polling `Viewer.getValue()`. + useEffect(() => { + if (!editMode) return; + let frame: number; + const tick = () => { + const value = viewerRef.current?.Viewer?.getValue(); + if (value) { + setDebugValue((prev) => + prev.zoom === value.a && prev.x === value.e && prev.y === value.f + ? prev + : { zoom: value.a, x: value.e, y: value.f }, + ); + } + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [editMode]); + const zoomIn = () => viewerRef.current?.zoomOnViewerCenter(ZOOM_STEP); const zoomOut = () => viewerRef.current?.zoomOnViewerCenter(1 / ZOOM_STEP); const fitView = () => viewerRef.current?.fitToViewer(); + const toggleFullscreen = () => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + stageRef.current?.requestFullscreen(); + } + }; + const panView = (direction: PanDirection) => { const viewer = viewerRef.current; const inner = viewer?.Viewer ?? null; @@ -126,9 +257,46 @@ export function WorldMap({ src, naturalWidth, naturalHeight }: Props) { width={drawnWidth} height={drawnHeight} /> + + + )} + {editMode && ( + + )} +
+ +
@@ -271,7 +439,56 @@ function MinusIcon() { ); } -function FitIcon() { +function ResetIcon() { + return ( + + + + + ); +} + +function ExpandIcon() { + return ( + + + + ); +} + +function CompressIcon() { return ( +
+

+ Drag to pan · Scroll or pinch to zoom · Keys: + and − zoom, arrows pan, + 0 resets +

+
+ ); +} diff --git a/components/WorldMap/index.ts b/components/WorldMap/index.ts index ae1f2612..dcc524d3 100644 --- a/components/WorldMap/index.ts +++ b/components/WorldMap/index.ts @@ -1 +1,2 @@ export { WorldMap } from "@/components/WorldMap/WorldMap"; +export { WorldMapSkeleton } from "@/components/WorldMap/WorldMapSkeleton"; diff --git a/content/castles/kings-landing.md b/content/castles/kings-landing.md new file mode 100644 index 00000000..9973785f --- /dev/null +++ b/content/castles/kings-landing.md @@ -0,0 +1,36 @@ +--- +slug: kings-landing +name: King's Landing +type: town +sub-region: blackwater-bay +founded: + year: 1 + era: AC + precision: exact +sworn-houses: [] +features: + - red-keep + - great-sept-of-baelor + - dragonpit + - aegons-high-hill + - street-of-steel + - flea-bottom +coords: + x: 590 + y: 830 +sources: + - type: awoiaf + url: https://awoiaf.westeros.org/index.php/King%27s_Landing + license: CC-BY-SA-3.0 +draft: false +--- + +Raised where Aegon the Conqueror came ashore at the mouth of the Blackwater Rush, King's Landing grew from a wooden fort on Aegon's High Hill into the largest city in the Seven Kingdoms and the seat of the Iron Throne. + +## The Red Keep + +Crowning Aegon's High Hill in pale red stone, the Red Keep has housed the ruling dynasty since Maegor the Cruel raised its walls and put the builders to the sword to keep its secrets. + +## Flea Bottom + +Sprawling across the low ground between the city's hills, Flea Bottom is King's Landing's poorest and most crowded district — famed, its people cheerfully admit, for a stew of a thousand flavors and a stink that carries all the way to Duskendale. diff --git a/public/map/the-know-world.jpg b/public/map/the-know-world.jpg new file mode 100644 index 00000000..687d03c9 Binary files /dev/null and b/public/map/the-know-world.jpg differ diff --git a/public/map/the-known-world-enhanced-no-text.jpg b/public/map/the-known-world-enhanced-no-text.jpg new file mode 100644 index 00000000..71591d44 Binary files /dev/null and b/public/map/the-known-world-enhanced-no-text.jpg differ diff --git a/public/map/the-known-world-enhanced.jpg b/public/map/the-known-world-enhanced.jpg new file mode 100644 index 00000000..59574cc4 Binary files /dev/null and b/public/map/the-known-world-enhanced.jpg differ