From b9663dbb9d29311b0c9bb1813b8440e52fdcf973 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:52:41 +0000 Subject: [PATCH 01/56] Rebuild the world map as a Google Maps-style viewer in CK3 frames The live main map moves to /map (/map/main redirects there; chronicle and staff pages keep their /map/main/... paths). The map now fills the screen with framed controls floating over it: - Left-drag, touch drag and pinch pan and zoom the map; keyboard arrows and +/- work too, with zoom buttons in the corner. A drag never also selects. Paint mode keeps the left button while it is on. - Clicking a realm opens a details panel (a bottom sheet on phones) in place of the modal and hover card, and outlines it on the map. The panel reads an allowlist of realm facts, so ledgers, balances, member lists and queues never reach the page. - A search box finds realms, titles, settlements and installations, and flies to the one picked. - Map modes are a CK3-style medallion tray on desktop and labelled chips on phones. A Layers menu holds the marker toggles, war planning and the archive. A breadcrumb shows which subject layers are open. Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/app/components/MapViewer.tsx | 640 ++++++++----- .../chronicle/ChronicleDayViewer.tsx | 13 +- .../app/components/map/DraggablePanel.tsx | 119 --- frontend/app/components/map/MapCanvas.tsx | 98 +- frontend/app/components/map/MapPageLayout.tsx | 151 ---- frontend/app/components/map/MapSidePanel.tsx | 94 -- .../app/components/map/MapToolbar.test.ts | 26 - frontend/app/components/map/MapToolbar.tsx | 141 --- frontend/app/components/map/MapViewport.tsx | 10 +- .../components/map/NationDetailContent.tsx | 91 -- .../app/components/map/NationDetailModal.tsx | 86 -- .../app/components/map/NationDetailPanel.tsx | 42 - frontend/app/components/map/PaintToolbar.tsx | 8 +- .../map/editor/EditorDisabledGate.tsx | 2 +- .../components/map/editor/EditorEntryGate.tsx | 2 +- frontend/app/components/map/mapModes.test.ts | 39 + frontend/app/components/map/mapModes.ts | 40 + .../map/shell/MapDrillBreadcrumb.tsx | 76 ++ .../app/components/map/shell/MapIcons.tsx | 201 +++++ .../components/map/shell/MapLayersMenu.tsx | 118 +++ .../app/components/map/shell/MapModeBar.tsx | 89 ++ .../app/components/map/shell/MapPlaque.tsx | 35 + .../app/components/map/shell/MapSearch.tsx | 174 ++++ .../app/components/map/shell/MapShell.tsx | 144 +++ .../components/map/shell/MapZoomControls.tsx | 48 + .../app/components/map/shell/RealmPanel.tsx | 407 +++++++++ frontend/app/components/shell/SiteHeader.tsx | 2 +- frontend/app/hooks/useMapViewport.ts | 840 ++++++++---------- frontend/app/hooks/useRegionHover.ts | 4 +- frontend/app/internal/globals.css | 228 +++++ .../app/lib/map/chronicleDayRoute.test.ts | 2 +- frontend/app/lib/map/chronicleDayRoute.ts | 16 +- frontend/app/lib/map/chronicleStaff.ts | 4 +- frontend/app/lib/map/mapSearch.test.ts | 107 +++ frontend/app/lib/map/mapSearch.ts | 164 ++++ frontend/app/lib/map/realmProfile.test.ts | 172 ++++ frontend/app/lib/map/realmProfile.ts | 189 ++++ frontend/app/lib/mapGestures.test.ts | 92 ++ frontend/app/lib/mapGestures.ts | 91 ++ frontend/app/lib/mapViewportMath.test.ts | 90 +- frontend/app/lib/mapViewportMath.ts | 107 ++- frontend/app/map/page.tsx | 11 + frontend/app/page.tsx | 2 +- frontend/next.config.ts | 5 + 44 files changed, 3494 insertions(+), 1526 deletions(-) delete mode 100644 frontend/app/components/map/DraggablePanel.tsx delete mode 100644 frontend/app/components/map/MapPageLayout.tsx delete mode 100644 frontend/app/components/map/MapSidePanel.tsx delete mode 100644 frontend/app/components/map/MapToolbar.test.ts delete mode 100644 frontend/app/components/map/MapToolbar.tsx delete mode 100644 frontend/app/components/map/NationDetailContent.tsx delete mode 100644 frontend/app/components/map/NationDetailModal.tsx delete mode 100644 frontend/app/components/map/NationDetailPanel.tsx create mode 100644 frontend/app/components/map/mapModes.test.ts create mode 100644 frontend/app/components/map/mapModes.ts create mode 100644 frontend/app/components/map/shell/MapDrillBreadcrumb.tsx create mode 100644 frontend/app/components/map/shell/MapIcons.tsx create mode 100644 frontend/app/components/map/shell/MapLayersMenu.tsx create mode 100644 frontend/app/components/map/shell/MapModeBar.tsx create mode 100644 frontend/app/components/map/shell/MapPlaque.tsx create mode 100644 frontend/app/components/map/shell/MapSearch.tsx create mode 100644 frontend/app/components/map/shell/MapShell.tsx create mode 100644 frontend/app/components/map/shell/MapZoomControls.tsx create mode 100644 frontend/app/components/map/shell/RealmPanel.tsx create mode 100644 frontend/app/lib/map/mapSearch.test.ts create mode 100644 frontend/app/lib/map/mapSearch.ts create mode 100644 frontend/app/lib/map/realmProfile.test.ts create mode 100644 frontend/app/lib/map/realmProfile.ts create mode 100644 frontend/app/lib/mapGestures.test.ts create mode 100644 frontend/app/lib/mapGestures.ts create mode 100644 frontend/app/map/page.tsx diff --git a/frontend/app/components/MapViewer.tsx b/frontend/app/components/MapViewer.tsx index ef152052..d40d2d90 100644 --- a/frontend/app/components/MapViewer.tsx +++ b/frontend/app/components/MapViewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef, useMemo } from "react"; +import { useCallback, useState, useEffect, useRef, useMemo } from "react"; import Link from "next/link"; import { useMapEngine } from "../core/MapEngineContext"; import { useMapHover } from "../hooks/useMapHover"; @@ -37,16 +37,18 @@ import { import MapAccessGate, { type MapAccessGateReason, } from "./map/MapAccessGate"; -import MapCanvas from "./map/MapCanvas"; -import MapPageLayout from "./map/MapPageLayout"; -import MapToolbar from "./map/MapToolbar"; +import MapCanvas, { type MapViewportControls } from "./map/MapCanvas"; import PaintToolbar from "./map/PaintToolbar"; -import { - MapDesktopSidePanel, - MapDrillStackBar, -} from "./map/MapSidePanel"; -import NationDetailModal from "./map/NationDetailModal"; -import { buildRegionInfo } from "./map/regionInfo"; +import { mapModeLabel } from "./map/mapModes"; +import MapShell from "./map/shell/MapShell"; +import MapPlaque from "./map/shell/MapPlaque"; +import MapSearch from "./map/shell/MapSearch"; +import { MapModeBar, MapModeChips } from "./map/shell/MapModeBar"; +import MapZoomControls from "./map/shell/MapZoomControls"; +import MapLayersMenu, { type MapLayerToggle } from "./map/shell/MapLayersMenu"; +import MapDrillBreadcrumb from "./map/shell/MapDrillBreadcrumb"; +import { RealmPanelContent } from "./map/shell/RealmPanel"; +import { HistoryIcon } from "./map/shell/MapIcons"; import type { CursorTooltip, HoverOverlay, @@ -55,6 +57,10 @@ import type { RegionInfo, } from "./map/types"; import { mapFallbackSize, mapDisplayName } from "./map/types"; +import { buildMapSearchIndex, type MapSearchEntry } from "@/app/lib/map/mapSearch"; +import type { MapFocusInset } from "../hooks/useMapViewport"; +import type { MapRect } from "../lib/mapViewportMath"; +import { isTypingTarget } from "../lib/mapGestures"; import { useAccessibleMaps } from "../hooks/useAccessibleMaps"; import { useCharacterSessionToken } from "../hooks/useCharacterSessionToken"; import { useCanEditMap } from "../hooks/useCanEditMap"; @@ -83,42 +89,55 @@ import { type ProvinceIdGrid, } from "@/app/lib/map/chroniclePaint"; -const editTitlesLinkClass = - "rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_25%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest)_40%,transparent)] px-3 py-2 text-sm text-[var(--tfmc-cream)] no-underline transition hover:brightness-110 hover:border-[var(--tfmc-accent)]"; +const actionLinkClass = "map-control h-9 px-2.5 text-xs no-underline"; /** - * The chronicle is the one feature nothing else on the map hints at, so this - * sits beside the map's title in accent colours rather than reading as a peer - * of "Edit titles". Without it a viewer has no way to discover the map has any - * history at all. + * The chronicle is the one feature nothing else on the map hints at, so it + * keeps its own button on the name plate rather than hiding in the Layers + * menu. Without it a viewer has no way to discover the map has any history. */ -const reviewHistoryLinkClass = - "inline-flex shrink-0 items-center gap-1.5 rounded-md border border-[color-mix(in_srgb,var(--tfmc-accent)_60%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-accent)_16%,transparent)] px-2.5 py-1.5 text-xs font-medium text-[var(--tfmc-cream)] no-underline transition hover:border-[var(--tfmc-accent)] hover:bg-[color-mix(in_srgb,var(--tfmc-accent)_28%,transparent)]"; - function ReviewHistoryLink({ mapId }: { mapId: MapId }) { return ( - - - - - Review History + + + History ); } -const fitModeLabelClass = (active: boolean) => - `text-xs transition ${active ? "text-[var(--tfmc-cream)]" : "text-[var(--tfmc-stone)]"}`; +/** + * Desktop opens on the whole world. A phone held upright would show that as a + * small square with the screen empty below it, so there the map fills the + * screen top to bottom instead and the sides are a drag away. + */ +function useResponsiveFitMode(): FitMode { + const [fitMode, setFitMode] = useState("contain"); + useEffect(() => { + const query = window.matchMedia("(max-width: 47.99rem)"); + const apply = () => setFitMode(query.matches ? "cover" : "contain"); + apply(); + query.addEventListener("change", apply); + return () => query.removeEventListener("change", apply); + }, []); + return fitMode; +} + +/** Room the desktop details panel and mode tray take from a "zoom to". */ +const DESKTOP_FOCUS_INSET: MapFocusInset = { left: 400, bottom: 110, top: 16, right: 80 }; + +function focusInset(): MapFocusInset { + if (typeof window === "undefined") return {}; + if (window.matchMedia("(min-width: 48rem)").matches) return DESKTOP_FOCUS_INSET; + // Phone: search and chips above, the details sheet below. + return { top: 150, bottom: Math.round(window.innerHeight * 0.45) }; +} + +/** Hover feeds a details card that no longer exists; selection replaced it. */ +const ignoreHoverRegionInfo = (_info: RegionInfo | null) => {}; type MapViewerProps = { mapId: MapId; @@ -161,7 +180,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { const [accessChecked, setAccessChecked] = useState(mapId === "main"); const [mapType, setMapType] = useState("nation"); - const [fitMode, setFitMode] = useState("contain"); + const fitMode = useResponsiveFitMode(); const [installationsVisible, setInstallationsVisible] = useState(true); const [supplyLinksVisible, setSupplyLinksVisible] = useState(true); const [hoveredOverlay, setHoveredOverlay] = useState( @@ -170,12 +189,14 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { const [hoveredFortZoc, setHoveredFortZoc] = useState( null ); - const [selectedRegionId, setSelectedRegionId] = useState(null); - const [regionInfo, setRegionInfo] = useState(null); - const [modalRegionInfo, setModalRegionInfo] = useState( - null - ); - const [modalOpen, setModalOpen] = useState(false); + /** The region under the pointer. Cleared as soon as the pointer leaves it. */ + const [hoveredRegionId, setHoveredRegionId] = useState(null); + /** + * The region whose details are open. Separate from hover on purpose: it + * survives the pointer moving on, and only an explicit choice (another + * click, a link in the panel, search, Escape, a mode change) replaces it. + */ + const [selectedId, setSelectedId] = useState(null); const [drillStack, setDrillStack] = useState([]); const [pendingDrillId, setPendingDrillId] = useState(null); const [cursorTooltip, setCursorTooltip] = useState( @@ -184,6 +205,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { const [hoveredMarkerId, setHoveredMarkerId] = useState(null); const canvasRef = useRef(null); + const viewportControlsRef = useRef(null); const viewportCoordsRef = useRef(null); const lastProvinceIdRef = useRef(null); @@ -475,9 +497,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { setDrillStack([]); setHoveredOverlay(null); setHoveredFortZoc(null); - setRegionInfo(null); - setModalOpen(false); - setModalRegionInfo(null); + setSelectedId(null); // `/mapdata/` is regenerated from today's data and has no per-day variant, // so a stored day must not download it — the effect below paints the pick // canvas from that day's own ownership instead. @@ -585,8 +605,8 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { setCursorTooltip, setHoveredOverlay, setHoveredFortZoc, - setRegionInfo, - setSelectedRegionId, + setRegionInfo: ignoreHoverRegionInfo, + setSelectedRegionId: setHoveredRegionId, getHoverRegion, mapDisplayName: displayName, mapObjects, @@ -612,7 +632,6 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { setCursorTooltip(null); setHoveredOverlay(null); setHoveredFortZoc(null); - setRegionInfo(null); lastProvinceIdRef.current = null; // onHoverLeave is stable enough for this one-shot cleanup. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -623,6 +642,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { // mode-data effect re-running to repaint them. if (mode === mapType) return; resetMapObjects(); + setSelectedId(null); setMapType(mode); } @@ -690,26 +710,20 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { if (!regionData) return; const regionId = pickRegionAtEvent(event); - if (!regionId) return; + if (!regionId) { + // Clicking open sea or unclaimed land puts the details away, as on any + // map site; Ctrl-click there is still a no-op. + if (!event.ctrlKey && !event.metaKey) setSelectedId(null); + return; + } if (event.ctrlKey || event.metaKey) { handleDrill(regionId); return; } - const region = regionData[regionId]; - if (!region) return; - - setModalRegionInfo( - buildRegionInfo( - regionId, - region, - mapType, - displayName, - regionData - ) - ); - setModalOpen(true); + if (!regionData[regionId]) return; + setSelectedId(regionId); }; const handleMouseLeave = () => { @@ -718,11 +732,137 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { setCursorTooltip(null); setHoveredOverlay(null); setHoveredFortZoc(null); - setRegionInfo(null); - setSelectedRegionId(null); + setHoveredRegionId(null); lastProvinceIdRef.current = null; }; + /** + * Open the subject layers above `regionId` so it is drawn as itself rather + * than inside its overlord's colour. Needed when a panel link or search + * picks a subject that the current drill state hides. + */ + const revealRegion = useCallback( + (regionId: string) => { + if (!regionData) return; + const ownObject = mapObjects.find( + (obj) => !obj.nested && obj.baseId === regionId + ); + if (!ownObject || ownObject.visible) return; + + const ancestors = getAncestryChain(regionId, regionData).slice(1).reverse(); + if (ancestors.length === 0) return; + const stack: DrillLayer[] = ancestors.map((id) => ({ + regionId: id, + name: regionData[id]?.name || id, + rgb: regionData[id]?.rgb ?? "128,128,128", + })); + applyDrillStack(stack, regionData, resetDrillVisibility, drillDownRegion); + setDrillStack(stack); + }, + [regionData, mapObjects, resetDrillVisibility, drillDownRegion] + ); + + /** Open `regionId`'s own subject layer, and every layer above it. */ + const openSubjects = useCallback( + (regionId: string) => { + if (!regionData?.[regionId]?.subjects?.length) return; + const stack: DrillLayer[] = getAncestryChain(regionId, regionData) + .reverse() + .map((id) => ({ + regionId: id, + name: regionData[id]?.name || id, + rgb: regionData[id]?.rgb ?? "128,128,128", + })); + applyDrillStack(stack, regionData, resetDrillVisibility, drillDownRegion); + setDrillStack(stack); + }, + [regionData, resetDrillVisibility, drillDownRegion] + ); + + const focusRect = useCallback((rect: MapRect) => { + viewportControlsRef.current?.focusMapRect(rect, focusInset()); + }, []); + + const focusRegion = useCallback( + (regionId: string) => { + const box = regionData?.[regionId]?.overlay; + if (box) focusRect(box); + }, + [regionData, focusRect] + ); + + const focusPoint = useCallback( + (mapX: number, mapY: number) => { + focusRect({ x: mapX - 80, y: mapY - 80, w: 160, h: 160 }); + }, + [focusRect] + ); + + /** A panel link or search result: show it, select it, frame it. */ + const selectRegion = useCallback( + (regionId: string, options: { focus?: boolean } = {}) => { + if (!regionData?.[regionId]) return; + revealRegion(regionId); + setSelectedId(regionId); + if (options.focus ?? true) focusRegion(regionId); + }, + [regionData, revealRegion, focusRegion] + ); + + const searchEntries = useMemo( + () => + buildMapSearchIndex({ + regionData, + tierLabel: mapModeLabel(mapType), + settlements, + installations, + }), + [regionData, mapType, settlements, installations] + ); + + const handleSearchSelect = useCallback( + (entry: MapSearchEntry) => { + if (entry.kind === "region") { + selectRegion(entry.regionId); + } else { + focusPoint(entry.mapX, entry.mapY); + } + }, + [selectRegion, focusPoint] + ); + + /** + * The selected region's `_hover` crop, kept lit under the details panel. + * Resolved like hover is, through the visible ancestor, so a subject inside + * a closed realm lights its overlord until its layer is opened. + */ + const selectedOverlay = useMemo(() => { + if (chronicle || !selectedId || !regionData?.[selectedId]) return null; + const target = getHoverRegion(mapType, mapId, selectedId, regionData); + return target.imagePath + ? { url: target.imagePath, overlay: target.overlay } + : null; + // mapObjects: the visible ancestor changes when layers open and close. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chronicle, selectedId, regionData, getHoverRegion, mapType, mapId, mapObjects]); + + // Escape puts the details away, unless it is closing something in a field. + useEffect(() => { + if (!selectedId) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || isTypingTarget(event.target)) return; + setSelectedId(null); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selectedId]); + + // A selection the current data no longer has (a stored day without that + // realm, a mode reload) closes rather than showing an empty panel. + useEffect(() => { + if (selectedId && regionData && !regionData[selectedId]) setSelectedId(null); + }, [selectedId, regionData]); + if (!accessChecked) { return (
@@ -767,7 +907,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { @@ -803,7 +943,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { @@ -826,193 +966,207 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { ); } - return ( + const archived = isArchivedMap(mapId, maps); + const markerLayers = day === null && isMarkerMapMode(mapType); + const layerToggles: MapLayerToggle[] = []; + if (markerLayers) { + layerToggles.push( + { + id: "installations", + label: "Installations", + hint: "Forts, ports, airfields and stations", + checked: installationsVisible, + onChange: setInstallationsVisible, + }, + { + id: "supply-links", + label: "Supply links", + hint: "Trade routes between supply hubs", + checked: supplyLinksVisible, + onChange: setSupplyLinksVisible, + } + ); + } + const desktopLayerToggles: MapLayerToggle[] = chronicle + ? layerToggles + : [ + ...layerToggles, + { + id: "paint", + label: "War planning", + hint: "Draw arrows, labels and objects over the map", + checked: paint.enabled, + onChange: paint.setEnabled, + }, + ]; + + const plaqueActions = chronicle ? null : ( <> - + Live map + + ) : null} + {!archived || showReviewHistory(mapId, maps) ? ( + + ) : null} + {/* The editor writes to the *live* map. Reaching it from a stored day + would invite editing today's titles while looking at last year's — + hence the whole block being hidden in chronicle mode. */} + {canEdit && !canEditLoading ? ( + + Edit titles + + ) : null} + + ); + + const archiveFooter = + chronicle || archived ? null : ( +
+ Earlier chapters + +
+ ); + + const zoomControls = ( + viewportControlsRef.current?.zoomBy(factor)} + onReset={() => viewportControlsRef.current?.reset()} + /> + ); + + const selectedRegion = selectedId ? regionData?.[selectedId] : undefined; + const canShowSubjects = + selectedId !== null && + regionData !== null && + (selectedRegion?.subjects?.length ?? 0) > 0 && + !drillStack.some((layer) => layer.regionId === selectedId); + + const details = + selectedId && regionData && selectedRegion ? ( + - {isArchivedMap(mapId, maps) ? ( -
- - Live map - - {showReviewHistory(mapId, maps) ? ( - - ) : null} -
- ) : ( -
- {/* Shown to everyone, and shown even when this map has captured - no days yet — the studio says so itself, which is a better - answer than an entry point that silently is not there. Costs - no request: the live map must not pay for the chronicle. */} - - -
- )} - {/* The editor writes to the *live* map. Reaching it from a stored - day would invite editing today's titles while looking at last - year's — hence the whole block being hidden in chronicle mode. */} - {canEdit && !canEditLoading ? ( - - Edit titles - - ) : null} -
- ) - } - mapModeSelectorMobile={ - + sessionToken={authToken} + settlements={settlements} + onSelectRegion={(id) => selectRegion(id)} + onFocusPoint={focusPoint} + onFocusRegion={ + selectedRegion.overlay ? () => focusRegion(selectedId) : undefined } - mapModeSelectorDesktop={ - + onShowSubjects={ + canShowSubjects ? () => openSubjects(selectedId) : undefined } - drillStackBar={ - setSelectedId(null)} + /> + ) : null; + + return ( + + } + /> + } + modeBar={} + modeChips={} + breadcrumb={ + drillStack.length > 0 ? ( + - } - desktopSidePanel={ - } + layersMobile={ + layerToggles.length > 0 ? ( + - } - fitModeToggle={ -
-
- Width - - Height -
- {day === null && isMarkerMapMode(mapType) ? ( -
- - -
- ) : null} -
- } - paintPanel={chronicle ? null : } - > - - ) : undefined - } - regionOverlay={ - chronicle ? ( - - ) : undefined - } - /> - - - : null} + > + setModalOpen(false)} + canvasRef={canvasRef} + viewportCoordsRef={viewportCoordsRef} + controlsRef={viewportControlsRef} + mapObjects={mapObjects} + hoveredOverlay={hoveredOverlay} + selectedOverlay={selectedOverlay} + hoveredFortZoc={hoveredFortZoc} + cursorTooltip={cursorTooltip} + labels={regionLabels} + markers={mapMarkers} + wars={wars} + hubLinks={ + day === null && installationsVisible && supplyLinksVisible + ? hubLinks + : [] + } + centroids={centroids} + hoveredMarkerId={hoveredMarkerId} + hoveredNationId={hoveredRegionId ?? selectedId} + onMouseMove={handleCanvasMouseMove} + onMouseLeave={handleMouseLeave} + onClick={handleMapClick} + isHoveringClickable={isHoveringClickable} + fill + fitMode={fitMode} + day={day} + paint={chronicle ? undefined : paint} + provinceOverlay={ + usesChronicleProvincePaint(mapType, day) ? ( + + ) : undefined + } + regionOverlay={ + chronicle ? ( + + ) : undefined + } /> - +
); }; diff --git a/frontend/app/components/chronicle/ChronicleDayViewer.tsx b/frontend/app/components/chronicle/ChronicleDayViewer.tsx index c17b7170..2bde8f53 100644 --- a/frontend/app/components/chronicle/ChronicleDayViewer.tsx +++ b/frontend/app/components/chronicle/ChronicleDayViewer.tsx @@ -104,13 +104,12 @@ export function describeChronicleDay( * The date banner, and the single most important element on this page: a * screenshot of a stored day must carry its date. * - * Fixed rather than in normal flow because `MapPageLayout` claims the whole - * viewport below the site header on desktop and hides its own overflow — - * anything stacked above it would push the map into a scroll region, and a - * banner that can scroll out of frame is a banner that can be missing from the - * screenshot. Pinned top-centre, the one strip of the map shell with no - * floating panel in it (mode selector top-left, nation detail top-right, - * drill stack and controls along the bottom). + * Fixed rather than in normal flow because `MapShell` claims the whole + * viewport below the site header and hides its own overflow — anything + * stacked above it would push the map into a scroll region, and a banner that + * can scroll out of frame is a banner that can be missing from the + * screenshot. Pinned top-centre; `MapShell` starts its top-row controls below + * it wherever the two would meet. */ function ChronicleDateBanner({ mapId, diff --git a/frontend/app/components/map/DraggablePanel.tsx b/frontend/app/components/map/DraggablePanel.tsx deleted file mode 100644 index 70ae6f66..00000000 --- a/frontend/app/components/map/DraggablePanel.tsx +++ /dev/null @@ -1,119 +0,0 @@ -"use client"; - -import { - useEffect, - useRef, - useState, - type ReactNode, - type PointerEvent as ReactPointerEvent, -} from "react"; - -type DraggablePanelProps = { - children: ReactNode; - className?: string; - /** When set, the dragged position is remembered per-browser (localStorage) across sessions. */ - storageKey?: string; -}; - -function loadStoredPos(storageKey: string): { x: number; y: number } | null { - try { - const raw = window.localStorage.getItem(storageKey); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (typeof parsed?.x === "number" && typeof parsed?.y === "number") return parsed; - } catch { - // ignore malformed/unavailable storage - } - return null; -} - -function clampToViewport(x: number, y: number, width: number, height: number) { - const margin = 4; - return { - x: Math.min(Math.max(margin, x), window.innerWidth - width - margin), - y: Math.min(Math.max(margin, y), window.innerHeight - height - margin), - }; -} - -/** - * Wraps a floating overlay panel so it can be dragged anywhere on screen. - * Stays in its default position (via `className`) until first dragged, then - * switches to viewport-fixed coordinates that follow the pointer, clamped to - * stay on screen. Clicks on interactive children (select/button/etc.) are - * left alone so dragging never steals them. With `storageKey`, the dragged - * position persists in this browser's localStorage across visits. - */ -export default function DraggablePanel({ children, className, storageKey }: DraggablePanelProps) { - const panelRef = useRef(null); - const [pos, setPos] = useState<{ x: number; y: number } | null>(null); - const drag = useRef<{ dx: number; dy: number; pointerId: number } | null>(null); - - useEffect(() => { - if (!storageKey) return; - const stored = loadStoredPos(storageKey); - if (!stored || !panelRef.current) return; - const rect = panelRef.current.getBoundingClientRect(); - setPos(clampToViewport(stored.x, stored.y, rect.width, rect.height)); - // Only re-run if the panel identity changes, not on every render. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [storageKey]); - - function persist(next: { x: number; y: number }) { - if (!storageKey) return; - try { - window.localStorage.setItem(storageKey, JSON.stringify(next)); - } catch { - // ignore write failures (private browsing, storage disabled, etc.) - } - } - - function onPointerDown(e: ReactPointerEvent) { - const target = e.target as HTMLElement; - if (target.closest("select, button, a, input, textarea")) return; - const panel = panelRef.current; - if (!panel) return; - const rect = panel.getBoundingClientRect(); - drag.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, pointerId: e.pointerId }; - panel.setPointerCapture(e.pointerId); - e.preventDefault(); - } - - function onPointerMove(e: ReactPointerEvent) { - if (!drag.current || drag.current.pointerId !== e.pointerId) return; - const panel = panelRef.current; - if (!panel) return; - const next = clampToViewport( - e.clientX - drag.current.dx, - e.clientY - drag.current.dy, - panel.offsetWidth, - panel.offsetHeight, - ); - setPos(next); - } - - function onPointerUp(e: ReactPointerEvent) { - if (drag.current?.pointerId === e.pointerId) { - panelRef.current?.releasePointerCapture(e.pointerId); - drag.current = null; - if (pos) persist(pos); - } - } - - return ( -
- {children} -
- ); -} diff --git a/frontend/app/components/map/MapCanvas.tsx b/frontend/app/components/map/MapCanvas.tsx index 111e4e77..cb3622ca 100644 --- a/frontend/app/components/map/MapCanvas.tsx +++ b/frontend/app/components/map/MapCanvas.tsx @@ -33,8 +33,11 @@ import WarCampaignLineLayer from "./WarCampaignLineLayer"; import SupplyLinkLayer from "./SupplyLinkLayer"; import MapAuthImage from "./MapAuthImage"; import MapViewport from "./MapViewport"; -import { useMapViewport } from "../../hooks/useMapViewport"; -import type { FitMode } from "../../lib/mapViewportMath"; +import { + useMapViewport, + type MapFocusInset, +} from "../../hooks/useMapViewport"; +import type { FitMode, MapRect } from "../../lib/mapViewportMath"; import { useMapAssetUrl } from "../../hooks/useMapAssetUrl"; import type { MapPickViewport } from "../../hooks/useMapCoords"; import type { NationLabelSpec, ProvinceCentroids } from "../../lib/mapLabels"; @@ -57,6 +60,16 @@ const PROVINCE_MODE_OVERLAY_OPACITY = 0.72; const OVERLAY_TRANSITION_CLASS = "pointer-events-none absolute transition-[left,top,width,height,opacity] duration-150 ease-out"; +/** + * Camera controls the map shell drives from outside the canvas: the zoom + * buttons, "zoom to realm", and search results. + */ +export type MapViewportControls = { + zoomBy: (factor: number) => void; + reset: () => void; + focusMapRect: (rect: MapRect, inset?: MapFocusInset) => void; +}; + function mapInteractionCursor( isPanning: boolean, isHoveringClickable: boolean @@ -73,6 +86,27 @@ function paintToolCursor(tool: UseMapPaintResult["tool"]): string { return "cursor-crosshair"; } +/** + * A gilt rim around the selected region. The overlay sits inside the scaled + * map, so the glow is sized in map pixels divided by the current scale to stay + * a constant couple of screen pixels at every zoom. + */ +function selectedOutlineStyle(displayScale: number): React.CSSProperties { + const px = displayScale > 0 ? 2 / displayScale : 0; + const gilt = "#f5d27a"; + // Four unblurred offsets trace a crisp rim; the last, blurred and dark, + // lifts it off light terrain. + return { + filter: [ + `drop-shadow(${px}px 0 0 ${gilt})`, + `drop-shadow(-${px}px 0 0 ${gilt})`, + `drop-shadow(0 ${px}px 0 ${gilt})`, + `drop-shadow(0 -${px}px 0 ${gilt})`, + `drop-shadow(0 0 ${px * 1.5}px rgb(0 0 0 / 0.8))`, + ].join(" "), + }; +} + function applyNaturalMapSize( img: HTMLImageElement, setMapSize: Dispatch> @@ -95,6 +129,7 @@ function HoverOverlayImage({ opacity = HOVER_OVERLAY_OPACITY, alt = "Hovered region", imageClassName = "", + imageStyle, }: { mapId: MapId; sessionToken?: string | null; @@ -104,6 +139,7 @@ function HoverOverlayImage({ opacity?: number; alt?: string; imageClassName?: string; + imageStyle?: React.CSSProperties; }) { const [loadedUrl, setLoadedUrl] = useState(null); const path = mapApiPathFromUrl(overlay.url); @@ -138,6 +174,7 @@ function HoverOverlayImage({ className={`${OVERLAY_TRANSITION_CLASS} z-10 ${imageClassName}`.trim()} style={{ ...positioned, + ...imageStyle, opacity: imageOpacity, }} onLoad={markLoaded} @@ -155,6 +192,12 @@ type MapCanvasProps = { canvasRef: RefObject; mapObjects: MapObject[]; hoveredOverlay: HoverOverlay | null; + /** + * The region whose details panel is open, kept lit after the pointer moves + * on. Live map only: like `hoveredOverlay` it is a server PNG of today's + * borders, so a stored day must not pass one. + */ + selectedOverlay?: HoverOverlay | null; hoveredFortZoc?: HoverOverlay | null; cursorTooltip: CursorTooltip | null; labels?: NationLabelSpec[]; @@ -169,6 +212,8 @@ type MapCanvasProps = { onMouseLeave: () => void; onClick: (e: React.MouseEvent) => void; isHoveringClickable?: boolean; + /** Filled with the camera controls once the viewport is mounted. */ + controlsRef?: MutableRefObject; /** Full-bleed mode: fills its container instead of sizing to the map itself * as a bordered card. See `MapViewport`'s `fill` prop. */ fill?: boolean; @@ -209,6 +254,7 @@ export default function MapCanvas({ canvasRef, mapObjects, hoveredOverlay, + selectedOverlay = null, hoveredFortZoc = null, cursorTooltip, labels = [], @@ -223,6 +269,7 @@ export default function MapCanvas({ onMouseLeave, onClick, isHoveringClickable = false, + controlsRef, fill = false, fitMode = "cover", paint, @@ -235,7 +282,23 @@ export default function MapCanvas({ h: mapFallbackSize(mapId), }); - const viewport = useMapViewport({ mapSize, fitMode }); + const paintEnabled = paint?.enabled ?? false; + // Paint mode owns the left button and single-finger drags; middle-drag and + // the wheel still move the map underneath the brush. + const viewport = useMapViewport({ + mapSize, + fitMode, + dragPan: !paintEnabled, + keyboard: true, + }); + + if (controlsRef) { + controlsRef.current = { + zoomBy: viewport.zoomBy, + reset: () => viewport.resetViewport({ animated: true }), + focusMapRect: viewport.focusMapRect, + }; + } const appliedNaturalSizeRef = useRef<{ w: number; h: number } | null>(null); const syncNaturalMapSize = (img: HTMLImageElement) => { @@ -291,7 +354,6 @@ export default function MapCanvas({ ? overlayPathFromHoverUrl(hoveredOverlay.url) : null; - const paintEnabled = paint?.enabled ?? false; const interactionCursor = paintEnabled && !viewport.isPanning ? paintToolCursor(paint!.tool) @@ -305,15 +367,15 @@ export default function MapCanvas({ : `relative max-w-full overflow-hidden ${panelClass}` } > - {cursorTooltip?.text && ( + {cursorTooltip?.text && !viewport.isPanning && (
-

+

{cursorTooltip.text}

{cursorTooltip.hint && ( @@ -331,6 +393,7 @@ export default function MapCanvas({ cursorClassName={interactionCursor} isPanning={viewport.isPanning} fill={fill} + capturesTouch > )} + {regionOverlay === undefined && selectedOverlay && ( + + )} {regionOverlay === undefined && hoveredOverlay && ( { + if (viewport.consumeDragClick()) return; + onClick(event); + }} /> {paint ? ( - {/* Mobile-only header + mode bar, normal document flow above the map. */} -
-
-
-

- World map -

-

- {mapDisplayName} -

-
- {headerAction} -
-
{mapModeSelectorMobile}
-
- -
- {children} - - {/* Floating controls: desktop only. On mobile the map's own square - box has no room to spare for overlays. */} -
- {/* One flow column rather than three hand-tuned `top-[Nrem]` offsets. - Those were tuned against a title panel holding at most a single - link, so anything taller — the staff "Edit titles" button, now the - timelapse card — slid under the mode selector. A dragged panel - switches to `position: fixed` and leaves the flow, so the ones - below simply close up, which is what you would expect. */} -
- -
- {/* Title, then chronicle/archive actions under it so the name - is not truncated to make room for the buttons. */} -
-
-

- World map -

-

- {mapDisplayName} -

-
- {headerAction} -
-
-
- - - {mapModeSelectorDesktop} - - - {paintPanel ? ( - - {paintPanel} - - ) : null} -
- -
- {desktopSidePanel} -
- - {/* This row spans the full map width, so it must let clicks through - to the map; only the panels inside it take pointer events. */} -
- {drillStackBar ?
{drillStackBar}
: null} - -
-

- Controls -

-
    -
  • Scroll to zoom
  • -
  • Middle-click + drag to pan
  • -
- {fitModeToggle ? ( -
- {fitModeToggle} -
- ) : null} -
-
-
-
-
- - {/* Mobile: active-layer bar stays in normal document flow below the map. */} -
{drillStackBar}
-
- ); -} diff --git a/frontend/app/components/map/MapSidePanel.tsx b/frontend/app/components/map/MapSidePanel.tsx deleted file mode 100644 index 5f6f8106..00000000 --- a/frontend/app/components/map/MapSidePanel.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import NationDetailPanel from "./NationDetailPanel"; -import type { DrillLayer } from "./drillUtils"; -import type { MapId, MapMode, RegionInfo, RegionRecord } from "./types"; - -const panelClass = - "rounded-lg border border-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-moss)_35%,var(--tfmc-forest-deep))] p-4 shadow-lg"; - -type MapDrillStackBarProps = { - drillStack: DrillLayer[]; - onSelectLayer: (index: number) => void; - onResetDrill: () => void; -}; - -function nationRgbColor(rgb: string): string { - return `rgb(${rgb})`; -} - -export function MapDrillStackBar({ - drillStack, - onSelectLayer, - onResetDrill, -}: MapDrillStackBarProps) { - if (drillStack.length === 0) return null; - - return ( -
-
-

- Active layers -

-
- {drillStack.map((layer, index) => { - const isCurrent = index === drillStack.length - 1; - - return ( - - ); - })} -
-
- -
- ); -} - -type MapDesktopSidePanelProps = { - mapId: MapId; - mapType: MapMode; - regionInfo: RegionInfo | null; - regionData: RegionRecord | null; - sessionToken?: string | null; -}; - -export function MapDesktopSidePanel({ - mapId, - mapType, - regionInfo, - regionData, - sessionToken, -}: MapDesktopSidePanelProps) { - return ( - - ); -} diff --git a/frontend/app/components/map/MapToolbar.test.ts b/frontend/app/components/map/MapToolbar.test.ts deleted file mode 100644 index 19489e1e..00000000 --- a/frontend/app/components/map/MapToolbar.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { mapToolbarModeOptions } from "./MapToolbar"; -import type { MapMode } from "./types"; - -const ALL_MODES: MapMode[] = [ - "nation", - "county", - "duchy", - "kingdom", - "empire", - "province", - "terrain", - "fertility", - "trade", - "prosperity", - "infestation", -]; - -describe("mapToolbarModeOptions", () => { - it("offers every map mode, including extras that used to be main/dev only", () => { - const values = mapToolbarModeOptions().map((opt) => opt.value); - expect(values).toHaveLength(11); - expect(values).toEqual(ALL_MODES); - }); -}); diff --git a/frontend/app/components/map/MapToolbar.tsx b/frontend/app/components/map/MapToolbar.tsx deleted file mode 100644 index e9bf5cce..00000000 --- a/frontend/app/components/map/MapToolbar.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import type { MapId, MapMode } from "./types"; - -const panelClass = - "rounded-lg border border-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-moss)_35%,var(--tfmc-forest-deep))] shadow-lg"; - -const BASE_MODE_OPTIONS: { value: MapMode; label: string }[] = [ - { value: "nation", label: "Nation Map" }, - { value: "county", label: "County Map" }, - { value: "duchy", label: "Duchy Map" }, - { value: "kingdom", label: "Kingdom Map" }, - { value: "empire", label: "Empire Map" }, -]; - -const EXTRA_MODE_OPTIONS: { value: MapMode; label: string }[] = [ - { value: "province", label: "Provinces" }, - { value: "terrain", label: "Terrain" }, - { value: "fertility", label: "Fertility" }, - { value: "trade", label: "Trade" }, - { value: "prosperity", label: "Prosperity" }, - { value: "infestation", label: "Infestation" }, -]; - -export function mapToolbarModeOptions(): { value: MapMode; label: string }[] { - return [...BASE_MODE_OPTIONS, ...EXTRA_MODE_OPTIONS]; -} - -type MapToolbarProps = { - mapId: MapId; - mapType: MapMode; - onMapTypeChange: (mode: MapMode) => void; - variant?: "sidebar" | "mobile" | "bar"; -}; - -export default function MapToolbar({ - mapType, - onMapTypeChange, - variant = "sidebar", -}: MapToolbarProps) { - const isMobile = variant === "mobile"; - const isBar = variant === "bar"; - const isSidebar = variant === "sidebar"; - - /* - * One list on every map, live or stored day. A mode with nothing stored for - * a given day (or a missing PNG on an archived chapter) falls through to - * MapViewer's missing-capture / empty panel rather than vanishing from the - * menu. - */ - const options = mapToolbarModeOptions(); - - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - - useEffect(() => { - if (!open) return; - function onPointerDown(e: PointerEvent) { - if (!rootRef.current?.contains(e.target as Node)) setOpen(false); - } - document.addEventListener("pointerdown", onPointerDown); - return () => document.removeEventListener("pointerdown", onPointerDown); - }, [open]); - - if (isSidebar) { - const selected = options.find((o) => o.value === mapType); - return ( -
-
- - Map mode - {selected?.label ?? mapType} - - -
- {open ? ( -
- {options.map((opt) => ( - - ))} -
- ) : null} -
- ); - } - - return ( -
-

- Map mode -

- -
- ); -} diff --git a/frontend/app/components/map/MapViewport.tsx b/frontend/app/components/map/MapViewport.tsx index cc71e139..3abe4211 100644 --- a/frontend/app/components/map/MapViewport.tsx +++ b/frontend/app/components/map/MapViewport.tsx @@ -18,6 +18,11 @@ export type MapViewportProps = { * the whole map inside whatever rectangle results. */ fill?: boolean; + /** + * The viewport handles touch itself (one-finger pan, pinch zoom), so the + * browser must not scroll or zoom the page underneath it. + */ + capturesTouch?: boolean; }; export default function MapViewport({ @@ -29,6 +34,7 @@ export default function MapViewport({ isPanning, children, fill = false, + capturesTouch = false, }: MapViewportProps) { const { w: mapW, h: mapH } = mapSize; @@ -49,8 +55,8 @@ export default function MapViewport({
diff --git a/frontend/app/components/map/NationDetailContent.tsx b/frontend/app/components/map/NationDetailContent.tsx deleted file mode 100644 index c6a9f0e4..00000000 --- a/frontend/app/components/map/NationDetailContent.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import MapAuthImage from "./MapAuthImage"; -import type { MapId, MapMode, RegionInfo, RegionRecord } from "./types"; - -type NationDetailContentProps = { - mapId: MapId; - mapType: MapMode; - regionInfo: RegionInfo; - regionData: RegionRecord | null; - sessionToken?: string | null; -}; - -export default function NationDetailContent({ - mapId, - mapType, - regionInfo, - regionData, - sessionToken, -}: NationDetailContentProps) { - return ( -
-
-

- {regionInfo.title} -

-

- - {mapType === "trade" ? "Type:" : "Tier:"} - {" "} - {regionInfo.tier} -

- - {mapType === "nation" && ( - <> -

- Type:{" "} - {regionInfo.overlord - ? `Subject of ${regionInfo.overlord}` - : "Independent"} -

-

- Realm size:{" "} - {regionInfo.subject_size > 0 - ? `${regionInfo.size} (${regionInfo.subject_size} from subjects)` - : regionInfo.size} -

- - )} - - {regionInfo.subjects.length > 0 && ( -
-

- Subjects -

-
    - {regionInfo.subjects.map((subjectId) => ( -
  • - {regionData?.[subjectId]?.name || subjectId} -
  • - ))} -
-
- )} - -

- {regionInfo.description} -

-
- - {regionInfo.banner && ( -
- -

- Official flag -

-
- )} -
- ); -} diff --git a/frontend/app/components/map/NationDetailModal.tsx b/frontend/app/components/map/NationDetailModal.tsx deleted file mode 100644 index 9d2c738c..00000000 --- a/frontend/app/components/map/NationDetailModal.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import NationDetailContent from "./NationDetailContent"; -import type { MapId, MapMode, RegionInfo, RegionRecord } from "./types"; - -type NationDetailModalProps = { - open: boolean; - mapId: MapId; - mapType: MapMode; - regionInfo: RegionInfo | null; - regionData: RegionRecord | null; - sessionToken?: string | null; - onClose: () => void; -}; - -export default function NationDetailModal({ - open, - mapId, - mapType, - regionInfo, - regionData, - sessionToken, - onClose, -}: NationDetailModalProps) { - useEffect(() => { - if (!open) return; - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); - }; - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - window.addEventListener("keydown", onKeyDown); - - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener("keydown", onKeyDown); - }; - }, [open, onClose]); - - if (!open || !regionInfo) return null; - - return ( -
-
event.stopPropagation()} - > -
- -
-

- Nation details -

- -
- - -
-
- ); -} diff --git a/frontend/app/components/map/NationDetailPanel.tsx b/frontend/app/components/map/NationDetailPanel.tsx deleted file mode 100644 index 54a22210..00000000 --- a/frontend/app/components/map/NationDetailPanel.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import NationDetailContent from "./NationDetailContent"; -import type { MapId, MapMode, RegionInfo, RegionRecord } from "./types"; - -const panelClass = - "rounded-lg border border-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-moss)_35%,var(--tfmc-forest-deep))] shadow-lg overflow-hidden transition-all duration-300"; - -type NationDetailPanelProps = { - mapId: MapId; - mapType: MapMode; - regionInfo: RegionInfo | null; - regionData: RegionRecord | null; - sessionToken?: string | null; -}; - -export default function NationDetailPanel({ - mapId, - mapType, - regionInfo, - regionData, - sessionToken, -}: NationDetailPanelProps) { - const showPanel = - regionInfo && mapType !== "terrain" && mapType !== "fertility" && mapType !== "infestation"; - - return ( -
- {showPanel && regionInfo && ( - - )} -
- ); -} diff --git a/frontend/app/components/map/PaintToolbar.tsx b/frontend/app/components/map/PaintToolbar.tsx index 571bdac9..1b46e941 100644 --- a/frontend/app/components/map/PaintToolbar.tsx +++ b/frontend/app/components/map/PaintToolbar.tsx @@ -26,8 +26,7 @@ import { } from "../../lib/mapPaint"; import type { UseMapPaintResult } from "../../hooks/useMapPaint"; -const panelClass = - "rounded-lg border border-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-moss)_35%,var(--tfmc-forest-deep))] shadow-lg"; +const panelClass = "map-frame"; const activeClass = "bg-[color-mix(in_srgb,var(--tfmc-cream)_92%,transparent)] text-[var(--tfmc-forest-deep)]"; @@ -65,9 +64,8 @@ const TEXT_MARKS: { type PaintToolbarProps = { paint: UseMapPaintResult }; /** - * Floating panel for the war-planning paint layer. Every control is a real - * + {drillStack.map((layer, index) => { + const current = index === drillStack.length - 1; + return ( + + + + + ); + })} + + + ); +} diff --git a/frontend/app/components/map/shell/MapIcons.tsx b/frontend/app/components/map/shell/MapIcons.tsx new file mode 100644 index 00000000..e984fa05 --- /dev/null +++ b/frontend/app/components/map/shell/MapIcons.tsx @@ -0,0 +1,201 @@ +import type { ReactNode, SVGProps } from "react"; + +import type { MapMode } from "../types"; + +type IconProps = SVGProps & { size?: number }; + +function Icon({ size = 20, children, ...rest }: IconProps & { children: ReactNode }) { + return ( + + {children} + + ); +} + +/* Map modes. Heraldic shapes for the political tiers, plain glyphs for the rest. */ + +export const RealmIcon = (props: IconProps) => ( + + + + +); + +export const CountyIcon = (props: IconProps) => ( + + + + +); + +export const DuchyIcon = (props: IconProps) => ( + + + + +); + +export const KingdomIcon = (props: IconProps) => ( + + + + + + + +); + +export const EmpireIcon = (props: IconProps) => ( + + + + +); + +export const ProvinceIcon = (props: IconProps) => ( + + + + +); + +export const TerrainIcon = (props: IconProps) => ( + + + + +); + +export const FertilityIcon = (props: IconProps) => ( + + + + +); + +export const TradeIcon = (props: IconProps) => ( + + + + +); + +export const ProsperityIcon = (props: IconProps) => ( + + + + + + +); + +export const InfestationIcon = (props: IconProps) => ( + + + + +); + +/* Controls. */ + +export const PlusIcon = (props: IconProps) => ( + + + +); + +export const MinusIcon = (props: IconProps) => ( + + + +); + +export const FitIcon = (props: IconProps) => ( + + + +); + +export const LayersIcon = (props: IconProps) => ( + + + + +); + +export const SearchIcon = (props: IconProps) => ( + + + + +); + +export const CloseIcon = (props: IconProps) => ( + + + +); + +export const HistoryIcon = (props: IconProps) => ( + + + +); + +export const FocusIcon = (props: IconProps) => ( + + + + +); + +export const SubjectsIcon = (props: IconProps) => ( + + + +); + +export const PinIcon = (props: IconProps) => ( + + + + +); + +export const BrushIcon = (props: IconProps) => ( + + + + +); + +export const ChevronIcon = (props: IconProps) => ( + + + +); + +export const MAP_MODE_ICONS: Record ReactNode> = { + nation: RealmIcon, + county: CountyIcon, + duchy: DuchyIcon, + kingdom: KingdomIcon, + empire: EmpireIcon, + province: ProvinceIcon, + terrain: TerrainIcon, + fertility: FertilityIcon, + trade: TradeIcon, + prosperity: ProsperityIcon, + infestation: InfestationIcon, +}; diff --git a/frontend/app/components/map/shell/MapLayersMenu.tsx b/frontend/app/components/map/shell/MapLayersMenu.tsx new file mode 100644 index 00000000..7c0f8f32 --- /dev/null +++ b/frontend/app/components/map/shell/MapLayersMenu.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import { LayersIcon } from "./MapIcons"; + +export type MapLayerToggle = { + id: string; + label: string; + hint?: string; + checked: boolean; + onChange: (next: boolean) => void; +}; + +type MapLayersMenuProps = { + toggles: MapLayerToggle[]; + /** Opens upwards from a bottom-anchored button, downwards otherwise. */ + placement?: "up" | "down"; + /** Extra rows under the toggles: history and archive links. */ + footer?: ReactNode; + /** Class for the trigger, so mobile can make it round. */ + triggerClassName?: string; + /** Which edge of the trigger the popover lines up with. */ + align?: "left" | "right"; +}; + +/** + * Google Maps' "Layers" button: the overlays and tools that sit on top of + * whichever map mode is chosen. It absorbs what used to be the Controls + * legend's checkboxes and the always-open paint-mode panel. + */ +export default function MapLayersMenu({ + toggles, + placement = "up", + footer, + triggerClassName = "h-10 px-3 text-sm", + align = "left", +}: MapLayersMenuProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onPointerDown(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + document.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + if (toggles.length === 0 && !footer) return null; + + return ( +
+ + {open ? ( +
+ {toggles.length > 0 ? ( + <> +

Show on map

+
    + {toggles.map((toggle) => ( +
  • + +
  • + ))} +
+ + ) : null} + {footer ? ( +
0 ? "mt-3 border-t border-[var(--tfmc-gilt-dim)]/50 pt-3" : ""}> + {footer} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/frontend/app/components/map/shell/MapModeBar.tsx b/frontend/app/components/map/shell/MapModeBar.tsx new file mode 100644 index 00000000..29042171 --- /dev/null +++ b/frontend/app/components/map/shell/MapModeBar.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Fragment } from "react"; + +import { mapModeLabel, mapModeOptions } from "../mapModes"; +import type { MapMode } from "../types"; +import { MAP_MODE_ICONS } from "./MapIcons"; + +type MapModeBarProps = { + mapType: MapMode; + onMapTypeChange: (mode: MapMode) => void; +}; + +/** + * Desktop map-mode row: round medallions in a framed tray along the bottom + * edge, political tiers then world modes, the way CK3 lays out its map modes. + * The caption above the tray always names the active mode, so the icons never + * have to be decoded from memory. + */ +export function MapModeBar({ mapType, onMapTypeChange }: MapModeBarProps) { + const options = mapModeOptions(); + + return ( + + ); +} + +/** + * Mobile map modes: a scrolling row of labelled chips, like Google Maps' + * place-type chips. Labels rather than icons alone, since there is no hover + * to explain them on a phone. + */ +export function MapModeChips({ mapType, onMapTypeChange }: MapModeBarProps) { + return ( + + ); +} diff --git a/frontend/app/components/map/shell/MapPlaque.tsx b/frontend/app/components/map/shell/MapPlaque.tsx new file mode 100644 index 00000000..5487aecc --- /dev/null +++ b/frontend/app/components/map/shell/MapPlaque.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +type MapPlaqueProps = { + /** Small caps over the name, e.g. "World map". */ + eyebrow: string; + mapDisplayName: string; + /** Icon buttons beside the name: history, archive, edit titles. */ + actions?: ReactNode; + search?: ReactNode; +}; + +/** The map's name plate with the search box under it, top left. */ +export default function MapPlaque({ + eyebrow, + mapDisplayName, + actions, + search, +}: MapPlaqueProps) { + return ( +
+
+
+

+ {eyebrow} +

+

+ {mapDisplayName} +

+
+ {actions ?
{actions}
: null} +
+ {search ?
{search}
: null} +
+ ); +} diff --git a/frontend/app/components/map/shell/MapSearch.tsx b/frontend/app/components/map/shell/MapSearch.tsx new file mode 100644 index 00000000..0fd4f856 --- /dev/null +++ b/frontend/app/components/map/shell/MapSearch.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useEffect, useId, useMemo, useRef, useState } from "react"; + +import { searchMap, type MapSearchEntry } from "@/app/lib/map/mapSearch"; + +import { CloseIcon, PinIcon, SearchIcon } from "./MapIcons"; + +type MapSearchProps = { + entries: MapSearchEntry[]; + placeholder: string; + onSelect: (entry: MapSearchEntry) => void; +}; + +/** + * The omnibox: type a realm, title or place and pick it to fly there. Results + * come from data the map already holds, so this costs no request. + */ +export default function MapSearch({ entries, placeholder, onSelect }: MapSearchProps) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const rootRef = useRef(null); + const inputRef = useRef(null); + const listId = useId(); + + const results = useMemo(() => searchMap(entries, query), [entries, query]); + + useEffect(() => { + setActive(0); + }, [query]); + + useEffect(() => { + if (!open) return; + function onPointerDown(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + } + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + // "/" jumps to the search box from anywhere on the map, as on most map sites. + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key !== "/" || event.ctrlKey || event.metaKey || event.altKey) return; + const target = event.target as HTMLElement | null; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) { + return; + } + event.preventDefault(); + inputRef.current?.focus(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + function choose(entry: MapSearchEntry) { + onSelect(entry); + setQuery(""); + setOpen(false); + inputRef.current?.blur(); + } + + const showList = open && query.trim().length > 0; + + return ( +
+
+ + { + setQuery(event.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActive((index) => Math.min(index + 1, Math.max(results.length - 1, 0))); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActive((index) => Math.max(index - 1, 0)); + } else if (event.key === "Enter") { + const entry = results[active]; + if (entry) { + event.preventDefault(); + choose(entry); + } + } else if (event.key === "Escape") { + setQuery(""); + setOpen(false); + inputRef.current?.blur(); + } + }} + className="h-10 min-w-0 flex-1 bg-transparent text-sm text-[var(--tfmc-parchment)] placeholder:text-[var(--tfmc-stone)]/70 focus:outline-none [&::-webkit-search-cancel-button]:hidden" + /> + {query ? ( + + ) : ( + + / + + )} +
+ + {showList ? ( +
    + {results.length === 0 ? ( +
  • + Nothing on this map matches “{query.trim()}”. +
  • + ) : ( + results.map((entry, index) => ( +
  • event.preventDefault()} + onClick={() => choose(entry)} + onMouseEnter={() => setActive(index)} + className={`flex cursor-pointer items-center gap-2.5 rounded px-2.5 py-2 ${ + index === active ? "bg-[rgb(240_213_140/0.1)]" : "" + }`} + > + {entry.kind === "region" ? ( + + ) : ( + + )} + + + {entry.label} + + + {entry.detail} + + +
  • + )) + )} +
+ ) : null} +
+ ); +} diff --git a/frontend/app/components/map/shell/MapShell.tsx b/frontend/app/components/map/shell/MapShell.tsx new file mode 100644 index 00000000..a37278ab --- /dev/null +++ b/frontend/app/components/map/shell/MapShell.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; + +type MapShellProps = { + /** The map itself. Rendered exactly once, at every breakpoint. */ + children: ReactNode; + /** Title plaque and search, top left. */ + plaque: ReactNode; + /** Desktop map-mode tray, bottom centre. */ + modeBar: ReactNode; + /** Mobile map-mode chips, under the search box. */ + modeChips: ReactNode; + /** Drill-down breadcrumb, top centre; null when not drilled in. */ + breadcrumb?: ReactNode; + /** The selected region's details; null when nothing is selected. */ + details?: ReactNode; + /** Changes whenever a different region is selected, to reset the sheet. */ + detailsKey?: string | null; + zoomControls: ReactNode; + layers: ReactNode; + /** The layers menu for phones, opening up and to the left. */ + layersMobile?: ReactNode; + /** War-planning toolbar, top right on desktop while paint mode is on. */ + paintPanel?: ReactNode; + /** + * A stored chronicle day. Its date banner is fixed at the top centre of the + * screen, so the top-row controls start below it wherever they would meet. + */ + chronicle?: boolean; +}; + +/** + * Full-bleed map with its controls floating over it: Google Maps' layout in + * CK3's frames. Search and the details panel down the left, zoom bottom right, + * map modes along the bottom, layers bottom left. + * + * On a phone the same pieces regroup instead of shrinking: search and mode + * chips across the top, details in a bottom sheet. Every control is one node + * placed by responsive classes rather than a mobile and a desktop copy, so + * nothing the map owns is mounted twice. + */ +export default function MapShell({ + children, + plaque, + modeBar, + modeChips, + breadcrumb, + details, + detailsKey, + zoomControls, + layers, + layersMobile, + paintPanel, + chronicle = false, +}: MapShellProps) { + const [sheetExpanded, setSheetExpanded] = useState(false); + + // A newly selected region opens the sheet at its peek height again. + useEffect(() => { + setSheetExpanded(false); + }, [detailsKey]); + + const hasDetails = Boolean(details); + const chronicleTop = chronicle ? "max-xl:top-[10.75rem]" : ""; + + return ( +
+
{children}
+ + {/* Left column: plaque on top, details under it on desktop; on mobile + the details drop to a bottom sheet via `mt-auto`. The column itself + lets clicks through to the map; only its panels take them. */} +
+ {/* Above the details panel, so search results drop down over it. */} +
+ {plaque} +
{modeChips}
+ {breadcrumb ?
{breadcrumb}
: null} +
+ + {details ? ( +
+ +
+ {details} +
+
+ ) : null} +
+ + {breadcrumb ? ( + + ) : null} + + {paintPanel ? ( + + ) : null} + + {/* Over the left column, so the Layers popover opens above the panel. */} +
+
{layers}
+
{modeBar}
+
{zoomControls}
+
+ + {/* Phone: layers and zoom tuck into the bottom-right corner while no + sheet is open; with one open, pinch still zooms. */} + {hasDetails ? null : ( +
+ {layersMobile ?
{layersMobile}
: null} +
{zoomControls}
+
+ )} +
+ ); +} diff --git a/frontend/app/components/map/shell/MapZoomControls.tsx b/frontend/app/components/map/shell/MapZoomControls.tsx new file mode 100644 index 00000000..712dc51e --- /dev/null +++ b/frontend/app/components/map/shell/MapZoomControls.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { MAP_ZOOM_STEP } from "@/app/lib/mapGestures"; + +import { FitIcon, MinusIcon, PlusIcon } from "./MapIcons"; + +type MapZoomControlsProps = { + onZoom: (factor: number) => void; + onReset: () => void; +}; + +/** Google Maps' zoom stack: in, out, and back to the whole map. */ +export default function MapZoomControls({ onZoom, onReset }: MapZoomControlsProps) { + const buttonClass = "map-control h-10 w-10 rounded-none border-0 shadow-none"; + return ( +
+ + + + + +
+ ); +} diff --git a/frontend/app/components/map/shell/RealmPanel.tsx b/frontend/app/components/map/shell/RealmPanel.tsx new file mode 100644 index 00000000..c2839ce8 --- /dev/null +++ b/frontend/app/components/map/shell/RealmPanel.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { useMemo, type ReactNode } from "react"; + +import { + buildRealmProfile, + formatFoundedDate, + realmCapitalSettlement, + relationKindLabel, + sortRealmRelations, + type RealmProfile, + type RealmRelation, +} from "@/app/lib/map/realmProfile"; +import { cleanRegionName } from "@/app/lib/mapLabels"; + +import MapAuthImage from "../MapAuthImage"; +import { buildRegionInfo } from "../regionInfo"; +import type { MapId, MapMode, RegionRecord, SettlementMarker } from "../types"; +import { CloseIcon, FocusIcon, SubjectsIcon } from "./MapIcons"; + +export type RealmPanelProps = { + mapId: MapId; + mapType: MapMode; + regionId: string; + regionData: RegionRecord; + mapDisplayName: string; + sessionToken?: string | null; + settlements: SettlementMarker[]; + /** Open another region's panel: an overlord, subject or neighbour. */ + onSelectRegion: (regionId: string) => void; + /** Fly to a map point, e.g. the capital. */ + onFocusPoint?: (mapX: number, mapY: number) => void; + onFocusRegion?: () => void; + /** Open the subject layout; absent when the region has none to show. */ + onShowSubjects?: () => void; + onClose: () => void; +}; + +const ATTITUDE_STYLES: Record = { + friendly: "border-emerald-400/50 bg-emerald-400/10 text-emerald-200", + neutral: "border-[var(--tfmc-stone)]/40 bg-white/5 text-[var(--tfmc-stone)]", + unfriendly: "border-amber-400/50 bg-amber-400/10 text-amber-200", + hostile: "border-red-400/60 bg-red-500/15 text-red-200", +}; + +function regionName(regionData: RegionRecord, id: string): string { + return cleanRegionName(regionData[id]?.name ?? id) || id; +} + +function Banner({ + mapId, + mapType, + banner, + name, + sessionToken, + className, +}: { + mapId: MapId; + mapType: MapMode; + banner: string | null | undefined; + name: string; + sessionToken?: string | null; + className: string; +}) { + if (!banner) { + return
; + } + return ( +
+ +
+ ); +} + +function RegionLink({ + id, + regionData, + onSelectRegion, +}: { + id: string; + regionData: RegionRecord; + onSelectRegion: (regionId: string) => void; +}) { + const known = Boolean(regionData[id]); + const name = regionName(regionData, id); + if (!known) return {name}; + return ( + + ); +} + +function Fact({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function RelationRow({ + relation, + regionData, + onSelectRegion, +}: { + relation: RealmRelation; + regionData: RegionRecord; + onSelectRegion: (regionId: string) => void; +}) { + const kind = relationKindLabel(relation.kind); + const attitude = relation.attitude ?? "neutral"; + const swatch = regionData[relation.id]?.rgb; + return ( +
  • + + + + {kind ? {kind} : null} + + + {attitude} + {relation.score !== null ? ( + + {relation.score > 0 ? `+${relation.score}` : relation.score} + + ) : null} + +
  • + ); +} + +function PanelActions({ + onFocusRegion, + onShowSubjects, +}: Pick) { + if (!onFocusRegion && !onShowSubjects) return null; + return ( +
    + {onFocusRegion ? ( + + ) : null} + {onShowSubjects ? ( + + ) : null} +
    + ); +} + +function RealmBody({ + profile, + props, +}: { + profile: RealmProfile; + props: RealmPanelProps; +}) { + const { mapId, mapType, regionData, sessionToken, settlements, onSelectRegion, onFocusPoint } = + props; + const capital = realmCapitalSettlement(profile, settlements); + const relations = sortRealmRelations(profile.relations); + const subjectSize = profile.realmSize - profile.provinces; + + return ( + <> +
    + {profile.government ?? "—"} + {profile.culture ?? "—"} + {profile.religion ?? "—"} + + {capital ? ( + typeof capital.map_x === "number" && + typeof capital.map_y === "number" && + onFocusPoint ? ( + + ) : ( + cleanRegionName(capital.name) + ) + ) : profile.capitalProvince !== null ? ( + "Unnamed" + ) : ( + "None" + )} + + + {profile.realmSize} {profile.realmSize === 1 ? "province" : "provinces"} + {subjectSize > 0 ? ( + + {subjectSize} held by subjects + + ) : null} + + + {profile.foundedAt !== null ? formatFoundedDate(profile.foundedAt) : "—"} + +
    + + + + {profile.subjects.length > 0 ? ( +
    +
      + {profile.subjects.map((subjectId) => ( +
    • + +
    • + ))} +
    +
    + ) : null} + + {relations.length > 0 ? ( +
    +
      + {relations.map((relation) => ( + + ))} +
    +
    + ) : null} + + ); +} + +/** + * The detail view for whatever is selected on the map, after CK3's realm + * window: banner in a heraldic frame, the ruler, then the realm's facts, + * subjects and relations. Every name in it is a link to that realm. + * + * On the nation map it reads the allowlisted `RealmProfile`. Title and trade + * modes carry less, so they get the same frame with the fields they have. + */ +export function RealmPanelContent(props: RealmPanelProps) { + const { mapId, mapType, regionId, regionData, mapDisplayName, sessionToken, onSelectRegion } = + props; + const raw = regionData[regionId]; + + const profile = useMemo( + () => + mapType === "nation" && raw + ? buildRealmProfile(regionId, raw as Record) + : null, + [mapType, raw, regionId] + ); + const info = useMemo( + () => (raw ? buildRegionInfo(regionId, raw, mapType, mapDisplayName, regionData) : null), + [raw, regionId, mapType, mapDisplayName, regionData] + ); + if (!raw || !info) return null; + + const name = profile?.name ?? (cleanRegionName(info.title) || regionId); + const overlordId = raw.overlord ?? null; + + return ( +
    +
    + +
    +

    + {profile?.rank ? `${profile.rank} realm` : mapType === "trade" ? "Trade area" : info.tier} +

    +

    + {name} +

    +

    + {overlordId ? ( + <> + {mapType === "nation" ? "Subject of " : "Part of "} + + + ) : mapType === "nation" ? ( + "Independent realm" + ) : ( + info.description + )} +

    +
    +
    + + + + {profile ? ( + <> + {profile.leader ? ( +
    + + {profile.rulerTitle ?? "Ruler"} + + + {profile.leader} + +
    + ) : null} + + + ) : ( + <> +
    + {info.tier} + {info.size > 0 ? {info.size} : null} +
    + + {info.subjects.length > 0 ? ( +
    +
      + {info.subjects.map((subjectId) => ( +
    • + +
    • + ))} +
    +
    + ) : null} + + )} +
    + ); +} diff --git a/frontend/app/components/shell/SiteHeader.tsx b/frontend/app/components/shell/SiteHeader.tsx index 228533bd..ab795542 100644 --- a/frontend/app/components/shell/SiteHeader.tsx +++ b/frontend/app/components/shell/SiteHeader.tsx @@ -6,7 +6,7 @@ import { useSiteStaffAccess } from "@/app/hooks/useSiteStaffAccess"; const staticLinks = [ { href: "/", label: "Home" }, - { href: "/map/main", label: "Map" }, + { href: "/map", label: "Map" }, { href: "/skins", label: "Skins" }, { href: "/drinks", label: "Drinks" }, { href: "/profile", label: "Profile" }, diff --git a/frontend/app/hooks/useMapViewport.ts b/frontend/app/hooks/useMapViewport.ts index 8ccf0ce6..09488c44 100644 --- a/frontend/app/hooks/useMapViewport.ts +++ b/frontend/app/hooks/useMapViewport.ts @@ -1,388 +1,309 @@ import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type RefObject, - } from "react"; - - - import { - clampTranslate, - clampUserScale, - computeCenteredTransform, - computeDisplayScale, - computeFitScale, - screenToMap, - + transformForMapRect, viewportTransformStyle, - zoomAtPoint, - + zoomToScaleAtPoint, type FitMode, - + type MapRect, type Size, - + type ViewportPoint, type ViewportTransform, - } from "../lib/mapViewportMath"; - - +import { + MAP_ZOOM_STEP, + exceedsDragThreshold, + isTypingTarget, + keyboardMapAction, + pinchSample, + pinchUserScale, + type PinchSample, +} from "../lib/mapGestures"; export type UseMapViewportOptions = { - mapSize: Size; - enabled?: boolean; - fitMode?: FitMode; - + /** + * Left-drag, one-finger drag and two-finger pinch move the map, Google Maps + * style. Off by default because the title editor's canvas uses left-drag for + * its own selection; the live map turns it off while paint mode owns the + * left button. + */ + dragPan?: boolean; + /** Arrow keys pan and +/- zoom while the pointer is not in a text field. */ + keyboard?: boolean; }; - +export type MapFocusInset = { + left?: number; + right?: number; + top?: number; + bottom?: number; +}; export type UseMapViewportResult = { - viewportRef: RefObject; - userScale: number; - translateX: number; - translateY: number; - displayScale: number; - fitScale: number; - isPanning: boolean; - transformStyle: string; - transformTransition?: string; - cursorClassName: string; - resetViewport: (options?: ViewportResetOptions) => void; - + /** Zoom by `factor` around the viewport centre, animated. */ + zoomBy: (factor: number) => void; + /** Animate to frame a map-space rectangle, clear of `inset` screen furniture. */ + focusMapRect: (rect: MapRect, inset?: MapFocusInset) => void; + /** + * True once for the click that ends a drag. The browser still fires `click` + * after a press that panned the map; the canvas asks this first so a pan + * never also selects whatever was under the pointer when it ended. + */ + consumeDragClick: () => boolean; screenToMapFromClient: ( - clientX: number, - clientY: number - ) => { x: number; y: number } | null; - }; - - export type ViewportResetOptions = { - animated?: boolean; - }; - - const VIEWPORT_RESET_TRANSITION_MS = 200; - const VIEWPORT_RESET_TRANSITION = `transform ${VIEWPORT_RESET_TRANSITION_MS}ms ease-out`; - - +const VIEWPORT_FOCUS_TRANSITION_MS = 450; +const VIEWPORT_FOCUS_TRANSITION = `transform ${VIEWPORT_FOCUS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`; const INITIAL_TRANSFORM: ViewportTransform = { - userScale: 1, - translateX: 0, - translateY: 0, - }; +type PanGesture = { + kind: "pan"; + pointerId: number; + startClientX: number; + startClientY: number; + startTranslateX: number; + startTranslateY: number; + /** Past the drag threshold; until then the press may still be a click. */ + moved: boolean; +}; +type PinchGesture = { + kind: "pinch"; + start: PinchSample; + startTransform: ViewportTransform; +}; -function isMiddleMouseButton(event: MouseEvent): boolean { - - return event.button === 1; +type Gesture = PanGesture | PinchGesture; +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); } - - export function readViewportSize(element: HTMLElement): Size { - const rect = element.getBoundingClientRect(); - return { w: rect.width, h: rect.height }; - } - - export function useMapViewport({ - mapSize, - enabled = true, - fitMode = "cover", - + dragPan = false, + keyboard = false, }: UseMapViewportOptions): UseMapViewportResult { - const viewportRef = useRef(null); - const [viewportSize, setViewportSize] = useState({ w: 0, h: 0 }); - const [transform, setTransform] = useState(INITIAL_TRANSFORM); - const [isPanning, setIsPanning] = useState(false); - - const [isResetAnimating, setIsResetAnimating] = useState(false); - - + const [transition, setTransition] = useState(undefined); const transformRef = useRef(transform); - transformRef.current = transform; - - - const viewportSizeRef = useRef(viewportSize); - viewportSizeRef.current = viewportSize; - - - const mapSizeRef = useRef(mapSize); - mapSizeRef.current = mapSize; - - - const fitModeRef = useRef(fitMode); - fitModeRef.current = fitMode; - - - - const panStateRef = useRef<{ - - startClientX: number; - - startClientY: number; - - startTranslateX: number; - - startTranslateY: number; - - } | null>(null); - - - - const resetTimerRef = useRef | null>(null); - - - - const clearResetAnimation = useCallback(() => { - - if (resetTimerRef.current !== null) { - - clearTimeout(resetTimerRef.current); - - resetTimerRef.current = null; - + const dragPanRef = useRef(dragPan); + dragPanRef.current = dragPan; + + const gestureRef = useRef(null); + /** Active touch/pen/mouse pointers in viewport pixels, for pinch. */ + const pointersRef = useRef(new Map()); + const dragClickRef = useRef(false); + const transitionTimerRef = useRef | null>(null); + + const clearTransition = useCallback(() => { + if (transitionTimerRef.current !== null) { + clearTimeout(transitionTimerRef.current); + transitionTimerRef.current = null; } - - setIsResetAnimating(false); - + setTransition(undefined); }, []); - + /** Apply `next` with a CSS transition that is dropped once it has run. */ + const animateTo = useCallback( + (next: ViewportTransform, css: string, ms: number) => { + clearTransition(); + setTransform(next); + if (prefersReducedMotion()) return; + setTransition(css); + transitionTimerRef.current = setTimeout(() => { + transitionTimerRef.current = null; + setTransition(undefined); + }, ms); + }, + [clearTransition] + ); const fitScale = useMemo( - () => computeFitScale(viewportSize, mapSize, fitMode), - [viewportSize, mapSize, fitMode] - ); - - - const displayScale = useMemo( - () => computeDisplayScale(fitScale, transform.userScale), - [fitScale, transform.userScale] - ); - - const applyClampedTransform = useCallback((next: ViewportTransform): ViewportTransform => { - const viewport = viewportSizeRef.current; - const map = mapSizeRef.current; - const nextFitScale = computeFitScale(viewport, map, fitModeRef.current); - const clampedUserScale = clampUserScale(next.userScale); - const nextDisplayScale = computeDisplayScale(nextFitScale, clampedUserScale); - const clamped = clampTranslate( - viewport, - map, - nextDisplayScale, - next.translateX, - next.translateY - ); - - - return { - userScale: clampedUserScale, - translateX: clamped.x, - translateY: clamped.y, - }; - }, []); - + const endGesture = useCallback(() => { + gestureRef.current = null; + pointersRef.current.clear(); + setIsPanning(false); + }, []); const resetViewport = useCallback((options?: ViewportResetOptions) => { - - clearResetAnimation(); - // Same centered position the view opens with, not the raw (0,0) sentinel // — that would land on cover-fit's cropped axis pinned to its edge. - setTransform( - computeCenteredTransform( - viewportSizeRef.current, - mapSizeRef.current, - INITIAL_TRANSFORM.userScale, - fitModeRef.current - ) + const centred = computeCenteredTransform( + viewportSizeRef.current, + mapSizeRef.current, + INITIAL_TRANSFORM.userScale, + fitModeRef.current ); - - setIsPanning(false); - - panStateRef.current = null; - - const animated = options?.animated ?? true; - - if (animated) { - - setIsResetAnimating(true); - - resetTimerRef.current = setTimeout(() => { - - resetTimerRef.current = null; - - setIsResetAnimating(false); - - }, VIEWPORT_RESET_TRANSITION_MS); - + endGesture(); + if (options?.animated ?? true) { + animateTo(centred, VIEWPORT_RESET_TRANSITION, VIEWPORT_RESET_TRANSITION_MS); + } else { + clearTransition(); + setTransform(centred); } + }, [animateTo, clearTransition, endGesture]); - }, [clearResetAnimation]); - - - - const cancelPan = useCallback(() => { - - panStateRef.current = null; + const zoomBy = useCallback( + (factor: number) => { + const viewport = viewportSizeRef.current; + if (viewport.w <= 0 || viewport.h <= 0) return; + const current = transformRef.current; + animateTo( + zoomToScaleAtPoint( + viewport, + mapSizeRef.current, + current, + { x: viewport.w / 2, y: viewport.h / 2 }, + current.userScale * factor, + fitModeRef.current + ), + VIEWPORT_RESET_TRANSITION, + VIEWPORT_RESET_TRANSITION_MS + ); + }, + [animateTo] + ); - setIsPanning(false); + const focusMapRect = useCallback( + (rect: MapRect, inset?: MapFocusInset) => { + const viewport = viewportSizeRef.current; + if (viewport.w <= 0 || viewport.h <= 0) return; + animateTo( + transformForMapRect(viewport, mapSizeRef.current, rect, fitModeRef.current, { + fill: 0.55, + // A one-province realm should not fill the screen with four pixels. + maxUserScale: 5, + inset, + }), + VIEWPORT_FOCUS_TRANSITION, + VIEWPORT_FOCUS_TRANSITION_MS + ); + }, + [animateTo] + ); + const consumeDragClick = useCallback(() => { + const dragged = dragClickRef.current; + dragClickRef.current = false; + return dragged; }, []); - - useLayoutEffect(() => { - if (!enabled) return; - - - const element = viewportRef.current; - if (!element) return; - - - const syncViewportSize = (next: Size) => { - setViewportSize((current) => - current.w === next.w && current.h === next.h ? current : next - ); - }; - - - syncViewportSize(readViewportSize(element)); - - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - const { width, height } = entry.contentRect; - syncViewportSize({ w: width, h: height }); - }); - - - observer.observe(element); - return () => observer.disconnect(); - }, [enabled]); - - useEffect(() => { - if (!enabled || viewportSize.w <= 0 || viewportSize.h <= 0) return; - setTransform((current) => { // Only the untouched initial transform gets centered — once the user // has panned or zoomed, a later resize (window resize, sidebar @@ -391,7 +312,6 @@ export function useMapViewport({ current.userScale === INITIAL_TRANSFORM.userScale && current.translateX === INITIAL_TRANSFORM.translateX && current.translateY === INITIAL_TRANSFORM.translateY; - if (isUntouched) { return computeCenteredTransform( viewportSizeRef.current, @@ -400,16 +320,11 @@ export function useMapViewport({ fitModeRef.current ); } - return applyClampedTransform(current); }); - }, [applyClampedTransform, enabled, mapSize, viewportSize]); - - const previousFitModeRef = useRef(fitMode); - useEffect(() => { // Toggling fit mode is a deliberate "show it the other way" action, not a // resize — it always snaps to the new mode's centered view, even if the @@ -419,9 +334,7 @@ export function useMapViewport({ // centered view the toggle promises. if (previousFitModeRef.current === fitMode) return; previousFitModeRef.current = fitMode; - if (!enabled) return; - setTransform( computeCenteredTransform( viewportSizeRef.current, @@ -432,374 +345,335 @@ export function useMapViewport({ ); }, [enabled, fitMode]); - - + // Paint mode switching on mid-drag hands the left button to the paint layer + // at once rather than letting the pan finish under the brush. useEffect(() => { + if (dragPan) return; + const gesture = gestureRef.current; + if (gesture && (gesture.kind === "pinch" || gesture.pointerId !== -1)) { + endGesture(); + } + }, [dragPan, endGesture]); + useEffect(() => { if (!enabled) return; - - - const element = viewportRef.current; - if (!element) return; - - - const handleWheel = (event: WheelEvent) => { - - event.preventDefault(); - - clearResetAnimation(); - - - + const toViewportPoint = (event: { clientX: number; clientY: number }) => { const rect = element.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + }; - const cursor = { - - x: event.clientX - rect.left, - - y: event.clientY - rect.top, - + const startPan = (event: PointerEvent, pointerId: number, moved: boolean) => { + const current = transformRef.current; + gestureRef.current = { + kind: "pan", + pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startTranslateX: current.translateX, + startTranslateY: current.translateY, + moved, }; + if (moved) setIsPanning(true); + }; + const startPinch = () => { + const [a, b] = [...pointersRef.current.values()]; + if (!a || !b) return; + gestureRef.current = { + kind: "pinch", + start: pinchSample(a, b), + startTransform: transformRef.current, + }; + setIsPanning(true); + }; - + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + clearTransition(); + const cursor = toViewportPoint(event); setTransform((current) => - applyClampedTransform( - zoomAtPoint( - viewportSizeRef.current, - mapSizeRef.current, - current, - cursor, - event.deltaY, - fitModeRef.current - ) - ) - ); - }; + const handlePointerDown = (event: PointerEvent) => { + dragClickRef.current = false; + const isMouse = event.pointerType === "mouse"; + + // Middle-drag pans everywhere, paint mode and editor included. It is a + // pan from the first pixel: a middle press is never a click. + if (isMouse && event.button === 1) { + event.preventDefault(); + clearTransition(); + // -1 marks the middle-button pan so turning paint on does not end it. + startPan(event, -1, true); + return; + } + if (!dragPanRef.current) return; + if (isMouse && event.button !== 0) return; - const handleMouseDown = (event: MouseEvent) => { - - if (!isMiddleMouseButton(event)) return; - - event.preventDefault(); - - clearResetAnimation(); - - - - const current = transformRef.current; - - panStateRef.current = { - - startClientX: event.clientX, - - startClientY: event.clientY, - - startTranslateX: current.translateX, - - startTranslateY: current.translateY, - - }; - - setIsPanning(true); - + clearTransition(); + pointersRef.current.set(event.pointerId, toViewportPoint(event)); + if (!isMouse && pointersRef.current.size >= 2) { + startPinch(); + return; + } + startPan(event, event.pointerId, false); }; + const handlePointerMove = (event: PointerEvent) => { + if (pointersRef.current.has(event.pointerId)) { + pointersRef.current.set(event.pointerId, toViewportPoint(event)); + } + const gesture = gestureRef.current; + if (!gesture) return; + + if (gesture.kind === "pinch") { + const [a, b] = [...pointersRef.current.values()]; + if (!a || !b) return; + event.preventDefault(); + const sample = pinchSample(a, b); + const viewport = viewportSizeRef.current; + const map = mapSizeRef.current; + // Zoom about where the fingers started, then follow the midpoint so + // two fingers can pan and zoom in one movement. + const zoomed = zoomToScaleAtPoint( + viewport, + map, + gesture.startTransform, + gesture.start.midpoint, + pinchUserScale(gesture.startTransform.userScale, gesture.start, sample), + fitModeRef.current + ); + setTransform( + applyClampedTransform({ + userScale: zoomed.userScale, + translateX: zoomed.translateX + sample.midpoint.x - gesture.start.midpoint.x, + translateY: zoomed.translateY + sample.midpoint.y - gesture.start.midpoint.y, + }) + ); + return; + } - - const handleMouseMove = (event: MouseEvent) => { - - const panState = panStateRef.current; - - if (!panState) return; - - - + if (gesture.pointerId !== -1 && gesture.pointerId !== event.pointerId) return; + const deltaX = event.clientX - gesture.startClientX; + const deltaY = event.clientY - gesture.startClientY; + if (!gesture.moved) { + if (!exceedsDragThreshold(deltaX, deltaY)) return; + gesture.moved = true; + setIsPanning(true); + } event.preventDefault(); - - - - const deltaX = event.clientX - panState.startClientX; - - const deltaY = event.clientY - panState.startClientY; - - - setTransform( - applyClampedTransform({ - userScale: transformRef.current.userScale, - - translateX: panState.startTranslateX + deltaX, - - translateY: panState.startTranslateY + deltaY, - + translateX: gesture.startTranslateX + deltaX, + translateY: gesture.startTranslateY + deltaY, }) - ); - }; + const handlePointerUp = (event: PointerEvent) => { + const gesture = gestureRef.current; + pointersRef.current.delete(event.pointerId); + if (!gesture) return; + + if (gesture.kind === "pinch") { + // Lifting one finger of a pinch carries on as a one-finger pan from + // where the remaining finger is, rather than jumping. + const remaining = [...pointersRef.current.entries()][0]; + if (remaining && event.type === "pointerup") { + const [pointerId, point] = remaining; + const rect = element.getBoundingClientRect(); + const current = transformRef.current; + gestureRef.current = { + kind: "pan", + pointerId, + startClientX: point.x + rect.left, + startClientY: point.y + rect.top, + startTranslateX: current.translateX, + startTranslateY: current.translateY, + moved: true, + }; + } else { + endGesture(); + } + // The tap that ends a pinch must not select. + dragClickRef.current = true; + return; + } - - const endPanOnMouseUp = (event: MouseEvent) => { - - if (!panStateRef.current) return; - - if (!isMiddleMouseButton(event)) return; - - cancelPan(); - + if (gesture.pointerId === -1) { + if (event.pointerType === "mouse" && event.button !== 1) return; + } else if (gesture.pointerId !== event.pointerId) { + return; + } + if (gesture.moved) dragClickRef.current = true; + endGesture(); }; - - - const endPanOnLeave = () => { - - if (!panStateRef.current) return; - - cancelPan(); - + const endOnBlur = () => { + if (gestureRef.current) endGesture(); }; - - - const endPanOnBlur = () => { - - if (!panStateRef.current) return; - - cancelPan(); - - }; - - - const handleContextMenu = (event: MouseEvent) => { - - if (!panStateRef.current && event.button !== 1) return; - + if (!gestureRef.current && event.button !== 1) return; event.preventDefault(); - }; - - const handleAuxClick = (event: MouseEvent) => { - if (event.button !== 1) return; - event.preventDefault(); - }; - + const handleDoubleClick = (event: MouseEvent) => { + if (!dragPanRef.current) return; + const viewport = viewportSizeRef.current; + const current = transformRef.current; + animateTo( + zoomToScaleAtPoint( + viewport, + mapSizeRef.current, + current, + toViewportPoint(event), + current.userScale * MAP_ZOOM_STEP, + fitModeRef.current + ), + VIEWPORT_RESET_TRANSITION, + VIEWPORT_RESET_TRANSITION_MS + ); + }; element.addEventListener("wheel", handleWheel, { passive: false }); - - element.addEventListener("mousedown", handleMouseDown); - - element.addEventListener("mouseleave", endPanOnLeave); - + element.addEventListener("pointerdown", handlePointerDown); element.addEventListener("contextmenu", handleContextMenu); - element.addEventListener("auxclick", handleAuxClick); - - window.addEventListener("mousemove", handleMouseMove); - - window.addEventListener("mouseup", endPanOnMouseUp); - - window.addEventListener("blur", endPanOnBlur); - - - + element.addEventListener("dblclick", handleDoubleClick); + window.addEventListener("pointermove", handlePointerMove, { passive: false }); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + window.addEventListener("blur", endOnBlur); return () => { - element.removeEventListener("wheel", handleWheel); - - element.removeEventListener("mousedown", handleMouseDown); - - element.removeEventListener("mouseleave", endPanOnLeave); - + element.removeEventListener("pointerdown", handlePointerDown); element.removeEventListener("contextmenu", handleContextMenu); - element.removeEventListener("auxclick", handleAuxClick); - - window.removeEventListener("mousemove", handleMouseMove); - - window.removeEventListener("mouseup", endPanOnMouseUp); - - window.removeEventListener("blur", endPanOnBlur); - + element.removeEventListener("dblclick", handleDoubleClick); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + window.removeEventListener("blur", endOnBlur); }; - - }, [applyClampedTransform, cancelPan, clearResetAnimation, enabled]); - - + }, [animateTo, applyClampedTransform, clearTransition, enabled, endGesture]); useEffect(() => { + if (!enabled || !keyboard) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + if (isTypingTarget(event.target)) return; + const action = keyboardMapAction(event.key); + if (!action) return; + event.preventDefault(); + if (action.kind === "zoom") { + zoomBy(action.factor); + return; + } + const current = transformRef.current; + animateTo( + applyClampedTransform({ + userScale: current.userScale, + translateX: current.translateX + action.dx, + translateY: current.translateY + action.dy, + }), + VIEWPORT_RESET_TRANSITION, + VIEWPORT_RESET_TRANSITION_MS + ); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [animateTo, applyClampedTransform, enabled, keyboard, zoomBy]); + useEffect(() => { return () => { - - if (resetTimerRef.current !== null) { - - clearTimeout(resetTimerRef.current); - + if (transitionTimerRef.current !== null) { + clearTimeout(transitionTimerRef.current); } - }; - }, []); - - const screenToMapFromClient = useCallback( - (clientX: number, clientY: number) => { - const element = viewportRef.current; - const viewport = viewportSizeRef.current; - const map = mapSizeRef.current; - const current = transformRef.current; - const currentFitScale = computeFitScale(viewport, map, fitModeRef.current); - const currentDisplayScale = computeDisplayScale( - currentFitScale, - current.userScale - ); - - - if (!element || viewport.w <= 0 || viewport.h <= 0) return null; - - - const rect = element.getBoundingClientRect(); - const viewportX = clientX - rect.left; - const viewportY = clientY - rect.top; - - - if ( - viewportX < 0 || - viewportY < 0 || - viewportX > viewport.w || - viewportY > viewport.h - ) { - return null; - } - - - const point = screenToMap(viewportX, viewportY, currentDisplayScale, { - x: current.translateX, - y: current.translateY, - }); - - - if (point.x < 0 || point.y < 0 || point.x > map.w || point.y > map.h) { - return null; - } - - - return point; - }, - [] - ); - - const transformStyle = viewportTransformStyle( - displayScale, - transform.translateX, - transform.translateY - ); - - - const cursorClassName = isPanning ? "cursor-grabbing" : "cursor-grab"; - - const transformTransition = - - isResetAnimating && !isPanning ? VIEWPORT_RESET_TRANSITION : undefined; - - + const transformTransition = isPanning ? undefined : transition; return { - viewportRef, - userScale: transform.userScale, - translateX: transform.translateX, - translateY: transform.translateY, - displayScale, - fitScale, - isPanning, - transformStyle, - transformTransition, - cursorClassName, - resetViewport, - + zoomBy, + focusMapRect, + consumeDragClick, screenToMapFromClient, - }; - } - - diff --git a/frontend/app/hooks/useRegionHover.ts b/frontend/app/hooks/useRegionHover.ts index 900161d4..35d9e4a3 100644 --- a/frontend/app/hooks/useRegionHover.ts +++ b/frontend/app/hooks/useRegionHover.ts @@ -94,9 +94,9 @@ export function useRegionHover({ ? info.title : `${info.title} · ${info.tier}`; - const hintLines = ["Click to view"]; + const hintLines = ["Click for details"]; if (canDrillIntoRegion(regionId, regionData!, mapObjects)) { - hintLines.push("CTRL+Click to see subjects"); + hintLines.push("Ctrl-click to open subjects"); } setCursorTooltip({ diff --git a/frontend/app/internal/globals.css b/frontend/app/internal/globals.css index 08b926d2..2e2f400c 100644 --- a/frontend/app/internal/globals.css +++ b/frontend/app/internal/globals.css @@ -10,6 +10,14 @@ --tfmc-accent: #7a9e6a; --tfmc-header-h: 3.5rem; + /* Map chrome: CK3-style lacquer panels with gilt trim. */ + --tfmc-gilt: #c9a45c; + --tfmc-gilt-bright: #f0d58c; + --tfmc-gilt-dim: #7a6236; + --tfmc-parchment: #efe3c4; + --tfmc-lacquer: #111813; + --tfmc-lacquer-raised: #1b2620; + --background: var(--tfmc-forest-deep); --foreground: var(--tfmc-cream); } @@ -285,3 +293,223 @@ body { .char-toast-out { animation: char-toast-out 0.28s ease-in both; } + +/* ------------------------------------------------------------------------ + * Map chrome + * + * Every floating control on the world map shares one frame so the page reads + * as a single instrument: a dark lacquer panel, a thin gilt rule with an inner + * shadow line, and small corner brackets, after Crusader Kings III's windows. + * ---------------------------------------------------------------------- */ + +/* In the components layer so Tailwind utilities on the same element (absolute, + * border-0, rounded-none…) still win, as they would over any component class. */ +@layer components { + .map-frame { + position: relative; + border: 1px solid var(--tfmc-gilt-dim); + border-radius: 6px; + background: + radial-gradient(120% 80% at 50% 0%, rgb(255 236 190 / 0.05), transparent 60%), + linear-gradient(180deg, color-mix(in srgb, var(--tfmc-lacquer-raised) 96%, transparent), color-mix(in srgb, var(--tfmc-lacquer) 97%, transparent)); + box-shadow: + inset 0 0 0 1px rgb(0 0 0 / 0.55), + inset 0 0 0 2px rgb(201 164 92 / 0.14), + 0 12px 32px rgb(0 0 0 / 0.5), + 0 2px 6px rgb(0 0 0 / 0.4); + color: var(--tfmc-cream); + backdrop-filter: blur(6px); + } + + /* Corner brackets: two short gilt strokes meeting at each corner. */ + .map-frame::before { + content: ""; + pointer-events: none; + position: absolute; + inset: 3px; + border-radius: 4px; + --c: var(--tfmc-gilt); + background: + linear-gradient(var(--c), var(--c)) top left / 12px 1px, + linear-gradient(var(--c), var(--c)) top left / 1px 12px, + linear-gradient(var(--c), var(--c)) top right / 12px 1px, + linear-gradient(var(--c), var(--c)) top right / 1px 12px, + linear-gradient(var(--c), var(--c)) bottom left / 12px 1px, + linear-gradient(var(--c), var(--c)) bottom left / 1px 12px, + linear-gradient(var(--c), var(--c)) bottom right / 12px 1px, + linear-gradient(var(--c), var(--c)) bottom right / 1px 12px; + background-repeat: no-repeat; + opacity: 0.75; + } + + .map-frame-header { + position: relative; + background: + linear-gradient(180deg, rgb(240 213 140 / 0.1), rgb(240 213 140 / 0.02)), + var(--tfmc-lacquer-raised); + border-bottom: 1px solid var(--tfmc-gilt-dim); + box-shadow: 0 1px 0 rgb(0 0 0 / 0.6); + } + + /* A gilt rule with a lozenge in the middle, for section breaks. */ + .map-rule { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--tfmc-gilt); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + } + + .map-rule::before, + .map-rule::after { + content: ""; + height: 1px; + flex: 1; + background: linear-gradient(90deg, transparent, var(--tfmc-gilt-dim) 30%, var(--tfmc-gilt-dim) 70%, transparent); + } + + .map-tooltip { + border: 1px solid var(--tfmc-gilt-dim); + border-radius: 4px; + background: color-mix(in srgb, var(--tfmc-lacquer) 95%, transparent); + box-shadow: + inset 0 0 0 1px rgb(0 0 0 / 0.5), + 0 6px 18px rgb(0 0 0 / 0.55); + } + + /* Round medallion buttons for the map modes, like CK3's map-mode row. */ + .map-medallion { + position: relative; + display: inline-flex; + height: 2.6rem; + width: 2.6rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 9999px; + border: 1px solid var(--tfmc-gilt-dim); + background: radial-gradient(circle at 50% 30%, #2a3a31, #101712 75%); + color: var(--tfmc-stone); + box-shadow: + inset 0 0 0 2px rgb(0 0 0 / 0.55), + inset 0 1px 0 3px rgb(201 164 92 / 0.08), + 0 2px 4px rgb(0 0 0 / 0.5); + transition: + color 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease, + transform 120ms ease; + } + + .map-medallion:hover { + color: var(--tfmc-parchment); + border-color: var(--tfmc-gilt); + } + + .map-medallion:focus-visible, + .map-control:focus-visible { + outline: 2px solid var(--tfmc-gilt-bright); + outline-offset: 2px; + } + + .map-medallion[aria-pressed="true"] { + color: #1a1408; + border-color: var(--tfmc-gilt-bright); + background: radial-gradient(circle at 50% 30%, #f6e2a4, #c9a45c 60%, #8a6a32); + box-shadow: + inset 0 0 0 2px rgb(255 245 210 / 0.35), + 0 0 0 2px rgb(0 0 0 / 0.6), + 0 0 14px rgb(240 213 140 / 0.45); + } + + /* Square-ish controls: zoom, layers, panel buttons. */ + .map-control { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 1px solid var(--tfmc-gilt-dim); + border-radius: 4px; + background: linear-gradient(180deg, #223028, #121a15); + color: var(--tfmc-stone); + box-shadow: + inset 0 1px 0 rgb(255 236 190 / 0.06), + 0 2px 5px rgb(0 0 0 / 0.45); + transition: + color 120ms ease, + border-color 120ms ease, + background 120ms ease; + } + + .map-control:hover:not(:disabled) { + color: var(--tfmc-parchment); + border-color: var(--tfmc-gilt); + background: linear-gradient(180deg, #2b3c32, #16201a); + } + + .map-control[aria-pressed="true"], + .map-control[aria-expanded="true"] { + color: var(--tfmc-parchment); + border-color: var(--tfmc-gilt); + background: linear-gradient(180deg, #3a3220, #1d190f); + } + + .map-control:disabled { + opacity: 0.45; + cursor: default; + } + + /* The heraldic frame a realm's banner sits in. */ + .map-banner-frame { + border: 2px solid var(--tfmc-gilt); + border-radius: 3px 3px 40% 40% / 3px 3px 22% 22%; + background: #0b100d; + box-shadow: + 0 0 0 1px #000, + 0 0 0 3px rgb(201 164 92 / 0.25), + 0 6px 14px rgb(0 0 0 / 0.6); + overflow: hidden; + } +} + +@keyframes map-panel-in { + from { + opacity: 0; + transform: translateX(-0.75rem); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes map-sheet-in { + from { + transform: translateY(1.5rem); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Details rise as a sheet on a phone and slide in from the left on desktop. */ +.map-details-enter { + animation: map-sheet-in 200ms ease-out; +} + +@media (min-width: 48rem) { + .map-details-enter { + animation: map-panel-in 180ms ease-out; + } +} + +@media (prefers-reduced-motion: reduce) { + .map-details-enter { + animation: none; + } +} diff --git a/frontend/app/lib/map/chronicleDayRoute.test.ts b/frontend/app/lib/map/chronicleDayRoute.test.ts index 24da59c1..557372c8 100644 --- a/frontend/app/lib/map/chronicleDayRoute.test.ts +++ b/frontend/app/lib/map/chronicleDayRoute.test.ts @@ -74,7 +74,7 @@ describe("isValidChronicleDay", () => { describe("chronicle route hrefs", () => { it("maps the dev map id onto its public segment", () => { - expect(liveMapHref("main")).toBe("/map/main"); + expect(liveMapHref("main")).toBe("/map"); expect(liveMapHref("dev")).toBe("/map/r3b1rth"); expect(chronicleStudioHref("main")).toBe("/map/main/chronicle"); expect(chronicleStudioHref("dev")).toBe("/map/r3b1rth/chronicle"); diff --git a/frontend/app/lib/map/chronicleDayRoute.ts b/frontend/app/lib/map/chronicleDayRoute.ts index 4b663ca5..a337590f 100644 --- a/frontend/app/lib/map/chronicleDayRoute.ts +++ b/frontend/app/lib/map/chronicleDayRoute.ts @@ -54,14 +54,24 @@ export function mapRouteSegment(mapId: MapId): string { return mapId === "dev" ? DEV_PUBLIC_SEGMENT : mapId; } -/** `/map/main` or `/map/r3b1rth` — the live map. */ -export function liveMapHref(mapId: MapId): string { +/** + * The map's own path, `/map/{segment}`. Pages nested under a map (chronicle, + * staff console) build on this rather than on `liveMapHref`, because `main`'s + * live map lives at the bare `/map` and `/map/chronicle` would read as a map + * called "chronicle". + */ +export function mapBaseHref(mapId: MapId): string { return `/map/${mapRouteSegment(mapId)}`; } +/** `/map` for `main`, `/map/r3b1rth` or `/map/{archive}` — the live map. */ +export function liveMapHref(mapId: MapId): string { + return mapId === "main" ? "/map" : mapBaseHref(mapId); +} + /** `/map/{map}/chronicle` — the timelapse studio. */ export function chronicleStudioHref(mapId: MapId): string { - return `${liveMapHref(mapId)}/chronicle`; + return `${mapBaseHref(mapId)}/chronicle`; } /** diff --git a/frontend/app/lib/map/chronicleStaff.ts b/frontend/app/lib/map/chronicleStaff.ts index 87bbe694..5cd62566 100644 --- a/frontend/app/lib/map/chronicleStaff.ts +++ b/frontend/app/lib/map/chronicleStaff.ts @@ -1,4 +1,4 @@ -import { liveMapHref } from "./chronicleDayRoute"; +import { mapBaseHref } from "./chronicleDayRoute"; import type { MapId } from "@/app/components/map/types"; /** @@ -133,7 +133,7 @@ export function chronicleArchivePath(mapId: MapId): string { /** `/map/{map}/chronicle/staff` — the page these helpers back. */ export function chronicleStaffHref(mapId: MapId): string { - return `${liveMapHref(mapId)}/chronicle/staff`; + return `${mapBaseHref(mapId)}/chronicle/staff`; } // --------------------------------------------------------------------------- diff --git a/frontend/app/lib/map/mapSearch.test.ts b/frontend/app/lib/map/mapSearch.test.ts new file mode 100644 index 00000000..96cdc9d5 --- /dev/null +++ b/frontend/app/lib/map/mapSearch.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + buildMapSearchIndex, + normaliseSearchText, + searchMap, +} from "./mapSearch"; + +const regionData = { + Huoyaoguo: { name: "Huǒyàoguó", rgb: "200,80,80" }, + Fingers: { name: "Fingers", overlord: "Huoyaoguo" }, + The_Holy_Order: { name: "The Holy Order", rgb: "150,90,200" }, + Thalendor: { name: "§x§a§3§a§1§8§4Thalendor" }, +}; + +const index = buildMapSearchIndex({ + regionData, + tierLabel: "Nation", + settlements: [ + { + id: "s1", + name: "Cru' Sadin", + faction_id: "The_Holy_Order", + kind: "faction_capital", + map_x: 100, + map_y: 200, + }, + { id: "s2", name: "Unplaced Town" }, + ], + installations: [ + { id: "i1", name: "Holy Harbour", kind: "port", map_x: 5, map_y: 6 }, + ], +}); + +describe("normaliseSearchText", () => { + it("strips accents and case", () => { + expect(normaliseSearchText(" Huǒyàoguó ")).toBe("huoyaoguo"); + }); +}); + +describe("buildMapSearchIndex", () => { + it("indexes regions with clean names and their overlord", () => { + const fingers = index.find((entry) => entry.key === "region:Fingers"); + const thalendor = index.find((entry) => entry.key === "region:Thalendor"); + + expect(fingers?.detail).toBe("Subject of Huǒyàoguó"); + expect(thalendor?.label).toBe("Thalendor"); + expect(thalendor?.detail).toBe("Nation"); + }); + + it("indexes placed settlements and installations with their owner", () => { + expect(index.find((entry) => entry.key === "settlement:s1")).toMatchObject({ + kind: "place", + label: "Cru' Sadin", + detail: "Capital · The Holy Order", + mapX: 100, + mapY: 200, + }); + expect(index.find((entry) => entry.key === "installation:i1")?.detail).toBe("Port"); + }); + + it("skips places with no map position", () => { + expect(index.some((entry) => entry.label === "Unplaced Town")).toBe(false); + }); + + it("searches places alone when the mode has no regions", () => { + const placesOnly = buildMapSearchIndex({ + regionData: null, + tierLabel: "Terrain", + settlements: [], + installations: [{ id: "i1", name: "Port", kind: "port", map_x: 1, map_y: 1 }], + }); + + expect(placesOnly).toHaveLength(1); + }); +}); + +describe("searchMap", () => { + it("finds accented names from plain typing", () => { + expect(searchMap(index, "huoy")[0]?.label).toBe("Huǒyàoguó"); + }); + + it("ranks a prefix, then a word start, then a substring", () => { + const labels = searchMap(index, "ho").map((entry) => entry.label); + + expect(labels).toEqual(["Holy Harbour", "The Holy Order"]); + expect(searchMap(index, "sadin")[0]?.label).toBe("Cru' Sadin"); + expect(searchMap(index, "lendor")[0]?.label).toBe("Thalendor"); + }); + + it("prefers a region over a place on an equal match", () => { + const results = searchMap( + [ + { kind: "place", key: "p", label: "Rat Hill", detail: "", mapX: 0, mapY: 0 }, + { kind: "region", key: "r", regionId: "Rat_Hill", label: "Rat Hill", detail: "", rgb: null }, + ], + "rat" + ); + + expect(results[0]?.kind).toBe("region"); + }); + + it("returns nothing for an empty query and respects the limit", () => { + expect(searchMap(index, " ")).toEqual([]); + expect(searchMap(index, "o", 2)).toHaveLength(2); + }); +}); diff --git a/frontend/app/lib/map/mapSearch.ts b/frontend/app/lib/map/mapSearch.ts new file mode 100644 index 00000000..c8422a8e --- /dev/null +++ b/frontend/app/lib/map/mapSearch.ts @@ -0,0 +1,164 @@ +import type { + InstallationMarker, + RegionRecord, + SettlementMarker, +} from "@/app/components/map/types"; +import { cleanRegionName } from "@/app/lib/mapLabels"; + +export type MapSearchEntry = + | { + kind: "region"; + key: string; + regionId: string; + label: string; + detail: string; + rgb: string | null; + } + | { + kind: "place"; + key: string; + label: string; + detail: string; + mapX: number; + mapY: number; + }; + +const INSTALLATION_LABELS: Record = { + fort: "Fort", + port: "Port", + airport: "Airfield", + train_station: "Station", +}; + +const SETTLEMENT_LABELS: Record = { + faction_capital: "Capital", + guild_capital: "Guild seat", + settlement: "Settlement", +}; + +/** Lower-case, accents stripped: "Huǒyàoguó" is found by typing "huoyaoguo". */ +export function normaliseSearchText(value: string): string { + return value + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .trim(); +} + +function ownerName( + factionId: string | undefined, + regionData: RegionRecord | null +): string | null { + if (!factionId) return null; + const name = regionData?.[factionId]?.name; + return name ? cleanRegionName(name) : null; +} + +/** + * Everything the search box can find on the current map mode: its regions + * (nations, titles, trade areas) and the placed settlements and + * installations. Raster-only modes have no region data, so they search places + * alone. + */ +export function buildMapSearchIndex({ + regionData, + tierLabel, + settlements, + installations, +}: { + regionData: RegionRecord | null; + tierLabel: string; + settlements: SettlementMarker[]; + installations: InstallationMarker[]; +}): MapSearchEntry[] { + const entries: MapSearchEntry[] = []; + + for (const [regionId, region] of Object.entries(regionData ?? {})) { + const label = cleanRegionName(region.name ?? regionId); + if (!label) continue; + const overlord = region.overlord ? ownerName(region.overlord, regionData) : null; + entries.push({ + kind: "region", + key: `region:${regionId}`, + regionId, + label, + detail: overlord ? `Subject of ${overlord}` : region.tier ?? tierLabel, + rgb: region.rgb ?? null, + }); + } + + for (const settlement of settlements) { + if (typeof settlement.map_x !== "number" || typeof settlement.map_y !== "number") { + continue; + } + const label = cleanRegionName(settlement.name); + if (!label) continue; + const owner = ownerName(settlement.faction_id, regionData); + const kind = SETTLEMENT_LABELS[settlement.kind ?? "settlement"] ?? "Settlement"; + entries.push({ + kind: "place", + key: `settlement:${settlement.id}`, + label, + detail: owner ? `${kind} · ${owner}` : kind, + mapX: settlement.map_x, + mapY: settlement.map_y, + }); + } + + for (const installation of installations) { + if ( + typeof installation.map_x !== "number" || + typeof installation.map_y !== "number" + ) { + continue; + } + const label = cleanRegionName(installation.name); + if (!label) continue; + const owner = ownerName(installation.faction_id, regionData); + const kind = INSTALLATION_LABELS[installation.kind] ?? "Installation"; + entries.push({ + kind: "place", + key: `installation:${installation.id}`, + label, + detail: owner ? `${kind} · ${owner}` : kind, + mapX: installation.map_x, + mapY: installation.map_y, + }); + } + + return entries; +} + +/** + * Best matches first: the whole name starting with the query, then any word + * in it starting with the query, then the query anywhere. Ties go to regions + * over places and then to the shorter name, which is usually the one meant. + */ +export function searchMap( + entries: MapSearchEntry[], + query: string, + limit = 8 +): MapSearchEntry[] { + const needle = normaliseSearchText(query); + if (!needle) return []; + + const scored: { entry: MapSearchEntry; score: number }[] = []; + for (const entry of entries) { + const haystack = normaliseSearchText(entry.label); + let score: number; + if (haystack.startsWith(needle)) score = 0; + else if (haystack.split(/[\s'’\-_]+/).some((word) => word.startsWith(needle))) score = 1; + else if (haystack.includes(needle)) score = 2; + else continue; + scored.push({ entry, score }); + } + + scored.sort( + (a, b) => + a.score - b.score || + (a.entry.kind === b.entry.kind ? 0 : a.entry.kind === "region" ? -1 : 1) || + a.entry.label.length - b.entry.label.length || + a.entry.label.localeCompare(b.entry.label) + ); + return scored.slice(0, limit).map(({ entry }) => entry); +} diff --git a/frontend/app/lib/map/realmProfile.test.ts b/frontend/app/lib/map/realmProfile.test.ts new file mode 100644 index 00000000..f41752d7 --- /dev/null +++ b/frontend/app/lib/map/realmProfile.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import { + buildRealmProfile, + formatFoundedDate, + parseRealmRelation, + realmCapitalSettlement, + realmRankLabel, + relationKindLabel, + sortRealmRelations, +} from "./realmProfile"; + +describe("parseRealmRelation", () => { + it("reads kind, attitude and a negative score", () => { + expect(parseRealmRelation("Thalendor(none.unfriendly.-20)")).toEqual({ + id: "Thalendor", + kind: "none", + attitude: "unfriendly", + score: -20, + }); + }); + + it("keeps brackets inside the realm id", () => { + expect(parseRealmRelation("The (Old) Guard(ally.friendly.80)")).toEqual({ + id: "The (Old) Guard", + kind: "ally", + attitude: "friendly", + score: 80, + }); + }); + + it("drops malformed entries", () => { + expect(parseRealmRelation("Thalendor")).toBeNull(); + expect(parseRealmRelation("(ally.friendly.80)")).toBeNull(); + expect(parseRealmRelation("Thalendor(ally.friendly.80")).toBeNull(); + }); + + it("leaves a missing score as null", () => { + expect(parseRealmRelation("12(palatinate.friendly)")?.score).toBeNull(); + }); +}); + +describe("labels", () => { + it("names ranks without the faction suffix", () => { + expect(realmRankLabel("powerful_faction")).toBe("Powerful"); + expect(realmRankLabel("obscure_faction")).toBe("Obscure"); + expect(realmRankLabel("")).toBeNull(); + expect(realmRankLabel(undefined)).toBeNull(); + }); + + it("keeps SimpleFactions' relation kinds rather than inventing categories", () => { + expect(relationKindLabel("trade_agreement")).toBe("Trade agreement"); + expect(relationKindLabel("palatinate")).toBe("Palatinate"); + expect(relationKindLabel("mercantile")).toBe("Mercantile"); + expect(relationKindLabel("none")).toBeNull(); + expect(relationKindLabel(null)).toBeNull(); + }); + + it("formats the founding date in UTC", () => { + expect(formatFoundedDate(0)).toBe("1 Jan 1970"); + }); +}); + +describe("buildRealmProfile", () => { + const raw = { + id: "Rat_Hill", + name: "§x§a§3§a§1§8§4Rat Hill", + rgb: "90,200,120", + leader: "GingerBAR", + "ruler title": "Leader", + government: "Community", + culture: "Multicultural", + religion: "The Eye", + rank: "influential_faction", + capital: 212, + "founded at": 1791002672, + banner: "90_200_120", + provinces: [1, 2, 3], + size: 5, + subject_size: 2, + subjects: ["Dyonine", "The_New_Waters_Port"], + relations: ["Sunsora(none.friendly.50)", "broken"], + guilds: [{ balance: 1234, members: ["someone"] }], + "military queue": ["x"], + }; + + it("reads the allowlisted facts", () => { + const profile = buildRealmProfile("Rat_Hill", raw); + + expect(profile).toMatchObject({ + id: "Rat_Hill", + name: "Rat Hill", + rank: "Influential", + rulerTitle: "Leader", + leader: "GingerBAR", + government: "Community", + culture: "Multicultural", + religion: "The Eye", + capitalProvince: 212, + foundedAt: 1791002672, + provinces: 3, + realmSize: 5, + overlordId: null, + subjects: ["Dyonine", "The_New_Waters_Port"], + }); + expect(profile.relations).toHaveLength(1); + }); + + it("never carries ledger, member or queue data", () => { + const profile = buildRealmProfile("Rat_Hill", raw); + const serialised = JSON.stringify(profile); + + expect(serialised).not.toContain("balance"); + expect(serialised).not.toContain("someone"); + expect(serialised).not.toContain("1234"); + expect(Object.keys(profile)).not.toContain("guilds"); + }); + + it("treats -1 as no capital and tolerates missing fields", () => { + const profile = buildRealmProfile("Masao", { capital: -1, overlord: "Sunsora" }); + + expect(profile.capitalProvince).toBeNull(); + expect(profile.foundedAt).toBeNull(); + expect(profile.name).toBe("Masao"); + expect(profile.overlordId).toBe("Sunsora"); + expect(profile.realmSize).toBe(0); + }); +}); + +describe("realmCapitalSettlement", () => { + const settlements = [ + { id: "a", name: "Market Town", province_id: 212, kind: "settlement" as const }, + { + id: "b", + name: "Cru' Sadin", + province_id: 212, + kind: "faction_capital" as const, + faction_id: "Rat_Hill", + }, + ]; + + it("prefers the realm's own faction capital", () => { + expect( + realmCapitalSettlement({ id: "Rat_Hill", capitalProvince: 212 }, settlements)?.name + ).toBe("Cru' Sadin"); + }); + + it("falls back to any settlement in the capital province", () => { + expect( + realmCapitalSettlement({ id: "Other", capitalProvince: 212 }, settlements)?.name + ).toBe("Market Town"); + }); + + it("has nothing for a realm without a capital", () => { + expect( + realmCapitalSettlement({ id: "Rat_Hill", capitalProvince: null }, settlements) + ).toBeNull(); + }); +}); + +describe("sortRealmRelations", () => { + it("puts formal ties first, then hostility, then strength", () => { + const sorted = sortRealmRelations([ + { id: "a", kind: "none", attitude: "friendly", score: 50 }, + { id: "b", kind: "none", attitude: "hostile", score: -6 }, + { id: "c", kind: "ally", attitude: "friendly", score: 80 }, + { id: "d", kind: "none", attitude: "friendly", score: 70 }, + ]); + + expect(sorted.map((relation) => relation.id)).toEqual(["c", "b", "d", "a"]); + }); +}); diff --git a/frontend/app/lib/map/realmProfile.ts b/frontend/app/lib/map/realmProfile.ts new file mode 100644 index 00000000..bb6039ea --- /dev/null +++ b/frontend/app/lib/map/realmProfile.ts @@ -0,0 +1,189 @@ +import type { SettlementMarker } from "@/app/components/map/types"; +import { cleanRegionName } from "@/app/lib/mapLabels"; + +/** + * What the map's realm panel shows about a nation, and nothing more. + * + * `/data/nation` carries far more than a public map should put in front of a + * reader: guild ledgers and balances, member lists, military and + * installation queues, election state. They are reachable through the API, + * but that is no reason to give them prominence. This is the allowlist: every + * field the panel renders is read here, by name, and anything not named here + * never reaches the page. + */ +export type RealmProfile = { + id: string; + name: string; + rgb: string | null; + banner: string | null; + /** "Obscure", "Influential", "Powerful", "Glorious", or null if unranked. */ + rank: string | null; + rulerTitle: string | null; + leader: string | null; + government: string | null; + culture: string | null; + religion: string | null; + /** Province id of the capital; SimpleFactions writes -1 for none. */ + capitalProvince: number | null; + /** Unix seconds. */ + foundedAt: number | null; + /** Provinces held directly. */ + provinces: number; + /** Provinces including subjects'. */ + realmSize: number; + overlordId: string | null; + subjects: string[]; + relations: RealmRelation[]; +}; + +export type RelationAttitude = "friendly" | "neutral" | "unfriendly" | "hostile"; + +export type RealmRelation = { + /** The other realm's region id. */ + id: string; + /** Relation kind exactly as SimpleFactions names it, e.g. "palatinate". */ + kind: string | null; + attitude: RelationAttitude | string | null; + score: number | null; +}; + +type RawRealm = Record; + +function text(value: unknown): string | null { + if (typeof value !== "string") return null; + const cleaned = cleanRegionName(value); + return cleaned ? cleaned : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +/** `"powerful_faction"` → `"Powerful"`. */ +export function realmRankLabel(rank: unknown): string | null { + if (typeof rank !== "string" || !rank.trim()) return null; + const word = rank.trim().replace(/_faction$/i, "").replace(/_/g, " "); + return word ? word[0].toUpperCase() + word.slice(1).toLowerCase() : null; +} + +/** `"trade_agreement"` → `"Trade agreement"`; `"none"` → null. */ +export function relationKindLabel(kind: string | null): string | null { + if (!kind || kind === "none") return null; + const words = kind.replace(/_/g, " ").trim(); + return words ? words[0].toUpperCase() + words.slice(1) : null; +} + +/** + * Parses one `relations` entry, `Name(kind.attitude.score)`, e.g. + * `Thalendor(none.unfriendly.-20)`. The realm id is everything before the + * *last* opening bracket, so an id that itself contains brackets survives. + * Anything malformed is dropped rather than shown half-read. + */ +export function parseRealmRelation(raw: string): RealmRelation | null { + const open = raw.lastIndexOf("("); + if (open <= 0 || !raw.endsWith(")")) return null; + const id = raw.slice(0, open).trim(); + const [kind, attitude, ...scoreParts] = raw.slice(open + 1, -1).split("."); + if (!id || kind === undefined) return null; + const score = Number(scoreParts.join(".")); + return { + id, + kind: kind || null, + attitude: attitude || null, + score: scoreParts.length && Number.isFinite(score) ? score : null, + }; +} + +export function buildRealmProfile(id: string, raw: RawRealm): RealmProfile { + const capital = finiteNumber(raw.capital); + const foundedAt = finiteNumber(raw["founded at"]); + const provinces = Array.isArray(raw.provinces) ? raw.provinces.length : 0; + const size = finiteNumber(raw.size); + const subjectSize = finiteNumber(raw.subject_size) ?? 0; + const overlord = typeof raw.overlord === "string" && raw.overlord ? raw.overlord : null; + + return { + id, + name: text(raw.name) ?? id, + rgb: typeof raw.rgb === "string" ? raw.rgb : null, + banner: typeof raw.banner === "string" && raw.banner ? raw.banner : null, + rank: realmRankLabel(raw.rank), + rulerTitle: text(raw["ruler title"]), + leader: text(raw.leader), + government: text(raw.government), + culture: text(raw.culture), + religion: text(raw.religion), + capitalProvince: capital !== null && capital >= 0 ? capital : null, + foundedAt: foundedAt !== null && foundedAt > 0 ? foundedAt : null, + provinces, + realmSize: size ?? provinces + subjectSize, + overlordId: overlord, + subjects: stringList(raw.subjects), + relations: stringList(raw.relations) + .map(parseRealmRelation) + .filter((relation): relation is RealmRelation => relation !== null), + }; +} + +/** + * The capital's settlement, if SimpleFactions has named one. A faction capital + * owned by this realm wins; failing that, any settlement in the capital + * province. + */ +export function realmCapitalSettlement( + profile: Pick, + settlements: SettlementMarker[] +): SettlementMarker | null { + if (profile.capitalProvince === null) return null; + const inCapital = settlements.filter( + (settlement) => settlement.province_id === profile.capitalProvince + ); + return ( + inCapital.find( + (settlement) => + settlement.kind === "faction_capital" && settlement.faction_id === profile.id + ) ?? + inCapital[0] ?? + null + ); +} + +/** `1791002672` → `"3 Oct 2026"`, in UTC so server and client agree. */ +export function formatFoundedDate(seconds: number): string { + return new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "UTC", + }).format(new Date(seconds * 1000)); +} + +const ATTITUDE_ORDER: Record = { + hostile: 0, + unfriendly: 1, + friendly: 2, + neutral: 3, +}; + +/** + * Relations worth reading first: formal ties (alliance, overlord, subject, + * trade) before plain attitudes, then hostility before friendship, then by + * strength of feeling. + */ +export function sortRealmRelations(relations: RealmRelation[]): RealmRelation[] { + return [...relations].sort((a, b) => { + const formalA = relationKindLabel(a.kind) ? 0 : 1; + const formalB = relationKindLabel(b.kind) ? 0 : 1; + if (formalA !== formalB) return formalA - formalB; + const attitudeA = ATTITUDE_ORDER[a.attitude ?? ""] ?? 4; + const attitudeB = ATTITUDE_ORDER[b.attitude ?? ""] ?? 4; + if (attitudeA !== attitudeB) return attitudeA - attitudeB; + return Math.abs(b.score ?? 0) - Math.abs(a.score ?? 0); + }); +} diff --git a/frontend/app/lib/mapGestures.test.ts b/frontend/app/lib/mapGestures.test.ts new file mode 100644 index 00000000..575c463d --- /dev/null +++ b/frontend/app/lib/mapGestures.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + DRAG_THRESHOLD_PX, + KEYBOARD_PAN_PX, + MAP_ZOOM_STEP, + exceedsDragThreshold, + isTypingTarget, + keyboardMapAction, + pinchSample, + pinchUserScale, +} from "./mapGestures"; + +describe("exceedsDragThreshold", () => { + it("treats a small wobble as a click", () => { + expect(exceedsDragThreshold(2, 2)).toBe(false); + expect(exceedsDragThreshold(DRAG_THRESHOLD_PX, 0)).toBe(false); + }); + + it("treats a longer move as a drag", () => { + expect(exceedsDragThreshold(DRAG_THRESHOLD_PX + 1, 0)).toBe(true); + expect(exceedsDragThreshold(-4, -4)).toBe(true); + }); +}); + +describe("pinch", () => { + it("samples the distance and midpoint of two fingers", () => { + expect(pinchSample({ x: 0, y: 0 }, { x: 30, y: 40 })).toEqual({ + distance: 50, + midpoint: { x: 15, y: 20 }, + }); + }); + + it("scales by how far the fingers spread", () => { + const start = pinchSample({ x: 0, y: 0 }, { x: 100, y: 0 }); + const spread = pinchSample({ x: 0, y: 0 }, { x: 250, y: 0 }); + + expect(pinchUserScale(2, start, spread)).toBeCloseTo(5); + }); + + it("keeps the start scale for a degenerate pinch", () => { + const start = pinchSample({ x: 10, y: 10 }, { x: 10, y: 10 }); + const later = pinchSample({ x: 0, y: 0 }, { x: 50, y: 0 }); + + expect(pinchUserScale(3, start, later)).toBe(3); + }); +}); + +describe("isTypingTarget", () => { + it("recognises text fields", () => { + expect(isTypingTarget({ tagName: "INPUT" } as HTMLElement)).toBe(true); + expect(isTypingTarget({ tagName: "TEXTAREA" } as HTMLElement)).toBe(true); + expect( + isTypingTarget({ tagName: "DIV", isContentEditable: true } as HTMLElement) + ).toBe(true); + }); + + it("leaves the map and buttons alone", () => { + expect(isTypingTarget(null)).toBe(false); + expect(isTypingTarget({ tagName: "BUTTON" } as HTMLElement)).toBe(false); + expect(isTypingTarget({} as EventTarget)).toBe(false); + }); +}); + +describe("keyboardMapAction", () => { + it("moves the map opposite to the arrow, like dragging the view", () => { + expect(keyboardMapAction("ArrowLeft")).toEqual({ + kind: "pan", + dx: KEYBOARD_PAN_PX, + dy: 0, + }); + expect(keyboardMapAction("ArrowDown")).toEqual({ + kind: "pan", + dx: 0, + dy: -KEYBOARD_PAN_PX, + }); + }); + + it("zooms with plus and minus", () => { + expect(keyboardMapAction("+")).toEqual({ kind: "zoom", factor: MAP_ZOOM_STEP }); + expect(keyboardMapAction("=")).toEqual({ kind: "zoom", factor: MAP_ZOOM_STEP }); + expect(keyboardMapAction("-")).toEqual({ + kind: "zoom", + factor: 1 / MAP_ZOOM_STEP, + }); + }); + + it("ignores other keys", () => { + expect(keyboardMapAction("a")).toBeNull(); + expect(keyboardMapAction("Enter")).toBeNull(); + }); +}); diff --git a/frontend/app/lib/mapGestures.ts b/frontend/app/lib/mapGestures.ts new file mode 100644 index 00000000..22cfd5f6 --- /dev/null +++ b/frontend/app/lib/mapGestures.ts @@ -0,0 +1,91 @@ +import type { ViewportPoint } from "./mapViewportMath"; + +/** + * How far (screen px) a press may wander before it counts as a drag. Below + * this a left press is still a click, so a slightly shaky hand selecting a + * nation does not nudge the map instead. + */ +export const DRAG_THRESHOLD_PX = 4; + +/** Zoom step for the +/- buttons, the keyboard and double-click. */ +export const MAP_ZOOM_STEP = 1.6; + +/** Pixels the arrow keys move the map per press. */ +export const KEYBOARD_PAN_PX = 120; + +export function exceedsDragThreshold(dx: number, dy: number): boolean { + return dx * dx + dy * dy > DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX; +} + +export type PinchSample = { + distance: number; + midpoint: ViewportPoint; +}; + +/** Distance and midpoint of two touch points, both in viewport pixels. */ +export function pinchSample(a: ViewportPoint, b: ViewportPoint): PinchSample { + return { + distance: Math.hypot(b.x - a.x, b.y - a.y), + midpoint: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, + }; +} + +/** + * The user scale a pinch has reached: the scale at pinch start, times how far + * the fingers have spread since. A degenerate start (fingers on the same + * pixel) keeps the start scale rather than dividing by zero. + */ +export function pinchUserScale( + startUserScale: number, + start: PinchSample, + current: PinchSample +): number { + if (start.distance <= 0) return startUserScale; + return startUserScale * (current.distance / start.distance); +} + +/** + * Whether a key press belongs to a text field rather than the map. Map + * shortcuts must never steal arrows or +/- from the search box, the paint + * text editor, or any other input on the page. + */ +export function isTypingTarget(target: EventTarget | null): boolean { + if (!target || typeof (target as HTMLElement).tagName !== "string") { + return false; + } + const element = target as HTMLElement; + const tag = element.tagName; + return ( + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" || + element.isContentEditable === true + ); +} + +export type KeyboardMapAction = + | { kind: "pan"; dx: number; dy: number } + | { kind: "zoom"; factor: number } + | null; + +/** Arrow keys pan, +/- zoom; anything else is not the map's business. */ +export function keyboardMapAction(key: string): KeyboardMapAction { + switch (key) { + case "ArrowLeft": + return { kind: "pan", dx: KEYBOARD_PAN_PX, dy: 0 }; + case "ArrowRight": + return { kind: "pan", dx: -KEYBOARD_PAN_PX, dy: 0 }; + case "ArrowUp": + return { kind: "pan", dx: 0, dy: KEYBOARD_PAN_PX }; + case "ArrowDown": + return { kind: "pan", dx: 0, dy: -KEYBOARD_PAN_PX }; + case "+": + case "=": + return { kind: "zoom", factor: MAP_ZOOM_STEP }; + case "-": + case "_": + return { kind: "zoom", factor: 1 / MAP_ZOOM_STEP }; + default: + return null; + } +} diff --git a/frontend/app/lib/mapViewportMath.test.ts b/frontend/app/lib/mapViewportMath.test.ts index f699829c..61bc8f60 100644 --- a/frontend/app/lib/mapViewportMath.test.ts +++ b/frontend/app/lib/mapViewportMath.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + MAP_ZOOM_MAX, MAP_ZOOM_WHEEL_FACTOR, clampTranslate, computeCenteredTransform, @@ -9,7 +10,9 @@ import { mapToScreen, screenToMap, viewportTransformStyle, + transformForMapRect, zoomAtPoint, + zoomToScaleAtPoint, } from "./mapViewportMath"; const viewport = { w: 1000, h: 1000 }; @@ -74,9 +77,9 @@ describe("clampTranslate", () => { ); }); - it("cannot pan past edges at max zoom 4.5", () => { + it("cannot pan past edges at max zoom", () => { const fitScale = computeFitScale(viewport, map); - const displayScale = computeDisplayScale(fitScale, 4.5); + const displayScale = computeDisplayScale(fitScale, MAP_ZOOM_MAX); const minX = viewport.w - map.w * displayScale; expect(clampTranslate(viewport, map, displayScale, minX - 25, 0)).toEqual( @@ -233,3 +236,86 @@ describe("viewportTransformStyle", () => { ); }); }); + +describe("zoomToScaleAtPoint", () => { + it("keeps the anchored map point under the anchor", () => { + const start = { userScale: 1, translateX: 0, translateY: 0 }; + const anchor = { x: 250, y: 400 }; + const before = screenToMap(anchor.x, anchor.y, 0.5, { x: 0, y: 0 }); + + const next = zoomToScaleAtPoint(viewport, map, start, anchor, 2); + const after = screenToMap( + anchor.x, + anchor.y, + computeDisplayScale(0.5, next.userScale), + { x: next.translateX, y: next.translateY } + ); + + expect(next.userScale).toBe(2); + expect(after.x).toBeCloseTo(before.x); + expect(after.y).toBeCloseTo(before.y); + }); + + it("clamps to the zoom range", () => { + const start = { userScale: 1, translateX: 0, translateY: 0 }; + const centre = { x: 500, y: 500 }; + + expect(zoomToScaleAtPoint(viewport, map, start, centre, 99).userScale).toBe( + MAP_ZOOM_MAX + ); + expect(zoomToScaleAtPoint(viewport, map, start, centre, 0.1).userScale).toBe(1); + }); + + it("ignores a non-finite scale", () => { + const start = { userScale: 2, translateX: -100, translateY: -50 }; + + expect( + zoomToScaleAtPoint(viewport, map, start, { x: 0, y: 0 }, Number.NaN) + ).toBe(start); + }); +}); + +describe("transformForMapRect", () => { + it("centres the rect and zooms until it fills the requested share", () => { + const rect = { x: 900, y: 900, w: 200, h: 100 }; + const next = transformForMapRect(viewport, map, rect, "cover", { fill: 0.5 }); + const displayScale = computeDisplayScale(0.5, next.userScale); + + // 200 map px * 0.5 fit * 5 = 500 screen px = half the viewport width. + expect(next.userScale).toBe(5); + const centre = mapToScreen(1000, 950, displayScale, { + x: next.translateX, + y: next.translateY, + }); + expect(centre.x).toBeCloseTo(500); + expect(centre.y).toBeCloseTo(500); + }); + + it("frames the rect in the space left beside an inset panel", () => { + const rect = { x: 900, y: 900, w: 200, h: 200 }; + const next = transformForMapRect(viewport, map, rect, "cover", { + fill: 0.5, + inset: { left: 400 }, + }); + const displayScale = computeDisplayScale(0.5, next.userScale); + const centre = mapToScreen(1000, 1000, displayScale, { + x: next.translateX, + y: next.translateY, + }); + + expect(centre.x).toBeCloseTo(700); + expect(centre.y).toBeCloseTo(500); + }); + + it("never zooms past the cap for a tiny rect", () => { + const next = transformForMapRect( + viewport, + map, + { x: 1000, y: 1000, w: 1, h: 1 }, + "cover", + { maxUserScale: 4 } + ); + + expect(next.userScale).toBe(4); + }); +}); diff --git a/frontend/app/lib/mapViewportMath.ts b/frontend/app/lib/mapViewportMath.ts index e66c2a2e..aaff8c05 100644 --- a/frontend/app/lib/mapViewportMath.ts +++ b/frontend/app/lib/mapViewportMath.ts @@ -1,5 +1,10 @@ export const MAP_ZOOM_MIN = 1; -export const MAP_ZOOM_MAX = 4.5; +/** + * Deep enough that settlement names and province borders are readable on a + * phone: at 8x a contain-fit 6400 px map shows roughly one map pixel per + * screen pixel on a 900 px tall viewport. + */ +export const MAP_ZOOM_MAX = 8; export const MAP_ZOOM_WHEEL_FACTOR = 1.1; export type Size = { @@ -169,26 +174,46 @@ export function zoomAtPoint( return transform; } + const zoomFactor = + wheelDelta < 0 ? MAP_ZOOM_WHEEL_FACTOR : 1 / MAP_ZOOM_WHEEL_FACTOR; + return zoomToScaleAtPoint( + viewport, + map, + transform, + cursor, + transform.userScale * zoomFactor, + mode + ); +} + +/** + * Zoom to `userScale` while keeping the map point under `anchor` (viewport + * pixels) fixed on screen. Shared by the wheel, the zoom buttons, double-click + * and pinch, which differ only in where the anchor is and how far they go. + */ +export function zoomToScaleAtPoint( + viewport: Size, + map: Size, + transform: ViewportTransform, + anchor: ViewportPoint, + userScale: number, + mode: FitMode = "cover" +): ViewportTransform { + if (!Number.isFinite(userScale)) return transform; + const fitScale = computeFitScale(viewport, map, mode); const displayScale = computeDisplayScale(fitScale, transform.userScale); const translate = { x: transform.translateX, y: transform.translateY }; + const mapPoint = screenToMap(anchor.x, anchor.y, displayScale, translate); - const mapPoint = screenToMap(cursor.x, cursor.y, displayScale, translate); - - const zoomFactor = - wheelDelta < 0 ? MAP_ZOOM_WHEEL_FACTOR : 1 / MAP_ZOOM_WHEEL_FACTOR; - const nextUserScale = clampUserScale(transform.userScale * zoomFactor); + const nextUserScale = clampUserScale(userScale); const nextDisplayScale = computeDisplayScale(fitScale, nextUserScale); - - let nextTranslateX = cursor.x - mapPoint.x * nextDisplayScale; - let nextTranslateY = cursor.y - mapPoint.y * nextDisplayScale; - const clamped = clampTranslate( viewport, map, nextDisplayScale, - nextTranslateX, - nextTranslateY + anchor.x - mapPoint.x * nextDisplayScale, + anchor.y - mapPoint.y * nextDisplayScale ); return { @@ -197,3 +222,61 @@ export function zoomAtPoint( translateY: clamped.y, }; } + +/** A rectangle in map pixels, e.g. a region's overlay crop box. */ +export type MapRect = { x: number; y: number; w: number; h: number }; + +/** + * The transform that centres `rect` and zooms until it fills `fill` of the + * viewport on its tighter axis. `inset` shifts the target centre away from + * screen furniture (a side panel on the left, a bottom sheet below), so the + * framed region lands in the part of the map the reader can actually see. + */ +export function transformForMapRect( + viewport: Size, + map: Size, + rect: MapRect, + mode: FitMode = "cover", + options: { + fill?: number; + maxUserScale?: number; + inset?: { left?: number; right?: number; top?: number; bottom?: number }; + } = {} +): ViewportTransform { + const fill = options.fill ?? 0.6; + const inset = options.inset ?? {}; + const left = inset.left ?? 0; + const right = inset.right ?? 0; + const top = inset.top ?? 0; + const bottom = inset.bottom ?? 0; + const usableW = Math.max(1, viewport.w - left - right); + const usableH = Math.max(1, viewport.h - top - bottom); + + const fitScale = computeFitScale(viewport, map, mode); + const rectW = Math.max(1, rect.w); + const rectH = Math.max(1, rect.h); + const wanted = Math.min( + (usableW * fill) / (rectW * fitScale), + (usableH * fill) / (rectH * fitScale) + ); + const userScale = clampUserScale( + Math.min(wanted, options.maxUserScale ?? MAP_ZOOM_MAX) + ); + const displayScale = computeDisplayScale(fitScale, userScale); + + const centreX = left + usableW / 2; + const centreY = top + usableH / 2; + const clamped = clampTranslate( + viewport, + map, + displayScale, + centreX - (rect.x + rect.w / 2) * displayScale, + centreY - (rect.y + rect.h / 2) * displayScale + ); + + return { + userScale, + translateX: clamped.x, + translateY: clamped.y, + }; +} diff --git a/frontend/app/map/page.tsx b/frontend/app/map/page.tsx new file mode 100644 index 00000000..49bda37b --- /dev/null +++ b/frontend/app/map/page.tsx @@ -0,0 +1,11 @@ +import { MapEngineProvider } from "../core/MapEngineContext"; +import MapViewer from "../components/MapViewer"; + +/** The live `main` map. `/map/main` redirects here (see `next.config.ts`). */ +export default function Page() { + return ( + + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 3c1b4488..6d9b4b3f 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -34,7 +34,7 @@ export default function HubPage() {

    Map diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 7a6a74c7..f91b051c 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -22,6 +22,11 @@ const nextConfig: NextConfig = { typescript: { tsconfigPath: "tsconfig.build.json", }, + // The live main map moved to the bare `/map`. Exact path only: the main + // map's chronicle and staff pages stay under `/map/main/...`. + async redirects() { + return [{ source: "/map/main", destination: "/map", permanent: true }]; + }, }; export default nextConfig; From 2e625afb31f5d6a2dd978ace75b81eee46feed13 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:03:56 +0000 Subject: [PATCH 02/56] Keep the map on screen and in place when switching modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mode change only swaps the overlays now. The map stays mounted after its first load instead of falling back to "Loading map…", the camera keeps its zoom and position, and a small note shows while the mode loads. Overlays are keyed by mode so nothing carries over between them. Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/app/components/MapViewer.tsx | 32 +++++++++++++++---- frontend/app/components/map/MapCanvas.tsx | 15 +++++++-- .../app/components/map/shell/MapShell.tsx | 12 +++++++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/frontend/app/components/MapViewer.tsx b/frontend/app/components/MapViewer.tsx index d40d2d90..6a31f89c 100644 --- a/frontend/app/components/MapViewer.tsx +++ b/frontend/app/components/MapViewer.tsx @@ -431,6 +431,18 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { const mapCanvasMounted = !loading && geometryReady; + /** + * Once a map has been shown it stays on screen. Switching mode only swaps + * its overlays: `useMapModeData` empties the region data while the next + * mode loads, so nothing stale is drawn meanwhile, and the camera keeps its + * place. Only the first load of a map waits behind "Loading map…". + */ + const [mapShownFor, setMapShownFor] = useState(null); + useEffect(() => { + if (mapCanvasMounted) setMapShownFor(mapId); + }, [mapCanvasMounted, mapId]); + const mapShown = mapCanvasMounted || mapShownFor === mapId; + // Pick pixels are read from the hidden canvas inside MapCanvas. That node // does not exist until loading/geometry finish, so this effect must wait // for mapCanvasMounted or it draws once into a null ref and never retries. @@ -643,6 +655,11 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { if (mode === mapType) return; resetMapObjects(); setSelectedId(null); + // The map stays mounted across modes, so hover from the old mode has to + // be cleared here rather than by a remount. + setHoveredOverlay(null); + setHoveredFortZoc(null); + setCursorTooltip(null); setMapType(mode); } @@ -951,12 +968,14 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { ); } - // Do not render the map until region data is in hand. Mounting MapCanvas - // early (to start the base-map download sooner) meant the region overlays - // rendered before regionData settled, and a failed overlay request is made - // permanent by MapCanvas's onError handler setting display:none — borders - // then stay invisible until something forces a remount. - if (!mapCanvasMounted) { + // Do not render the map until region data is first in hand. Mounting + // MapCanvas early (to start the base-map download sooner) meant the region + // overlays rendered before regionData settled, and a failed overlay request + // is made permanent by MapCanvas's onError handler setting display:none — + // borders then stay invisible until something forces a remount. Later mode + // changes are safe: overlays are keyed by mode, and the list is empty until + // the new mode's data lands. + if (!mapShown) { return (

    @@ -1096,6 +1115,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { } details={details} detailsKey={selectedId} + status={loading ? `Loading ${mapModeLabel(mapType).toLowerCase()}…` : null} zoomControls={zoomControls} layers={} layersMobile={ diff --git a/frontend/app/components/map/MapCanvas.tsx b/frontend/app/components/map/MapCanvas.tsx index cb3622ca..a65a16fd 100644 --- a/frontend/app/components/map/MapCanvas.tsx +++ b/frontend/app/components/map/MapCanvas.tsx @@ -328,9 +328,14 @@ export default function MapCanvas({ setMapSize({ w: mapFallbackSize(mapId), h: mapFallbackSize(mapId) }); }, [mapId]); + // A different map opens on its whole-world view. A mode change is only a + // new overlay on the same map, so the camera stays where the reader left it. + const previousMapIdRef = useRef(mapId); useEffect(() => { - viewport.resetViewport({ animated: true }); - }, [mapId, mapType, viewport.resetViewport]); + if (previousMapIdRef.current === mapId) return; + previousMapIdRef.current = mapId; + viewport.resetViewport({ animated: false }); + }, [mapId, viewport.resetViewport]); /** * The four modes drawn as a full-map raster over the base map rather than as @@ -426,7 +431,11 @@ export default function MapCanvas({ .filter((obj) => obj.visible) .map((obj) => ( ) : null} + {status ? ( +

    + {status} +
    + ) : null} + {paintPanel ? (