diff --git a/backend/server.py b/backend/server.py index 45ab9130..dc95e30d 100644 --- a/backend/server.py +++ b/backend/server.py @@ -125,6 +125,7 @@ def ping(): from src.api.claim_routes import claim_router from src.api.regen_routes import regen_router from src.api.file_routes import file_router +from src.api.tile_routes import tile_router from src.api.maps_routes import maps_router from src.api.profile_routes import profile_router from src.api.skins_routes import skins_router @@ -150,6 +151,7 @@ def ping(): app.include_router(ledger_router) app.include_router(regen_router) app.include_router(file_router) +app.include_router(tile_router) app.include_router(skins_router) app.include_router(profile_router) app.include_router(characters_router) diff --git a/backend/src/api/file_routes.py b/backend/src/api/file_routes.py index 3c05510b..6d206ab6 100644 --- a/backend/src/api/file_routes.py +++ b/backend/src/api/file_routes.py @@ -1,10 +1,11 @@ -from fastapi import APIRouter, Header, HTTPException +from fastapi import APIRouter, Header, HTTPException, Query from pathlib import Path from .http_headers import conditional_file_response from .map_access import ensure_map_access from .path_safety import is_safe_segment, resolve_within from .webp_cache import webp_variant +from .tile_cache import MAX_LOD, MAX_PICK_SCALE, lod_variant, pick_variant from ..scripts.util import dirs from ..scripts.util.dirs import ( map_image, @@ -52,17 +53,13 @@ def _image_response( return response -@file_router.get("/{map_name}/mapdata/{map_type}") -async def get_map_file( - map_name: str, - map_type: str, - authorization: str | None = Header(default=None), - if_none_match: str | None = Header(default=None), - if_modified_since: str | None = Header(default=None), -): - map_name = ensure_map_access(map_name, authorization).id +def resolve_mapdata_path(map_name: str, map_type: str) -> Path | None: + """The full-map raster behind a map mode, or None if there is none. + + `map_name` must already have passed the access check. + """ if not is_safe_segment(map_type): - raise HTTPException(status_code=404, detail="Map not found") + return None # The province mode paints the source pick map itself, so there is # nothing to generate and nothing for a regen to keep in sync. @@ -84,7 +81,30 @@ async def get_map_file( file_path = resolve_within(root, file_path) if file_path is None or not file_path.is_file(): + return None + return file_path + + +@file_router.get("/{map_name}/mapdata/{map_type}") +async def get_map_file( + map_name: str, + map_type: str, + authorization: str | None = Header(default=None), + if_none_match: str | None = Header(default=None), + if_modified_since: str | None = Header(default=None), + scale: int = Query(default=0, ge=0, le=MAX_PICK_SCALE), +): + map_name = ensure_map_access(map_name, authorization).id + file_path = resolve_mapdata_path(map_name, map_type) + if file_path is None: raise HTTPException(status_code=404, detail="Map not found") + # Phones ask for a smaller copy: the full-size pick canvas is more than + # iOS Safari will hold (see tile_cache.pick_variant). Called directly + # (not through FastAPI) the parameter is still its Query default. + if isinstance(scale, int) and scale > 0: + file_path = pick_variant(file_path, scale) + if file_path is None: + raise HTTPException(status_code=404, detail="Map not found") # Deliberately NOT routed through webp_variant: this is the pick map. The # client draws it to an offscreen canvas and reads exact RGB values back to @@ -106,6 +126,7 @@ async def get_region_file( accept: str | None = Header(default=None), if_none_match: str | None = Header(default=None), if_modified_since: str | None = Header(default=None), + lod: int = Query(default=0, ge=0, le=MAX_LOD), ): map_name = ensure_map_access(map_name, authorization).id # Ensure .png extension @@ -120,6 +141,18 @@ async def get_region_file( if file_path is None or not file_path.is_file(): raise HTTPException(status_code=404, detail="Region overlay not found") + # A zoomed-out client asks for a reduced copy rather than decoding the + # full crop only to draw it a few pixels across. + if isinstance(lod, int) and lod > 0: + reduced = lod_variant(file_path, lod) + if reduced is not None: + return conditional_file_response( + reduced, + media_type="image/webp", + if_none_match=if_none_match, + if_modified_since=if_modified_since, + ) + return _image_response(file_path, accept, if_none_match, if_modified_since) diff --git a/backend/src/api/map_registry.py b/backend/src/api/map_registry.py index 8e9c2816..0b1af18f 100644 --- a/backend/src/api/map_registry.py +++ b/backend/src/api/map_registry.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any @@ -11,6 +11,13 @@ _CONFIG_DIR = Path(__file__).resolve().parent.parent / "config" _DEFAULT_REGISTRY_PATH = _CONFIG_DIR / "maps.yml" +# A site's own registry, in its data volume. Lets one deployment differ from +# the repo's (the dev site shows the Dev server's map at /map) without an +# environment variable, which the compose files on the host do not allow. +_SITE_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "data" / "maps.yml" + +# The map `/map` shows when no entry says `live: true`. +DEFAULT_LIVE_MAP_ID = "main" _registry_cache: dict[str, MapEntry] | None = None @@ -23,6 +30,8 @@ class MapEntry: realm_id: str staff_permission: str | None = None archived: bool = False + #: The map this site's `/map` shows. See `live_map_id`. + live: bool = False def to_public_dict(self) -> dict[str, Any]: return { @@ -30,6 +39,7 @@ def to_public_dict(self) -> dict[str, Any]: "display_name": self.display_name, "public": self.public, "archived": self.archived, + "live": self.live, } @@ -41,6 +51,8 @@ def _registry_path() -> Path: override = os.environ.get("MAP_REGISTRY_PATH", "").strip() if override: return Path(override) + if _SITE_REGISTRY_PATH.is_file(): + return _SITE_REGISTRY_PATH return _DEFAULT_REGISTRY_PATH @@ -88,6 +100,12 @@ def _parse_entry(raw: dict[str, Any]) -> MapEntry: if not isinstance(archived, bool): raise MapRegistryError(f"Map '{map_id}' requires boolean archived") + live = raw.get("live", False) + if not isinstance(live, bool): + raise MapRegistryError(f"Map '{map_id}' requires boolean live") + if live and archived: + raise MapRegistryError(f"Map '{map_id}' cannot be both live and archived") + return MapEntry( id=map_id, public=public, @@ -95,6 +113,7 @@ def _parse_entry(raw: dict[str, Any]) -> MapEntry: realm_id=realm_id, staff_permission=staff_permission, archived=archived, + live=live, ) @@ -125,10 +144,27 @@ def load_map_registry(*, force: bool = False) -> dict[str, MapEntry]: raise MapRegistryError(f"Duplicate map id '{entry.id}'") entries[entry.id] = entry + live = [entry.id for entry in entries.values() if entry.live] + if len(live) > 1: + raise MapRegistryError(f"Only one map can be live, found: {', '.join(live)}") + if not live and DEFAULT_LIVE_MAP_ID in entries: + # Registries written before `live` existed: `main` is the live map. + default = entries[DEFAULT_LIVE_MAP_ID] + if not default.archived: + entries[DEFAULT_LIVE_MAP_ID] = replace(default, live=True) + _registry_cache = entries return entries +def live_map_id() -> str: + """The map this site's `/map` shows.""" + for entry in load_map_registry().values(): + if entry.live: + return entry.id + return DEFAULT_LIVE_MAP_ID + + def get_map_entry(map_id: str) -> MapEntry | None: normalized = _normalize_map_id(map_id) if not normalized: diff --git a/backend/src/api/region_composite.py b/backend/src/api/region_composite.py new file mode 100644 index 00000000..2a9013de --- /dev/null +++ b/backend/src/api/region_composite.py @@ -0,0 +1,229 @@ +"""One full-map image of a mode's region overlays, as the viewer first shows it. + +A region mode (realms, counties, duchies, ...) is drawn from one cropped PNG +per region. Switching to a mode meant fetching and decoding every one of them — +85 for counties — before the map had colour, while the labels, which come with +the region data, were already on screen. + +This flattens the overlays the viewer shows by default into a single image so +it can be tiled like the base map (`tile_cache`): a mode switch then costs the +handful of tiles on screen. The default view is every region with an `rgb`, no +`overlord` (subjects open only on drill-down) and, on the realm map, at least +one province — the same rules as the frontend's `filterMapModeRegions` and +`initialMapObjectVisibility`. Drilled-in layouts still use the separate PNGs. + +The composite is versioned by the mtimes of its inputs: the mode's defines +JSON, which says who is visible, and the overlay sidecar, which regen rewrites +whenever it redraws a region. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import threading +from pathlib import Path + +from ..scripts.util.dirs import defines_file, input_file, region_image, region_overlay_file +from ..scripts.util.overlay_metadata import load_overlay_metadata +from . import tile_cache + +_COMPOSITE_DIR = Path(__file__).resolve().parent.parent / "output" / "_derived" / "composite" + +# Bump when the way a composite is drawn changes, so existing ones rebuild. +_COMPOSITE_FORMAT = 2 + +_building: set[str] = set() +_building_lock = threading.Lock() + + +def _mtime_ns(path: str | os.PathLike[str]) -> int | None: + try: + return os.stat(path).st_mtime_ns + except OSError: + return None + + +def _composite_dir(map_name: str, mode: str) -> Path: + key = hashlib.sha1(f"{map_name}/{mode}".encode("utf-8"), usedforsecurity=False).hexdigest() + return _COMPOSITE_DIR / key + + +def composite_path(map_name: str, mode: str) -> Path: + """The mode's composite. One fixed path, so its tile pyramid replaces the + previous version in place (see `tile_cache.build_pyramid`).""" + return _composite_dir(map_name, mode) / "composite.png" + + +def _load_regions(map_name: str, mode: str) -> dict | None: + try: + with open(defines_file(map_name, f"{mode}.json"), encoding="utf-8") as f: + regions = json.load(f) + except (OSError, ValueError): + return None + return regions if isinstance(regions, dict) else None + + +def composite_version(map_name: str, mode: str) -> str | None: + """Identity of what the composite would contain, or None with no inputs. + + Deliberately not the defines file's mtime: on a live map SimpleFactions + rewrites it every few minutes with new wealth figures while who is visible + stays the same. Only the visible set and the overlay images (regen rewrites + the sidecar whenever it redraws them) change the picture. + """ + overlays_mtime = _mtime_ns(region_overlay_file(map_name, mode)) + base_mtime = _mtime_ns(input_file(map_name, "map.png")) + if overlays_mtime is None or base_mtime is None: + return None + regions = _load_regions(map_name, mode) + if regions is None: + return None + visible = sorted(default_visible_regions(mode, regions)) + raw = json.dumps([_COMPOSITE_FORMAT, visible, overlays_mtime, base_mtime]) + return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest() + + +def _built_version(map_name: str, mode: str) -> str | None: + try: + return (_composite_dir(map_name, mode) / "composite.version").read_text( + encoding="utf-8" + ).strip() + except OSError: + return None + + +def default_visible_regions(mode: str, regions: dict) -> list[str]: + """`rgb` strings of the regions the viewer shows before any drill-down.""" + visible = [] + for region in regions.values(): + if not isinstance(region, dict): + continue + rgb = region.get("rgb") + if not isinstance(rgb, str): + continue + if region.get("overlord"): + continue + if mode == "nation": + provinces = region.get("provinces") + if not isinstance(provinces, list) or not provinces: + continue + visible.append(rgb) + return visible + + +def build_composite(map_name: str, mode: str) -> Path | None: + from PIL import Image + + version = composite_version(map_name, mode) + if version is None: + return None + target = composite_path(map_name, mode) + if target.is_file() and _built_version(map_name, mode) == version: + return target + + regions = _load_regions(map_name, mode) or {} + boxes = load_overlay_metadata(map_name, mode) + + with Image.open(input_file(map_name, "map.png")) as base: + size = base.size + canvas = Image.new("RGBA", size, (0, 0, 0, 0)) + + for rgb in default_visible_regions(mode, regions): + # Sidecar entries are `{"overlay": {x, y, w, h}, "overlay_nested": ...}`, + # the fields the data route merges onto each region. + box = (boxes.get(rgb) or {}).get("overlay") + if not isinstance(box, dict): + continue + png = region_image(map_name, mode, f"{rgb.replace(',', '_')}.png") + try: + with Image.open(png) as overlay: + layer = overlay.convert("RGBA") + except OSError: + continue + x, y = int(box.get("x", 0)), int(box.get("y", 0)) + # A box starting off the map's top or left edge keeps its pixels in + # place: cut off the part beyond the edge rather than shifting it in. + left, top = max(0, -x), max(0, -y) + if left >= layer.width or top >= layer.height: + continue + canvas.alpha_composite(layer, dest=(max(0, x), max(0, y)), source=(left, top)) + + target.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=target.parent, suffix=".tmp") + os.close(fd) + tmp = Path(tmp_name) + try: + # Fast, low compression: this file is read once, by the tiler. + canvas.save(tmp, "PNG", compress_level=1) + os.chmod(tmp, 0o644) + os.replace(tmp, target) + except BaseException: + tmp.unlink(missing_ok=True) + raise + (target.parent / "composite.version").write_text(version, encoding="utf-8") + return target + + +def _build_in_background(map_name: str, mode: str) -> None: + key = f"{map_name}/{mode}" + with _building_lock: + if key in _building: + return + _building.add(key) + + def run() -> None: + try: + with tile_cache.BUILD_LOCK: + composite = build_composite(map_name, mode) + if composite is not None: + tile_cache.build_pyramid(composite) + except Exception as exc: # pragma: no cover - background best effort + print(f"[tiles] composite build failed for {key}: {exc}") + finally: + with _building_lock: + _building.discard(key) + + threading.Thread(target=run, name="region-composite", daemon=True).start() + + +def ready_composite(map_name: str, mode: str, *, background: bool = True) -> Path | None: + """The composite, once its current version and tile pyramid are built. + + None while either is building (a build is started), and None without + starting anything when the mode has no inputs. + """ + version = composite_version(map_name, mode) + if version is None: + return None + target = composite_path(map_name, mode) + if ( + target.is_file() + and _built_version(map_name, mode) == version + and tile_cache.existing_manifest(target) is not None + ): + return target + if background: + _build_in_background(map_name, mode) + return None + with tile_cache.BUILD_LOCK: + built = build_composite(map_name, mode) + if built is not None: + tile_cache.build_pyramid(built) + return built + + +def latest_manifest(map_name: str, mode: str) -> dict | None: + """The composite's current tile pyramid or, while a newer one is being + built (`ready_composite` starts it), the last one finished. None only when + no composite of this mode has been tiled yet.""" + target = composite_path(map_name, mode) + if ready_composite(map_name, mode) is not None: + return tile_cache.existing_manifest(target) + return tile_cache.latest_manifest(target) + + +def has_inputs(map_name: str, mode: str) -> bool: + return composite_version(map_name, mode) is not None diff --git a/backend/src/api/test_map_registry_live.py b/backend/src/api/test_map_registry_live.py new file mode 100644 index 00000000..1dba5590 --- /dev/null +++ b/backend/src/api/test_map_registry_live.py @@ -0,0 +1,117 @@ +"""Which map a site's /map shows, and the per-site registry that sets it.""" + +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +_BACKEND_ROOT = Path(__file__).resolve().parents[2] +_BACKEND_SRC = _BACKEND_ROOT / "src" +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) +if str(_BACKEND_SRC) not in sys.path: + sys.path.insert(0, str(_BACKEND_SRC)) + +from src.api import map_registry # noqa: E402 + +REPO = """ +maps: + - id: main + public: true + display_name: Vardera + - id: dev + public: false + staff_permission: tfmc.map.staff + display_name: Dev +""" + +SITE = """ +maps: + - id: main + public: true + display_name: Vardera + - id: dev + public: true + live: true + display_name: Vardera (Dev) +""" + + +class LiveMapTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.repo = root / "config" / "maps.yml" + self.site = root / "data" / "maps.yml" + self.repo.parent.mkdir() + self.site.parent.mkdir() + self.repo.write_text(REPO, encoding="utf-8") + for name, value in ( + ("_DEFAULT_REGISTRY_PATH", self.repo), + ("_SITE_REGISTRY_PATH", self.site), + ): + patcher = patch.object(map_registry, name, value) + patcher.start() + self.addCleanup(patcher.stop) + self._env = os.environ.pop("MAP_REGISTRY_PATH", None) + self.addCleanup(self._restore_env) + map_registry.clear_map_registry_cache() + self.addCleanup(map_registry.clear_map_registry_cache) + + def _restore_env(self) -> None: + if self._env is not None: + os.environ["MAP_REGISTRY_PATH"] = self._env + + def _reload(self) -> dict: + map_registry.clear_map_registry_cache() + return map_registry.load_map_registry() + + def test_main_is_live_when_nothing_says_otherwise(self) -> None: + entries = self._reload() + self.assertEqual(map_registry.live_map_id(), "main") + self.assertTrue(entries["main"].to_public_dict()["live"]) + self.assertFalse(entries["dev"].to_public_dict()["live"]) + + def test_site_registry_overrides_the_repo(self) -> None: + self.site.write_text(SITE, encoding="utf-8") + entries = self._reload() + self.assertEqual(map_registry.live_map_id(), "dev") + self.assertTrue(entries["dev"].public) + self.assertFalse(entries["main"].live) + + def test_environment_override_still_wins(self) -> None: + self.site.write_text(SITE, encoding="utf-8") + os.environ["MAP_REGISTRY_PATH"] = str(self.repo) + self.addCleanup(os.environ.pop, "MAP_REGISTRY_PATH", None) + self._reload() + self.assertEqual(map_registry.live_map_id(), "main") + + def test_only_one_live_map(self) -> None: + self.site.write_text(SITE.replace("display_name: Vardera\n", "display_name: Vardera\n live: true\n", 1), encoding="utf-8") + with self.assertRaises(map_registry.MapRegistryError): + self._reload() + + def test_an_archived_map_cannot_be_live(self) -> None: + self.site.write_text( + "maps:\n - id: old\n public: true\n archived: true\n live: true\n", + encoding="utf-8", + ) + with self.assertRaises(map_registry.MapRegistryError): + self._reload() + + def test_live_must_be_boolean(self) -> None: + self.site.write_text( + "maps:\n - id: main\n public: true\n live: yes please\n", + encoding="utf-8", + ) + with self.assertRaises(map_registry.MapRegistryError): + self._reload() + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/src/api/test_region_composite.py b/backend/src/api/test_region_composite.py new file mode 100644 index 00000000..c6811d31 --- /dev/null +++ b/backend/src/api/test_region_composite.py @@ -0,0 +1,228 @@ +"""Flattened region overlays for tiling.""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +_BACKEND_ROOT = Path(__file__).resolve().parents[2] +_BACKEND_SRC = _BACKEND_ROOT / "src" +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) +if str(_BACKEND_SRC) not in sys.path: + sys.path.insert(0, str(_BACKEND_SRC)) + +os.environ.setdefault("SKINS_DEV", "1") + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from PIL import Image # noqa: E402 + +from src.api import region_composite, tile_cache # noqa: E402 +from src.api.map_registry import clear_map_registry_cache # noqa: E402 +from src.api.tile_routes import tile_router # noqa: E402 +from src.scripts.util import dirs # noqa: E402 + +TEST_REGISTRY = """ +maps: + - id: main + public: true + display_name: Adavaar + realm_id: main +""" + +RED = (200, 0, 0, 255) +BLUE = (0, 0, 200, 255) +GREEN = (0, 200, 0, 255) + + +class RegionCompositeTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + + registry = root / "maps.yml" + registry.write_text(TEST_REGISTRY, encoding="utf-8") + self._orig_registry = os.environ.get("MAP_REGISTRY_PATH") + os.environ["MAP_REGISTRY_PATH"] = str(registry) + clear_map_registry_cache() + self.addCleanup(self._restore_registry) + + originals = { + name: getattr(dirs, name) for name in ("INPUT_DIR", "DEFINES_DIR", "OUTPUT_DIR") + } + self.addCleanup(lambda: [setattr(dirs, k, v) for k, v in originals.items()]) + dirs.INPUT_DIR = str(root / "input") + dirs.DEFINES_DIR = str(root / "defines") + dirs.OUTPUT_DIR = str(root / "output") + patch_tiles = patch.object(tile_cache, "_CACHE_DIR", root / "tiles") + patch_comp = patch.object(region_composite, "_COMPOSITE_DIR", root / "composite") + for patcher in (patch_tiles, patch_comp): + patcher.start() + self.addCleanup(patcher.stop) + + (root / "input" / "main").mkdir(parents=True) + Image.new("RGB", (100, 100), (0, 0, 0)).save(root / "input" / "main" / "map.png") + self.defines = root / "defines" / "main" + self.defines.mkdir(parents=True) + self.regions_dir = root / "output" / "main" / "regions" / "nation" + self.regions_dir.mkdir(parents=True) + + # Three realms: two independent, one a subject of the first. + for rgb, colour in (("1,1,1", RED), ("2,2,2", BLUE), ("3,3,3", GREEN)): + Image.new("RGBA", (20, 20), colour).save( + self.regions_dir / f"{rgb.replace(',', '_')}.png" + ) + (self.regions_dir / "overlays.json").write_text( + json.dumps( + # The real sidecar shape: the box sits under "overlay". + { + "1,1,1": {"overlay": {"x": 0, "y": 0, "w": 20, "h": 20}}, + "2,2,2": {"overlay": {"x": 50, "y": 50, "w": 20, "h": 20}}, + "3,3,3": {"overlay": {"x": 10, "y": 60, "w": 20, "h": 20}}, + } + ), + encoding="utf-8", + ) + self._write_nations(balance=1) + + def _restore_registry(self) -> None: + if self._orig_registry is None: + os.environ.pop("MAP_REGISTRY_PATH", None) + else: + os.environ["MAP_REGISTRY_PATH"] = self._orig_registry + clear_map_registry_cache() + + def _write_nations(self, balance: int, subject_free: bool = False) -> None: + nations = { + "A": {"rgb": "1,1,1", "provinces": [1], "balance": balance}, + "B": {"rgb": "2,2,2", "provinces": [2]}, + "C": { + "rgb": "3,3,3", + "provinces": [3], + "overlord": None if subject_free else "A", + }, + "Landless": {"rgb": "9,9,9", "provinces": []}, + } + (self.defines / "nation.json").write_text(json.dumps(nations), encoding="utf-8") + + def test_only_default_visible_regions(self) -> None: + visible = region_composite.default_visible_regions( + "nation", json.loads((self.defines / "nation.json").read_text()) + ) + self.assertEqual(sorted(visible), ["1,1,1", "2,2,2"]) + + # Off the realm map, a region needs no provinces to be drawn. + self.assertIn( + "9,9,9", + region_composite.default_visible_regions( + "county", {"x": {"rgb": "9,9,9", "provinces": []}} + ), + ) + + def test_composite_places_overlays_and_skips_subjects(self) -> None: + path = region_composite.build_composite("main", "nation") + assert path is not None + with Image.open(path) as image: + self.assertEqual(image.size, (100, 100)) + self.assertEqual(image.getpixel((5, 5)), RED) + self.assertEqual(image.getpixel((55, 55)), BLUE) + # The subject's square is not drawn: transparent. + self.assertEqual(image.getpixel((15, 65))[3], 0) + + def test_version_ignores_figures_but_not_visibility(self) -> None: + first = region_composite.composite_version("main", "nation") + self._write_nations(balance=999) + self.assertEqual(region_composite.composite_version("main", "nation"), first) + + self._write_nations(balance=1, subject_free=True) + self.assertNotEqual(region_composite.composite_version("main", "nation"), first) + + def test_rebuilds_only_when_version_changes(self) -> None: + path = region_composite.ready_composite("main", "nation", background=False) + assert path is not None + mtime = path.stat().st_mtime_ns + self._write_nations(balance=5) + self.assertEqual(region_composite.ready_composite("main", "nation"), path) + self.assertEqual(path.stat().st_mtime_ns, mtime) + + def test_no_inputs(self) -> None: + self.assertFalse(region_composite.has_inputs("main", "duchy")) + self.assertIsNone(region_composite.ready_composite("main", "duchy")) + + def test_routes(self) -> None: + app = FastAPI() + app.include_router(tile_router) + client = TestClient(app) + self.addCleanup(client.close) + + with patch.object(region_composite, "_build_in_background") as background: + pending = client.get("/main/tiles/regions-nation/manifest") + self.assertEqual(pending.status_code, 202) + background.assert_called_once() + + region_composite.ready_composite("main", "nation", background=False) + ready = client.get("/main/tiles/regions-nation/manifest") + self.assertEqual(ready.status_code, 200) + manifest = ready.json() + tile = client.get(f"/main/tiles/regions-nation/{manifest['version']}/0/0/0.webp") + self.assertEqual(tile.status_code, 200) + + self.assertEqual(client.get("/main/tiles/regions-duchy/manifest").status_code, 404) + self.assertEqual(client.get("/main/tiles/regions-bogus/manifest").status_code, 404) + + def test_overlay_off_the_left_edge_is_clipped_not_shifted(self) -> None: + # B's 20 px box now starts 5 px beyond the left edge, at y 40. + boxes = json.loads((self.regions_dir / "overlays.json").read_text(encoding="utf-8")) + boxes["2,2,2"]["overlay"] = {"x": -5, "y": 40, "w": 20, "h": 20} + (self.regions_dir / "overlays.json").write_text(json.dumps(boxes), encoding="utf-8") + composite = region_composite.build_composite("main", "nation") + assert composite is not None + with Image.open(composite) as image: + self.assertEqual(image.getpixel((14, 45)), BLUE) # B's right edge stays at x 14 + self.assertEqual(image.getpixel((15, 45))[3], 0) # not shifted 5 px right + + def test_route_serves_the_last_composite_while_the_next_builds(self) -> None: + app = FastAPI() + app.include_router(tile_router) + client = TestClient(app) + self.addCleanup(client.close) + + region_composite.ready_composite("main", "nation", background=False) + first = client.get("/main/tiles/regions-nation/manifest").json() + + # C gains its independence: a new picture to build. + self._write_nations(balance=1, subject_free=True) + with patch.object(region_composite, "_build_in_background") as background: + stale = client.get("/main/tiles/regions-nation/manifest") + self.assertEqual(stale.status_code, 200) + self.assertEqual(stale.json()["version"], first["version"]) + background.assert_called_once() + tile = client.get(f"/main/tiles/regions-nation/{first['version']}/0/0/0.webp") + self.assertEqual(tile.status_code, 200) + + def test_regeneration_warm_up_queues_out_of_date_layers(self) -> None: + from src.api import tile_warm + + maps = Path(dirs.OUTPUT_DIR) / "main" / "maps" + maps.mkdir(parents=True) + Image.new("RGB", (100, 100), (9, 9, 9)).save(maps / "prosperity_map.png") + + with patch.object(tile_cache, "_build_in_background") as rasters, patch.object( + region_composite, "_build_in_background" + ) as composites: + tile_warm.warm_map_tiles("main") + self.assertEqual( + [call.args[0].name for call in rasters.call_args_list], ["prosperity_map.png"] + ) + composites.assert_called_once_with("main", "nation") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/src/api/test_tile_cache.py b/backend/src/api/test_tile_cache.py new file mode 100644 index 00000000..269d1363 --- /dev/null +++ b/backend/src/api/test_tile_cache.py @@ -0,0 +1,215 @@ +"""Tile pyramids and reduced overlay copies.""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +_BACKEND_ROOT = Path(__file__).resolve().parents[2] +_BACKEND_SRC = _BACKEND_ROOT / "src" +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) +if str(_BACKEND_SRC) not in sys.path: + sys.path.insert(0, str(_BACKEND_SRC)) + +from PIL import Image + +from src.api import tile_cache + + +class MaxLevelTest(unittest.TestCase): + def test_single_tile_image_is_level_zero(self) -> None: + self.assertEqual(tile_cache.max_level_for(200, 100), 0) + self.assertEqual(tile_cache.max_level_for(256, 256), 0) + + def test_doubles_per_level(self) -> None: + self.assertEqual(tile_cache.max_level_for(257, 10), 1) + self.assertEqual(tile_cache.max_level_for(512, 512), 1) + # The live map: 6400 / 256 = 25 tiles across -> five halvings to one. + self.assertEqual(tile_cache.max_level_for(6400, 6400), 5) + + +class PyramidTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.tmp = Path(self._tmp.name) + patcher = patch.object(tile_cache, "_CACHE_DIR", self.tmp / "tiles") + patcher.start() + self.addCleanup(patcher.stop) + self.source = self.tmp / "map.png" + Image.new("RGB", (600, 300), (10, 120, 40)).save(self.source, "PNG") + + def test_builds_every_level_with_edge_tiles(self) -> None: + manifest = tile_cache.build_pyramid(self.source) + + assert manifest is not None + self.assertEqual(manifest["max_level"], 2) + self.assertEqual(manifest["width"], 600) + self.assertEqual( + manifest["levels"], + [ + {"width": 150, "height": 75}, + {"width": 300, "height": 150}, + {"width": 600, "height": 300}, + ], + ) + version = manifest["version"] + # Level 2 is full size: 3 x 2 tiles, the last column and row cropped. + edge = tile_cache.tile_file(self.source, version, 2, 2, 1) + assert edge is not None + with Image.open(edge) as tile: + self.assertEqual(tile.size, (600 - 512, 300 - 256)) + self.assertIsNone(tile_cache.tile_file(self.source, version, 2, 3, 0)) + self.assertIsNotNone(tile_cache.tile_file(self.source, version, 0, 0, 0)) + + def test_keeps_transparency(self) -> None: + Image.new("RGBA", (300, 300), (255, 0, 0, 0)).save(self.source, "PNG") + manifest = tile_cache.build_pyramid(self.source) + + assert manifest is not None + tile = tile_cache.tile_file(self.source, manifest["version"], 1, 0, 0) + assert tile is not None + with Image.open(tile) as image: + self.assertIn("A", image.getbands()) + + def test_not_ready_until_built_then_ready(self) -> None: + with patch.object(tile_cache, "_build_in_background") as background: + self.assertIsNone(tile_cache.ready_manifest(self.source)) + background.assert_called_once() + + built = tile_cache.ready_manifest(self.source, background=False) + assert built is not None + self.assertEqual(tile_cache.ready_manifest(self.source), built) + + def test_regenerated_source_invalidates_old_version(self) -> None: + old = tile_cache.build_pyramid(self.source) + assert old is not None + + Image.new("RGB", (600, 300), (200, 200, 0)).save(self.source, "PNG") + stat = self.source.stat() + os.utime(self.source, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10_000_000)) + + # The old version keeps serving its own pixels until the rebuild... + self.assertIsNotNone(tile_cache.tile_file(self.source, old["version"], 0, 0, 0)) + self.assertIsNone(tile_cache.existing_manifest(self.source)) + new = tile_cache.build_pyramid(self.source) + assert new is not None + self.assertNotEqual(new["version"], old["version"]) + # ...and is gone once the new one replaces it. + self.assertIsNone(tile_cache.tile_file(self.source, old["version"], 0, 0, 0)) + self.assertEqual(tile_cache.existing_manifest(self.source), new) + siblings = [p.name for p in tile_cache.pyramid_dir(self.source, "x").parent.iterdir()] + self.assertEqual(siblings, [new["version"]]) + + def test_previous_version_serves_while_the_new_one_builds(self) -> None: + old = tile_cache.build_pyramid(self.source) + assert old is not None + Image.new("RGB", (600, 300), (200, 200, 0)).save(self.source, "PNG") + stat = self.source.stat() + os.utime(self.source, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10_000_000)) + + with patch.object(tile_cache, "_build_in_background") as background: + self.assertEqual(tile_cache.ready_manifest(self.source), old) + background.assert_called_once() + self.assertIsNotNone(tile_cache.tile_file(self.source, old["version"], 0, 0, 0)) + + def test_an_older_build_finishing_last_keeps_the_newer_pyramid(self) -> None: + newer = tile_cache.build_pyramid(self.source) + assert newer is not None + # A build of an earlier version of the source, landing after it. + earlier = str(int(newer["version"]) - 1) + with patch.object(tile_cache, "_version_of", return_value=earlier): + self.assertIsNotNone(tile_cache.build_pyramid(self.source)) + self.assertEqual(tile_cache.existing_manifest(self.source), newer) + self.assertIsNotNone(tile_cache.tile_file(self.source, newer["version"], 0, 0, 0)) + + def test_manifest_is_written_last(self) -> None: + manifest = tile_cache.build_pyramid(self.source) + assert manifest is not None + directory = tile_cache.pyramid_dir(self.source, manifest["version"]) + on_disk = json.loads((directory / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(on_disk, manifest) + + def test_missing_source(self) -> None: + missing = self.tmp / "nope.png" + self.assertIsNone(tile_cache.build_pyramid(missing)) + self.assertIsNone(tile_cache.ready_manifest(missing)) + self.assertIsNone(tile_cache.tile_file(missing, "1", 0, 0, 0)) + + +class PickVariantTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.tmp = Path(self._tmp.name) + patcher = patch.object(tile_cache, "_PICK_DIR", self.tmp / "pick") + patcher.start() + self.addCleanup(patcher.stop) + # Two regions meeting along a ragged edge, which averaging would blur. + self.source = self.tmp / "nation_map.png" + image = Image.new("RGBA", (64, 32), (0, 0, 0, 0)) + for x in range(64): + for y in range(32): + if (x + (y % 3)) % 7 < 3: + image.putpixel((x, y), (10, 20, 30, 255)) + elif x > 20: + image.putpixel((x, y), (200, 100, 50, 255)) + image.save(self.source, "PNG") + + def test_scale_zero_is_the_original(self) -> None: + self.assertEqual(tile_cache.pick_variant(self.source, 0), self.source) + + def test_halves_without_inventing_colours(self) -> None: + reduced = tile_cache.pick_variant(self.source, 1) + assert reduced is not None + with Image.open(self.source) as original, Image.open(reduced) as image: + self.assertEqual(image.size, (32, 16)) + source_colours = {colour for _, colour in original.getcolors(4096)} + reduced_colours = {colour for _, colour in image.getcolors(4096)} + self.assertLessEqual(reduced_colours, source_colours) + self.assertEqual(tile_cache.pick_variant(self.source, 1), reduced) + + def test_caps_the_scale(self) -> None: + capped = tile_cache.pick_variant(self.source, 9) + assert capped is not None + with Image.open(capped) as image: + self.assertEqual(image.size, (64 // 2**tile_cache.MAX_PICK_SCALE, 32 // 2**tile_cache.MAX_PICK_SCALE)) + + +class LodVariantTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.tmp = Path(self._tmp.name) + patcher = patch.object(tile_cache, "_LOD_DIR", self.tmp / "lod") + patcher.start() + self.addCleanup(patcher.stop) + self.source = self.tmp / "region.png" + Image.new("RGBA", (400, 200), (0, 0, 255, 128)).save(self.source, "PNG") + + def test_lod_zero_is_the_original(self) -> None: + self.assertEqual(tile_cache.lod_variant(self.source, 0), self.source) + + def test_reduces_by_powers_of_two_and_caches(self) -> None: + first = tile_cache.lod_variant(self.source, 2) + assert first is not None + with Image.open(first) as image: + self.assertEqual(image.size, (100, 50)) + self.assertIn("A", image.getbands()) + self.assertEqual(tile_cache.lod_variant(self.source, 2), first) + + def test_caps_at_max_lod(self) -> None: + capped = tile_cache.lod_variant(self.source, 10) + assert capped is not None + with Image.open(capped) as image: + self.assertEqual(image.size[0], 400 // 2**tile_cache.MAX_LOD) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/src/api/test_tile_routes.py b/backend/src/api/test_tile_routes.py new file mode 100644 index 00000000..dec3ff53 --- /dev/null +++ b/backend/src/api/test_tile_routes.py @@ -0,0 +1,182 @@ +"""Tile manifest and tile routes, and reduced realm overlays.""" + +from __future__ import annotations + +import io +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +_BACKEND_ROOT = Path(__file__).resolve().parents[2] +_BACKEND_SRC = _BACKEND_ROOT / "src" +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) +if str(_BACKEND_SRC) not in sys.path: + sys.path.insert(0, str(_BACKEND_SRC)) + +os.environ.setdefault("SKINS_DEV", "1") + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from PIL import Image # noqa: E402 + +from src.api import file_routes, tile_cache # noqa: E402 +from src.api.file_routes import file_router # noqa: E402 +from src.api.map_registry import clear_map_registry_cache # noqa: E402 +from src.api.tile_routes import tile_router # noqa: E402 +from src.scripts.util import dirs # noqa: E402 + +TEST_REGISTRY = """ +maps: + - id: main + public: true + display_name: Adavaar + realm_id: main +""" + + +class TileRoutesTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + + registry = root / "maps.yml" + registry.write_text(TEST_REGISTRY, encoding="utf-8") + self._orig_registry = os.environ.get("MAP_REGISTRY_PATH") + os.environ["MAP_REGISTRY_PATH"] = str(registry) + clear_map_registry_cache() + self.addCleanup(self._restore_registry) + + self.input_dir = root / "input" + (self.input_dir / "main").mkdir(parents=True) + self.output_dir = root / "output" + (self.output_dir / "main" / "maps").mkdir(parents=True) + (self.output_dir / "main" / "regions" / "nation").mkdir(parents=True) + + self._orig_input = dirs.INPUT_DIR + self._orig_output_base = file_routes.OUTPUT_BASE + dirs.INPUT_DIR = str(self.input_dir) + file_routes.OUTPUT_BASE = self.output_dir + self.addCleanup(self._restore_dirs) + + for name, value in ( + ("_CACHE_DIR", root / "tiles"), + ("_LOD_DIR", root / "lod"), + ("_PICK_DIR", root / "pick"), + ): + patcher = patch.object(tile_cache, name, value) + patcher.start() + self.addCleanup(patcher.stop) + + Image.new("RGB", (600, 600), (30, 90, 30)).save( + self.input_dir / "main" / "map.png", "PNG" + ) + Image.new("RGBA", (300, 300), (200, 0, 0, 255)).save( + self.output_dir / "main" / "maps" / "terrain_map.png", "PNG" + ) + + app = FastAPI() + app.include_router(file_router) + app.include_router(tile_router) + self.client = TestClient(app) + self.addCleanup(self.client.close) + + def _restore_registry(self) -> None: + if self._orig_registry is None: + os.environ.pop("MAP_REGISTRY_PATH", None) + else: + os.environ["MAP_REGISTRY_PATH"] = self._orig_registry + clear_map_registry_cache() + + def _restore_dirs(self) -> None: + dirs.INPUT_DIR = self._orig_input + file_routes.OUTPUT_BASE = self._orig_output_base + + def _manifest(self, layer: str) -> dict: + with patch.object(tile_cache, "_build_in_background") as background: + pending = self.client.get(f"/main/tiles/{layer}/manifest") + self.assertEqual(pending.status_code, 202) + self.assertEqual(pending.json(), {"ready": False}) + background.assert_called_once() + + source = ( + self.input_dir / "main" / "map.png" + if layer == "base" + else self.output_dir / "main" / "maps" / f"{layer[8:]}_map.png" + ) + tile_cache.build_pyramid(source) + ready = self.client.get(f"/main/tiles/{layer}/manifest") + self.assertEqual(ready.status_code, 200) + body = ready.json() + self.assertTrue(body["ready"]) + return body + + def test_base_map_manifest_then_tiles(self) -> None: + manifest = self._manifest("base") + self.assertEqual(manifest["max_level"], 2) + + tile = self.client.get(f"/main/tiles/base/{manifest['version']}/2/1/1.webp") + self.assertEqual(tile.status_code, 200) + self.assertEqual(tile.headers["content-type"], "image/webp") + self.assertIn("immutable", tile.headers["cache-control"]) + self.assertTrue(tile.headers["cache-control"].startswith("public")) + + def test_mode_raster_tiles(self) -> None: + manifest = self._manifest("mapdata-terrain") + tile = self.client.get( + f"/main/tiles/mapdata-terrain/{manifest['version']}/0/0/0.webp" + ) + self.assertEqual(tile.status_code, 200) + + def test_reduced_pick_map(self) -> None: + full = self.client.get("/main/mapdata/terrain") + reduced = self.client.get("/main/mapdata/terrain?scale=1") + self.assertEqual(full.status_code, 200) + self.assertEqual(reduced.status_code, 200) + self.assertEqual(reduced.headers["content-type"], "image/png") + with Image.open(io.BytesIO(reduced.content)) as image: + self.assertEqual(image.size, (150, 150)) + self.assertEqual(self.client.get("/main/mapdata/terrain?scale=7").status_code, 422) + + def test_stale_or_bad_tile_requests_are_404(self) -> None: + manifest = self._manifest("base") + version = manifest["version"] + for path in ( + f"/main/tiles/base/1/0/0/0.webp", + f"/main/tiles/base/{version}/9/0/0.webp", + f"/main/tiles/base/{version}/0/5/5.webp", + f"/main/tiles/nonsense/{version}/0/0/0.webp", + f"/main/tiles/base/abc/0/0/0.webp", + ): + self.assertEqual(self.client.get(path).status_code, 404, path) + + def test_unknown_layer_manifest_is_404(self) -> None: + self.assertEqual(self.client.get("/main/tiles/nonsense/manifest").status_code, 404) + self.assertEqual( + self.client.get("/main/tiles/mapdata-..%2Fx/manifest").status_code, 404 + ) + + def test_region_overlay_lod(self) -> None: + Image.new("RGBA", (400, 200), (0, 0, 255, 200)).save( + self.output_dir / "main" / "regions" / "nation" / "1_2_3.png", "PNG" + ) + full = self.client.get("/main/regions/nation/1_2_3") + self.assertEqual(full.status_code, 200) + + reduced = self.client.get("/main/regions/nation/1_2_3?lod=2") + self.assertEqual(reduced.status_code, 200) + self.assertEqual(reduced.headers["content-type"], "image/webp") + out = Path(self.tmp.name) / "reduced.webp" + out.write_bytes(reduced.content) + with Image.open(out) as image: + self.assertEqual(image.size, (100, 50)) + + self.assertEqual(self.client.get("/main/regions/nation/1_2_3?lod=9").status_code, 422) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/src/api/tile_cache.py b/backend/src/api/tile_cache.py new file mode 100644 index 00000000..8fc05857 --- /dev/null +++ b/backend/src/api/tile_cache.py @@ -0,0 +1,371 @@ +"""Tile pyramids for full-map images, cached on disk. + +The map page used to draw each full-map raster (the 6400x6400 satellite base, +the terrain/fertility/... mode maps) as one image. A browser has to decode the +whole thing — 41 megapixels — and decode it again at every new zoom scale, +even when the reader can see a twentieth of it. That decode is what made +zooming stutter. + +This is what Google Maps does instead: cut the image into 256 px tiles at a +ladder of resolutions, each level half the size of the one above, so a client +only ever fetches and decodes the handful of tiles on screen, at the level +that matches its zoom. + +Building a pyramid takes a few seconds, so it is never done inside a request. +A request finds a ready pyramid for the source's current mtime or, while a +background thread builds that one, the previous version's; only a source that +has never been tiled answers "not ready" (the client then uses the single +image). Each build lives in a directory named after the source's mtime, so a +regenerated map invalidates itself and a reader never sees a half-written +level. Regeneration starts the builds itself (`tile_warm`) rather than +leaving them to the first visitor. + +Tiles are lossy WebP: they are only ever drawn, never read back. The pick maps +the client reads pixel-by-pixel still come from the original PNG routes. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import shutil +import tempfile +import threading +from pathlib import Path + +TILE_SIZE = 256 +_QUALITY = 80 +_METHOD = 4 + +_ROUTER_DIR = Path(__file__).resolve().parent +_CACHE_DIR = _ROUTER_DIR.parent / "output" / "_derived" / "tiles" + +_building: set[str] = set() +_building_lock = threading.Lock() + +# One full-map build at a time. Each holds a 6400 px image (~160 MB) and its +# smaller levels in memory; several modes asked for at once must queue rather +# than stack up in the API's memory. +BUILD_LOCK = threading.Lock() + + +def _source_key(source: Path) -> str: + return hashlib.sha1( + str(source.resolve()).encode("utf-8"), usedforsecurity=False + ).hexdigest() + + +def _version_of(source: Path) -> str | None: + try: + return str(source.stat().st_mtime_ns) + except OSError: + return None + + +def pyramid_dir(source: os.PathLike[str] | str, version: str) -> Path: + return _CACHE_DIR / _source_key(Path(source)) / version + + +def max_level_for(width: int, height: int) -> int: + """The level at which the image is at full resolution. + + Level 0 fits in a single tile; each level above doubles the size. + """ + longest = max(width, height, 1) + return max(0, math.ceil(math.log2(longest / TILE_SIZE))) + + +def _encode_level(image, level_dir: Path) -> None: + width, height = image.size + level_dir.mkdir(parents=True, exist_ok=True) + for ty in range(math.ceil(height / TILE_SIZE)): + for tx in range(math.ceil(width / TILE_SIZE)): + box = ( + tx * TILE_SIZE, + ty * TILE_SIZE, + min(width, (tx + 1) * TILE_SIZE), + min(height, (ty + 1) * TILE_SIZE), + ) + image.crop(box).save( + level_dir / f"{tx}_{ty}.webp", + "WEBP", + quality=_QUALITY, + method=_METHOD, + ) + + +def build_pyramid(source: os.PathLike[str] | str) -> dict | None: + """Build every level for `source` and return its manifest. + + Built into a temporary directory and renamed into place, so a reader only + ever finds a complete pyramid. Older versions of the same source are + removed once the new one is in place. + """ + from PIL import Image + + source_path = Path(source) + version = _version_of(source_path) + if version is None: + return None + final = pyramid_dir(source_path, version) + if (final / "manifest.json").is_file(): + return read_manifest(final) + + parent = final.parent + parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(dir=parent, prefix=".build-")) + # mkdtemp makes the directory private (0700); the finished pyramid is an + # ordinary readable cache like the rest of output/. + os.chmod(staging, 0o755) + try: + with Image.open(source_path) as opened: + opened.load() + mode = "RGBA" if "A" in opened.getbands() or "transparency" in opened.info else "RGB" + image = opened.convert(mode) + + width, height = image.size + top = max_level_for(width, height) + levels: list[dict] = [{}] * (top + 1) + current = image + for level in range(top, -1, -1): + if level != top: + # Box-filtered halving: fast, and exact enough for terrain. + current = current.reduce(2) + levels[level] = {"width": current.size[0], "height": current.size[1]} + _encode_level(current, staging / str(level)) + + manifest = { + "version": version, + "width": width, + "height": height, + "tile_size": TILE_SIZE, + "max_level": top, + "levels": levels, + "format": "webp", + } + (staging / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + try: + os.replace(staging, final) + except OSError: + # Another build of the same version won the race; theirs is as good. + shutil.rmtree(staging, ignore_errors=True) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + # Only versions older than this one: a build of an earlier source that + # finishes last must not delete the newer pyramid clients are now served. + for sibling in parent.iterdir(): + if sibling.name.isdigit() and int(sibling.name) < int(version): + shutil.rmtree(sibling, ignore_errors=True) + return read_manifest(final) + + +def read_manifest(directory: Path) -> dict | None: + try: + return json.loads((directory / "manifest.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _build_in_background(source: Path) -> None: + key = str(source.resolve()) + with _building_lock: + if key in _building: + return + _building.add(key) + + def run() -> None: + try: + with BUILD_LOCK: + build_pyramid(source) + except Exception as exc: # pragma: no cover - background best effort + print(f"[tiles] build failed for {source}: {exc}") + finally: + with _building_lock: + _building.discard(key) + + threading.Thread(target=run, name="tile-pyramid", daemon=True).start() + + +def ready_manifest( + source: os.PathLike[str] | str, *, background: bool = True +) -> dict | None: + """The manifest for `source`'s current version. + + While that version builds, the newest one already built: a map regenerated + every few minutes (prosperity follows every trade update) would otherwise + answer "not ready" for most of its life and send readers the full-size + image. None only when nothing has been built yet. + """ + source_path = Path(source) + version = _version_of(source_path) + if version is None: + return None + manifest = read_manifest(pyramid_dir(source_path, version)) + if manifest is not None: + return manifest + if background: + _build_in_background(source_path) + return latest_manifest(source_path) + return build_pyramid(source_path) + + +def latest_manifest(source: os.PathLike[str] | str) -> dict | None: + """The newest finished pyramid of `source`, whatever its version. + + Its tiles stay on disk until a newer build replaces them, so it can be + served while that build runs (`tile_file` keeps answering for it). + """ + parent = _CACHE_DIR / _source_key(Path(source)) + try: + versions = [entry for entry in parent.iterdir() if entry.name.isdigit()] + except OSError: + return None + for directory in sorted(versions, key=lambda entry: int(entry.name), reverse=True): + manifest = read_manifest(directory) + if manifest is not None: + return manifest + return None + + +def existing_manifest(source: os.PathLike[str] | str) -> dict | None: + """The manifest for `source`'s current version if built; never builds.""" + version = _version_of(Path(source)) + if version is None: + return None + return read_manifest(pyramid_dir(source, version)) + + +def tile_file( + source: os.PathLike[str] | str, version: str, level: int, x: int, y: int +) -> Path | None: + """A tile of `version`, while that version's pyramid is still on disk. + + Versions live in separate directories, so a URL can never answer with + different pixels; an older version keeps serving until the next build + replaces it, which lets a client holding the previous manifest finish + loading rather than 404 the moment the source changes. + """ + if not version.isdigit(): + return None + path = pyramid_dir(Path(source), version) / str(level) / f"{x}_{y}.webp" + return path if path.is_file() else None + + +# --------------------------------------------------------------------------- +# Reduced copies of single overlays +# +# Realm overlays are cropped to their region, so they are not full-map sized, +# but the large ones are still thousands of pixels across and are decoded at +# full size even when the whole world is on screen. `lod=n` serves a copy +# reduced by 2**n, which the client asks for when zoomed out far enough that +# the full size would only be thrown away. +# --------------------------------------------------------------------------- + +MAX_LOD = 3 + +_LOD_DIR = _ROUTER_DIR.parent / "output" / "_derived" / "lod" +_lod_lock = threading.Lock() + + +def lod_variant(source: os.PathLike[str] | str, lod: int) -> Path | None: + """`source` reduced by 2**lod, cached next to other derived images. + + Built synchronously: overlays are small enough that one reduce and encode + takes a few milliseconds, and a request for a reduced copy has nothing + better to fall back to. Lossless WebP keeps the overlay's hard alpha edges. + """ + from PIL import Image + + if lod <= 0: + return Path(source) + lod = min(lod, MAX_LOD) + source_path = Path(source) + version = _version_of(source_path) + if version is None: + return None + target = _LOD_DIR / f"{_source_key(source_path)}_{version}_{lod}.webp" + if target.is_file(): + return target + + with _lod_lock: + if target.is_file(): + return target + target.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=target.parent, suffix=".tmp") + os.close(fd) + tmp = Path(tmp_name) + try: + with Image.open(source_path) as opened: + opened.load() + image = opened.convert("RGBA") + image.reduce(2**lod).save(tmp, "WEBP", lossless=True, method=2) + os.replace(tmp, target) + except BaseException: + tmp.unlink(missing_ok=True) + raise + for stale in target.parent.glob(f"{_source_key(source_path)}_*_{lod}.webp"): + if stale != target: + stale.unlink(missing_ok=True) + return target + + +# --------------------------------------------------------------------------- +# Reduced pick maps +# +# The client reads region ids back out of a `mapdata` pick map pixel by pixel, +# from a canvas the pick map's own size: 6400 px square, some 160 MB, and over +# the 16.7-megapixel limit iOS Safari puts on a canvas. A phone that held it +# alongside the map ran out of memory and Safari reloaded the page, again and +# again. `pick_variant` serves the pick map reduced by 2**scale with nearest- +# neighbour sampling, so every pixel is still exactly one region's colour. +# --------------------------------------------------------------------------- + +MAX_PICK_SCALE = 2 + +_PICK_DIR = _ROUTER_DIR.parent / "output" / "_derived" / "pick" +_pick_lock = threading.Lock() + + +def pick_variant(source: os.PathLike[str] | str, scale: int) -> Path | None: + """`source` reduced by 2**scale, nearest-neighbour, as a PNG; cached.""" + from PIL import Image + + if scale <= 0: + return Path(source) + scale = min(scale, MAX_PICK_SCALE) + source_path = Path(source) + version = _version_of(source_path) + if version is None: + return None + target = _PICK_DIR / f"{_source_key(source_path)}_{version}_{scale}.png" + if target.is_file(): + return target + + with _pick_lock: + if target.is_file(): + return target + target.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=target.parent, suffix=".tmp") + os.close(fd) + tmp = Path(tmp_name) + try: + with Image.open(source_path) as opened: + opened.load() + factor = 2**scale + size = (max(1, opened.width // factor), max(1, opened.height // factor)) + reduced = opened.resize(size, Image.Resampling.NEAREST) + reduced.save(tmp, "PNG", compress_level=6) + os.chmod(tmp, 0o644) + os.replace(tmp, target) + except BaseException: + tmp.unlink(missing_ok=True) + raise + for stale in target.parent.glob(f"{_source_key(source_path)}_*_{scale}.png"): + if stale != target: + stale.unlink(missing_ok=True) + return target + diff --git a/backend/src/api/tile_routes.py b/backend/src/api/tile_routes.py new file mode 100644 index 00000000..249ab913 --- /dev/null +++ b/backend/src/api/tile_routes.py @@ -0,0 +1,112 @@ +"""Tiled versions of the full-map rasters. See `tile_cache` for why.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Header, HTTPException +from fastapi.responses import FileResponse + +from . import file_routes, map_routes, region_composite +from .http_headers import add_cors, conditional_json_response +from .map_access import ensure_map_access +from .tile_cache import ready_manifest, tile_file +from ..scripts.util.regen_types import MODES as REGION_MODES + +tile_router = APIRouter() + +# `base` is the satellite map the viewer draws under everything; `mapdata-X` +# is mode X's full-map raster (terrain, fertility, province, ...); `regions-X` +# is region mode X's overlays flattened as first shown (see region_composite). +_MAPDATA_PREFIX = "mapdata-" +_REGIONS_PREFIX = "regions-" + + +def _region_mode(layer: str) -> str | None: + if not layer.startswith(_REGIONS_PREFIX): + return None + mode = layer[len(_REGIONS_PREFIX):] + return mode if mode in REGION_MODES else None + + +def _tile_source(map_name: str, layer: str) -> Path | None: + if layer == "base": + path = map_routes._resolve_base_map_path(map_name, "satellite") + return Path(path) if path else None + if layer.startswith(_MAPDATA_PREFIX): + return file_routes.resolve_mapdata_path(map_name, layer[len(_MAPDATA_PREFIX):]) + mode = _region_mode(layer) + if mode is not None: + return region_composite.composite_path(map_name, mode) + return None + + +def _not_ready(): + response = conditional_json_response({"ready": False}) + response.status_code = 202 + return response + + +@tile_router.get("/{map_name}/tiles/{layer}/manifest") +async def get_tile_manifest( + map_name: str, + layer: str, + authorization: str | None = Header(default=None), + if_none_match: str | None = Header(default=None), +): + """The pyramid's shape, or `{"ready": false}` while it is being built. + + Not ready is a normal answer (202), not an error: the client keeps drawing + the single image and asks again later. + """ + map_name = ensure_map_access(map_name, authorization).id + + mode = _region_mode(layer) + if mode is not None: + if not region_composite.has_inputs(map_name, mode): + raise HTTPException(status_code=404, detail="Layer not found") + manifest = region_composite.latest_manifest(map_name, mode) + if manifest is None: + return _not_ready() + return conditional_json_response( + {"ready": True, **manifest}, if_none_match=if_none_match + ) + + source = _tile_source(map_name, layer) + if source is None: + raise HTTPException(status_code=404, detail="Layer not found") + + manifest = ready_manifest(source) + if manifest is None: + return _not_ready() + return conditional_json_response( + {"ready": True, **manifest}, if_none_match=if_none_match + ) + + +@tile_router.get("/{map_name}/tiles/{layer}/{version}/{level}/{x}/{y}.webp") +async def get_tile( + map_name: str, + layer: str, + version: str, + level: int, + x: int, + y: int, + authorization: str | None = Header(default=None), +): + entry = ensure_map_access(map_name, authorization) + source = _tile_source(entry.id, layer) + if source is None or not version.isdigit() or min(level, x, y) < 0: + raise HTTPException(status_code=404, detail="Tile not found") + + path = tile_file(source, version, level, x, y) + if path is None: + raise HTTPException(status_code=404, detail="Tile not found") + + response = FileResponse(path, media_type="image/webp") + # The version is part of the URL and a stale one is refused above, so a + # tile URL never changes meaning: cache it for good. Staff maps stay out + # of shared caches. + scope = "public" if entry.public else "private" + response.headers["Cache-Control"] = f"{scope}, max-age=31536000, immutable" + return add_cors(response) diff --git a/backend/src/api/tile_warm.py b/backend/src/api/tile_warm.py new file mode 100644 index 00000000..a9b0c227 --- /dev/null +++ b/backend/src/api/tile_warm.py @@ -0,0 +1,34 @@ +"""Start a map's tile builds as soon as a regeneration has redrawn it. + +`tile_cache` and `region_composite` build a pyramid when one is first asked +for, so the first reader after every regeneration waited on the build (or, for +a map that had never been tiled, got the full-size image). Prosperity follows +every trade update, every few minutes on the live map, so that was most +readers. Starting the builds here instead has them ready, or under way, by +the time anyone opens the mode; anything already current returns at once. +""" + +from __future__ import annotations + +from pathlib import Path + +from ..scripts.util.dirs import input_file, map_image +from ..scripts.util.regen_types import MODES as REGION_MODES +from . import region_composite, tile_cache + +# The full-map rasters the viewer tiles as `mapdata-{mode}`, except the +# province map, which is the unchanging input pick map (see file_routes). +RASTER_MODES = ("terrain", "fertility", "prosperity", "infrastructure", "infestation") + + +def warm_map_tiles(map_name: str) -> None: + """Queue background builds for every tiled layer of `map_name` that is + out of date. Returns at once; the builds run one at a time.""" + sources = [Path(map_image(map_name, mode)) for mode in RASTER_MODES] + sources.append(Path(input_file(map_name, "provinces.png"))) + for source in sources: + if source.is_file(): + tile_cache.ready_manifest(source) + for mode in REGION_MODES: + if region_composite.has_inputs(map_name, mode): + region_composite.ready_composite(map_name, mode) diff --git a/backend/src/scripts/mapgen/geometry_cache.py b/backend/src/scripts/mapgen/geometry_cache.py index 7a8ca6e6..3f5752c0 100644 --- a/backend/src/scripts/mapgen/geometry_cache.py +++ b/backend/src/scripts/mapgen/geometry_cache.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import numpy as np @@ -23,6 +23,9 @@ class MapGeometryCache: province_id_map: np.ndarray land_mask: np.ndarray rgb_to_id: dict[tuple[int, int, int], int] + _province_boxes: dict[int, tuple[int, int, int, int]] | None = field( + default=None, repr=False, compare=False + ) @classmethod def load(cls, map_name: str) -> MapGeometryCache: @@ -37,10 +40,16 @@ def load(cls, map_name: str) -> MapGeometryCache: packed_rgb = pack_rgb(provinces_rgba[:, :, :3]) terrains = load_province_terrains(map_name) - land_mask = np.zeros_like(province_id_map, dtype=bool) - for province_id, terrain in terrains.items(): - if terrain not in SKIP_TERRAINS: - land_mask[province_id_map == province_id] = True + land_ids = [ + province_id + for province_id, terrain in terrains.items() + if terrain not in SKIP_TERRAINS + ] + is_land = np.zeros( + max([int(province_id_map.max()), *land_ids]) + 1, dtype=bool + ) + is_land[land_ids] = True + land_mask = is_land[province_id_map] return cls( width=width, @@ -51,3 +60,30 @@ def load(cls, map_name: str) -> MapGeometryCache: land_mask=land_mask, rgb_to_id=rgb_to_id, ) + + def province_box(self, province_id: int) -> tuple[int, int, int, int] | None: + """`(x0, y0, x1, y1)` around a province's pixels, or None if it has none. + + Lets callers compare a province's own box rather than the whole map, + which over hundreds of provinces is most of the work. + """ + if self._province_boxes is None: + self._province_boxes = _province_boxes(self.province_id_map) + return self._province_boxes.get(province_id) + + +def _province_boxes(ids: np.ndarray) -> dict[int, tuple[int, int, int, int]]: + height, width = ids.shape + count = int(ids.max()) + 1 + rows = np.zeros((count, height), dtype=bool) + rows[ids, np.arange(height)[:, None]] = True + cols = np.zeros((count, width), dtype=bool) + cols[ids, np.arange(width)[None, :]] = True + present = np.flatnonzero(rows.any(axis=1)) + y0 = rows.argmax(axis=1) + y1 = height - rows[:, ::-1].argmax(axis=1) + x0 = cols.argmax(axis=1) + x1 = width - cols[:, ::-1].argmax(axis=1) + return { + int(i): (int(x0[i]), int(y0[i]), int(x1[i]), int(y1[i])) for i in present + } diff --git a/backend/src/scripts/mapgen/map_paint_numpy.py b/backend/src/scripts/mapgen/map_paint_numpy.py index e843ccc3..340a3aae 100644 --- a/backend/src/scripts/mapgen/map_paint_numpy.py +++ b/backend/src/scripts/mapgen/map_paint_numpy.py @@ -53,26 +53,24 @@ def paint_from_rgb_lut( Missing keys remain transparent. Values may be RGB (alpha 255) or RGBA. """ - height, width = provinces_rgba.shape[:2] - packed = pack_rgb(provinces_rgba[:, :, :3]) - unique_keys, inverse = np.unique(packed, return_inverse=True) - - colors = np.zeros((unique_keys.shape[0], 4), dtype=np.uint8) - for index, key in enumerate(unique_keys): - rgb = unpack_rgb_key(int(key)) - mapped = rgb_to_color.get(rgb) - if mapped is None: - continue - + # Index every possible packed RGB straight into a palette whose entry 0 is + # transparent. Sorting the map's pixels to find its distinct colours + # (np.unique) cost seconds per call on a 6400 px map; this is one gather. + palette = [(0, 0, 0, 0)] + index_dtype = np.uint16 if len(rgb_to_color) < 0xFFFF else np.uint32 + palette_index = np.zeros(1 << 24, dtype=index_dtype) + for rgb, mapped in rgb_to_color.items(): if color_overrides is not None: mapped_rgb = mapped[:3] mapped = color_overrides.get(mapped_rgb, mapped_rgb) rgba = _resolve_rgba(mapped, skip_black=skip_black) if rgba is not None: - colors[index] = rgba + palette_index[_pack_key(rgb)] = len(palette) + palette.append(rgba) - return colors[inverse].reshape(height, width, 4) + packed = pack_rgb(provinces_rgba[:, :, :3]) + return np.asarray(palette, dtype=np.uint8)[palette_index[packed]] def _build_mode_lut( diff --git a/backend/src/scripts/mapgen/regiongen_numpy.py b/backend/src/scripts/mapgen/regiongen_numpy.py index 5e78f17d..1af7894a 100644 --- a/backend/src/scripts/mapgen/regiongen_numpy.py +++ b/backend/src/scripts/mapgen/regiongen_numpy.py @@ -11,11 +11,10 @@ from PIL import Image from ..util.border_paint import ( - OPAQUE_UNION_OWNER, - apply_occupation_seam_dashes, + apply_occupation_seam_dashes_array, border_color_for_fill, border_thickness as default_border_thickness, - compute_opaque_union_borders, + stroke_opaque_union_array, ) from ..util.colour_mapping import build_color_mapping, get_color_overrides from ..util.display_colour import display_rgb, hover_rgb, occupation_display_rgb @@ -124,10 +123,14 @@ def paint_flat( hover_rgb: OwnerColor, *, nested: bool = False, + origin: tuple[int, int] = (0, 0), ) -> None: + """Paint `mask`, whose top-left pixel sits at map `origin` (x, y).""" ys, xs = np.where(mask) if ys.size == 0: return + xs = xs + origin[0] + ys = ys + origin[1] self._expand(int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) ly = ys - self.y0 @@ -171,6 +174,29 @@ def _save_layer_array(arr: np.ndarray, path: str) -> None: Image.fromarray(np.array(arr, dtype=np.uint8, copy=True), mode="RGBA").save(path, "PNG") +def _stage_on_window( + buf: RegionBuffer, + box: tuple[int, int, int, int], + margin: int, + height: int, + width: int, + layer: str, +) -> tuple[np.ndarray, tuple[int, int]]: + """The layer on its box grown by `margin`, clipped to the map, and the + window's map origin. + + Stands in for the full map canvas: everything outside the box is + transparent, so with room for the stroke and the crop padding the window + gives the same pixels for a fraction of the work. + """ + x0, y0, x1, y1 = box + wx0, wy0 = max(0, x0 - margin), max(0, y0 - margin) + wx1, wy1 = min(width, x1 + margin), min(height, y1 + margin) + window = np.zeros((wy1 - wy0, wx1 - wx0, 4), dtype=np.uint8) + window[y0 - wy0 : y1 - wy0, x0 - wx0 : x1 - wx0] = getattr(buf, layer) + return window, (wx0, wy0) + + def _finalize_buffer_layer( buf: RegionBuffer, layer: str, @@ -178,8 +204,12 @@ def _finalize_buffer_layer( *, store_overlay_meta: bool = False, store_nested_overlay_meta: bool = False, + origin: tuple[int, int] = (0, 0), ) -> None: + """Crop `full` (a canvas whose top-left sits at map `origin`) to content.""" cropped, meta = _finalize_layer_from_full(full) + if meta is not None: + meta = {**meta, "x": meta["x"] + origin[0], "y": meta["y"] + origin[1]} setattr(buf, layer, cropped) if store_overlay_meta and meta is not None: buf.overlay_meta = meta @@ -191,56 +221,9 @@ def _finalize_buffer_layer( buf.y1 = cropped.shape[0] -def _apply_region_borders_np( - img: np.ndarray, - region_color: OwnerColor, - border_owners: dict, - color: tuple[int, int, int, int], - thickness: int, -) -> None: - """Paint border dilation on a writable (H, W, 4) array.""" - height, width = img.shape[:2] - t = thickness - for (x, y), owners in border_owners.items(): - if region_color not in owners: - continue - for dy in range(-t, t + 1): - ny = y + dy - if 0 <= ny < height: - for dx in range(-t, t + 1): - nx = x + dx - if 0 <= nx < width: - img[ny, nx] = color - - -def _stroke_opaque_union_np( - img: np.ndarray, - stroke: tuple[int, int, int, int], - thickness: int, -) -> None: - height, width = img.shape[:2] - owners = compute_opaque_union_borders( - Image.fromarray(img, mode="RGBA").load(), - width, - height, - ) - _apply_region_borders_np(img, OPAQUE_UNION_OWNER, owners, stroke, thickness) - - -class _XyPixels: - """PIL-style [x, y] access over a (H, W, 4) array.""" - - def __init__(self, arr: np.ndarray): - self.arr = arr - - def __getitem__(self, xy): - x, y = xy - pix = self.arr[y, x] - return (int(pix[0]), int(pix[1]), int(pix[2]), int(pix[3])) - - def __setitem__(self, xy, value): - x, y = xy - self.arr[y, x] = value +# crop_to_content's default padding. A region's window leaves room for it +# beyond the stroke, so the crop matches the one taken from the full map. +_CROP_PAD = 2 def generate_regions_numpy( @@ -317,9 +300,11 @@ def ensure_region(color: OwnerColor) -> RegionBuffer: if pid is None: continue - mask = cache.province_id_map == pid - if not np.any(mask): + box = cache.province_box(pid) + if box is None: continue + px0, py0, px1, py1 = box + mask = cache.province_id_map[py0:py1, px0:px1] == pid current += 1 log_progress( @@ -339,108 +324,56 @@ def ensure_region(color: OwnerColor) -> RegionBuffer: base, hover, nested=owner in overlord_colors, + origin=(px0, py0), ) for anc in overlord_chains.get(owner, []): - ensure_region(anc).paint_flat(mask, display_rgb(anc), hover_rgb(anc)) + ensure_region(anc).paint_flat( + mask, display_rgb(anc), hover_rgb(anc), origin=(px0, py0) + ) print() - if borders and regions: + if regions: total_regions = len(regions) kind = "nested" if has_nesting else "fast" + margin = (border_thickness if borders else 0) + _CROP_PAD + 1 for i, (color, buf) in enumerate(regions.items(), start=1): - log_progress( - f"Painting borders ({kind}): {i}/{total_regions} " - f"({i / max(total_regions, 1) * 100:5.1f}%)" - ) + if borders: + log_progress( + f"Painting borders ({kind}): {i}/{total_regions} " + f"({i / max(total_regions, 1) * 100:5.1f}%)" + ) display_color = display_rgb(color) base_stroke = border_color_for_fill(display_color) hover_stroke = border_color_for_fill(hover_rgb(color)) - x0, y0, x1, y1 = buf.x0, buf.y0, buf.x1, buf.y1 - - full_base = _stage_on_full_canvas(buf, height, width, "base") - _stroke_opaque_union_np(full_base, base_stroke, border_thickness) - - full_hover = np.zeros((height, width, 4), dtype=np.uint8) - full_hover[y0:y1, x0:x1] = buf.hover - _stroke_opaque_union_np(full_hover, hover_stroke, border_thickness) - - if occupation_provinces: - occ_color = occupation_display_rgb(color) - base_px = _XyPixels(full_base) - apply_occupation_seam_dashes( - base_px, - [base_px, _XyPixels(full_hover)], - width, - height, - display_color, - occ_color, - ) - - _finalize_buffer_layer(buf, "base", full_base, store_overlay_meta=True) - _finalize_buffer_layer(buf, "hover", full_hover) - - if buf.with_nested and buf.nested is not None: - full_nested = np.zeros((height, width, 4), dtype=np.uint8) - full_nested[y0:y1, x0:x1] = buf.nested - _stroke_opaque_union_np(full_nested, base_stroke, border_thickness) - - full_nested_hover = np.zeros((height, width, 4), dtype=np.uint8) - full_nested_hover[y0:y1, x0:x1] = buf.nested_hover - _stroke_opaque_union_np( - full_nested_hover, hover_stroke, border_thickness - ) - - if occupation_provinces: - occ_color = occupation_display_rgb(color) - nested_px = _XyPixels(full_nested) - apply_occupation_seam_dashes( - nested_px, - [nested_px, _XyPixels(full_nested_hover)], - width, - height, - display_color, - occ_color, - ) - - _finalize_buffer_layer( - buf, - "nested", - full_nested, - store_nested_overlay_meta=True, - ) - _finalize_buffer_layer(buf, "nested_hover", full_nested_hover) - elif regions: - for buf in regions.values(): - map_x0, map_y0, map_x1, map_y1 = buf.x0, buf.y0, buf.x1, buf.y1 - - full_base = np.zeros((height, width, 4), dtype=np.uint8) - full_base[map_y0:map_y1, map_x0:map_x1] = buf.base - _finalize_buffer_layer( - buf, - "base", - full_base, - store_overlay_meta=True, - ) - - full_hover = np.zeros((height, width, 4), dtype=np.uint8) - full_hover[map_y0:map_y1, map_x0:map_x1] = buf.hover - _finalize_buffer_layer(buf, "hover", full_hover) - + box = (buf.x0, buf.y0, buf.x1, buf.y1) + layers = [("base", "hover", "overlay")] if buf.with_nested and buf.nested is not None and buf.nested_hover is not None: - full_nested = np.zeros((height, width, 4), dtype=np.uint8) - full_nested[map_y0:map_y1, map_x0:map_x1] = buf.nested + layers.append(("nested", "nested_hover", "nested_overlay")) + + for fill_layer, hover_layer, meta_kind in layers: + fill, origin = _stage_on_window(buf, box, margin, height, width, fill_layer) + hover, _ = _stage_on_window(buf, box, margin, height, width, hover_layer) + if borders: + stroke_opaque_union_array(fill, base_stroke, border_thickness) + stroke_opaque_union_array(hover, hover_stroke, border_thickness) + if occupation_provinces: + apply_occupation_seam_dashes_array( + fill, + [fill, hover], + display_color, + occupation_display_rgb(color), + ) _finalize_buffer_layer( buf, - "nested", - full_nested, - store_nested_overlay_meta=True, + fill_layer, + fill, + store_overlay_meta=meta_kind == "overlay", + store_nested_overlay_meta=meta_kind == "nested_overlay", + origin=origin, ) - - full_nested_hover = np.zeros((height, width, 4), dtype=np.uint8) - full_nested_hover[map_y0:map_y1, map_x0:map_x1] = buf.nested_hover - _finalize_buffer_layer(buf, "nested_hover", full_nested_hover) + _finalize_buffer_layer(buf, hover_layer, hover, origin=origin) print() diff --git a/backend/src/scripts/mapgen/test_geometry_cache.py b/backend/src/scripts/mapgen/test_geometry_cache.py index 23759c09..aa413b4e 100644 --- a/backend/src/scripts/mapgen/test_geometry_cache.py +++ b/backend/src/scripts/mapgen/test_geometry_cache.py @@ -50,6 +50,24 @@ def test_land_mask_excludes_water_if_present(self): self.assertTrue(np.any(cache.land_mask)) + def test_province_box_bounds_each_province(self): + ids = np.zeros((6, 8), dtype=np.uint16) + ids[1:3, 2:5] = 1 + ids[5, 7] = 2 + ids[0, 0] = 2 + cache = MapGeometryCache( + width=8, + height=6, + provinces_rgba=np.zeros((6, 8, 4), dtype=np.uint8), + packed_rgb=np.zeros((6, 8), dtype=np.uint32), + province_id_map=ids, + land_mask=np.zeros((6, 8), dtype=bool), + rgb_to_id={}, + ) + self.assertEqual(cache.province_box(1), (2, 1, 5, 3)) + self.assertEqual(cache.province_box(2), (0, 0, 8, 6)) + self.assertIsNone(cache.province_box(3)) + def test_paint_from_province_id_lut_matches_rgb_lut(self): provinces = np.array( [ diff --git a/backend/src/scripts/util/border_paint.py b/backend/src/scripts/util/border_paint.py index 17b341bc..c6b78851 100644 --- a/backend/src/scripts/util/border_paint.py +++ b/backend/src/scripts/util/border_paint.py @@ -1,3 +1,5 @@ +import numpy as np + # Now, after all provinces are painted, paint the borders border_color = (0, 0, 0, 255) # Solid black for kingdom borders (legacy paint_borders) duchy_border_color = (255, 255, 255, 255) # White for duchy borders @@ -258,6 +260,119 @@ def apply_occupation_seam_dashes( ) +# ------------------------------------------------------------ +# Array versions +# ------------------------------------------------------------ +# The functions above visit every pixel in Python, which over a 6400 px map +# costs seconds per image. These do the same on (H, W, 4) uint8 arrays with +# whole-array operations, and produce identical pixels. + + +def _any_neighbour4(mask: np.ndarray, outside: bool = False) -> np.ndarray: + """Pixels with at least one 4-neighbour set; off the array counts as `outside`.""" + padded = np.pad(mask, 1, constant_values=outside) + return padded[:-2, 1:-1] | padded[2:, 1:-1] | padded[1:-1, :-2] | padded[1:-1, 2:] + + +def dilate_square(mask: np.ndarray, radius: int) -> np.ndarray: + """Grow `mask` by a (2r+1) square, clipped to the array, as the per-pixel + stamp in `apply_region_borders` does.""" + out = mask + if radius <= 0: + return out.copy() + for axis in (0, 1): + n = out.shape[axis] + sums = np.cumsum(out, axis=axis, dtype=np.int32) + sums = np.concatenate( + [np.zeros_like(np.take(sums, [0], axis=axis)), sums], axis=axis + ) + index = np.arange(n) + high = np.minimum(index + radius + 1, n) + low = np.maximum(index - radius, 0) + out = (np.take(sums, high, axis=axis) - np.take(sums, low, axis=axis)) > 0 + return out + + +def stroke_opaque_union_array( + img: np.ndarray, color: tuple[int, int, int, int], thickness: int +) -> None: + """`apply_opaque_union_borders` on an array: outline the opaque pixels. + + The array's edge counts as transparent, as the map's edge does, so pass the + whole map or a window that only meets the map's edge where the map ends. + """ + opaque = img[:, :, 3] != 0 + outline = opaque & _any_neighbour4(~opaque, outside=True) + img[dilate_square(outline, thickness)] = color + + +def occupation_seam_mask( + img: np.ndarray, + home_rgb: tuple[int, int, int], + occ_rgb: tuple[int, int, int], +) -> np.ndarray: + """`compute_occupation_seam_pixels` as a mask over an array.""" + if tuple(home_rgb) == tuple(occ_rgb): + return np.zeros(img.shape[:2], dtype=bool) + opaque = img[:, :, 3] != 0 + rgb = img[:, :, :3] + is_occ = opaque & np.all(rgb == np.asarray(occ_rgb, dtype=img.dtype), axis=-1) + is_home = opaque & np.all(rgb == np.asarray(home_rgb, dtype=img.dtype), axis=-1) + return is_occ & _any_neighbour4(is_home) + + +class _ArrayPixels: + """PIL-style [x, y] access over an (H, W, 4) array.""" + + def __init__(self, arr: np.ndarray): + self.arr = arr + + def __getitem__(self, xy): + x, y = xy + pix = self.arr[y, x] + return (int(pix[0]), int(pix[1]), int(pix[2]), int(pix[3])) + + def __setitem__(self, xy, value): + x, y = xy + self.arr[y, x] = value + + +def apply_occupation_seam_dashes_array( + source: np.ndarray, + targets: list[np.ndarray], + home_rgb: tuple[int, int, int], + occ_rgb: tuple[int, int, int], + color=OCCUPATION_DASH_COLOR, + thickness=OCCUPATION_DASH_THICKNESS, + dash_on=OCCUPATION_DASH_ON, + dash_off=OCCUPATION_DASH_OFF, +) -> None: + """`apply_occupation_seam_dashes` on arrays.""" + seam = occupation_seam_mask(source, home_rgb, occ_rgb) + if not seam.any(): + return + if dash_off == 0: + # A solid line stamps every seam pixel, so the walk order is moot. + stamp = dilate_square(seam, thickness) + for target in targets: + target[stamp] = color + return + ys, xs = np.nonzero(seam) + polylines = occupation_seam_polylines(set(zip(xs.tolist(), ys.tolist()))) + for target in targets: + height, width = target.shape[:2] + stamp_dashed_polylines( + _ArrayPixels(target), + width, + height, + polylines, + color=color, + thickness=thickness, + dash_on=dash_on, + dash_off=dash_off, + ) + + def paint_borders(outline, between, new_img_data, height, width): borders = set() # Iterate through all pixels and check for borders diff --git a/backend/src/scripts/util/mode_worker.py b/backend/src/scripts/util/mode_worker.py index 49be11f2..9193de3a 100644 --- a/backend/src/scripts/util/mode_worker.py +++ b/backend/src/scripts/util/mode_worker.py @@ -49,7 +49,8 @@ def run_mode(map_name: str, mode: str, full_regions: bool) -> dict: generate_regions( map_name, mode, - borders=mode != "trade", + # Every mode, trade included; see regeneration._run_mode_serial. + borders=True, queued_regen=(not full_regions and mode != "trade"), cache=cache, ) diff --git a/backend/src/scripts/util/regeneration.py b/backend/src/scripts/util/regeneration.py index 30adfc0d..a2920f06 100644 --- a/backend/src/scripts/util/regeneration.py +++ b/backend/src/scripts/util/regeneration.py @@ -186,7 +186,9 @@ def _run_mode_serial( generate_regions( map_name, mode, - borders=mode != "trade", + # Every mode, trade included: without outlines, neighbouring + # areas dominated by different guilds ran together. + borders=True, queued_regen=region_regen_queued(spec, mode), cache=cache, ) @@ -602,6 +604,13 @@ def _sync_regeneration(map_name: str, regen_type: str): # After the summary so it never hides the real regen cost, but still inside # the map lock so a request cannot race a half-written cache entry. warm_webp_cache(map_name, timings) + try: + # Background builds; this only queues them. + from ...api.tile_warm import warm_map_tiles + + warm_map_tiles(map_name) + except Exception as exc: # the map is regenerated either way + print(f"[tiles] could not start tile builds for '{map_name}': {exc}") return timings, elapsed diff --git a/backend/src/scripts/util/test_border_paint.py b/backend/src/scripts/util/test_border_paint.py index 41f816da..d65441de 100644 --- a/backend/src/scripts/util/test_border_paint.py +++ b/backend/src/scripts/util/test_border_paint.py @@ -1,5 +1,6 @@ import unittest +import numpy as np from PIL import Image from .border_paint import ( @@ -7,11 +8,15 @@ OCCUPATION_DASH_COLOR, OPAQUE_UNION_OWNER, apply_occupation_seam_dashes, + apply_occupation_seam_dashes_array, + apply_opaque_union_borders, apply_region_borders, border_color_for_fill, compute_border_owners, compute_occupation_seam_pixels, compute_opaque_union_borders, + dilate_square, + stroke_opaque_union_array, ) @@ -190,5 +195,69 @@ def test_occupation_dash_skips_outer_vs_transparent(self): self.assertEqual(red, []) + +def _blobs(seed: int, width: int = 40, height: int = 30) -> np.ndarray: + """Ragged two-tone shapes, some touching the edge, on transparency.""" + rng = np.random.default_rng(seed) + img = np.zeros((height, width, 4), dtype=np.uint8) + for _ in range(6): + x, y = int(rng.integers(-3, width)), int(rng.integers(-3, height)) + w, h = int(rng.integers(1, 12)), int(rng.integers(1, 12)) + tone = (180, 80, 80) if rng.random() < 0.5 else (120, 70, 70) + img[max(0, y) : y + h, max(0, x) : x + w] = (*tone, 255) + img[rng.random((height, width)) < 0.05] = 0 + return img + + +class ArrayBorderTests(unittest.TestCase): + """The array versions must give the per-pixel versions' exact pixels.""" + + def test_stroke_matches_per_pixel(self): + for seed in range(12): + for thickness in (0, 1, 5): + expected = Image.fromarray(_blobs(seed), mode="RGBA").copy() + apply_opaque_union_borders( + expected.load(), *expected.size, INK_DARK, thickness + ) + actual = _blobs(seed) + stroke_opaque_union_array(actual, INK_DARK, thickness) + np.testing.assert_array_equal(actual, np.array(expected)) + + def test_seam_dashes_match_per_pixel(self): + wash, grey = (180, 80, 80), (120, 70, 70) + # Only some seeds put the two tones side by side; 40 gives several seams. + for seed in range(40): + for thickness, dash_off in ((0, 0), (1, 0), (1, 2)): + expected = Image.fromarray(_blobs(seed), mode="RGBA").copy() + hover = Image.fromarray(_blobs(seed), mode="RGBA").copy() + apply_occupation_seam_dashes( + expected.load(), + [expected.load(), hover.load()], + *expected.size, + wash, + grey, + thickness=thickness, + dash_off=dash_off, + ) + actual, actual_hover = _blobs(seed), _blobs(seed) + apply_occupation_seam_dashes_array( + actual, + [actual, actual_hover], + wash, + grey, + thickness=thickness, + dash_off=dash_off, + ) + np.testing.assert_array_equal(actual, np.array(expected)) + np.testing.assert_array_equal(actual_hover, np.array(hover)) + + def test_dilate_square_clips_at_the_edge(self): + mask = np.zeros((5, 6), dtype=bool) + mask[0, 0] = True + grown = dilate_square(mask, 2) + self.assertTrue(grown[:3, :3].all()) + self.assertEqual(int(grown.sum()), 9) + + if __name__ == "__main__": unittest.main() diff --git a/frontend/app/components/MapViewer.tsx b/frontend/app/components/MapViewer.tsx index c10bc5c5..32a550a7 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"; @@ -23,38 +23,57 @@ import { import { useGuildCache } from "../hooks/useGuildCache"; import { useTitleLayerData } from "../hooks/useTitleLayerData"; import { - computeRegionLabelGeometry, + computeRegionLabelGeometrySteps, filterRegionLabelsForMapObjects, LABEL_MAP_MODES, + type RegionLabelGeometryCache, } from "../lib/mapLabels"; import { applyDrillStack, drillStackNames, getAncestryChain, getNextDrillTarget, + hasLandSubjects, type DrillLayer, } from "./map/drillUtils"; import MapAccessGate, { type MapAccessGateReason, } from "./map/MapAccessGate"; -import MapCanvas from "./map/MapCanvas"; -import MapPageLayout from "./map/MapPageLayout"; -import MapToolbar from "./map/MapToolbar"; +import { provinceHoverBlocksRegionPick } from "../hooks/regionPick"; +import MapCanvas, { type MapFocus, type MapViewportControls } from "./map/MapCanvas"; import PaintToolbar from "./map/PaintToolbar"; +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 { PlacePanelContent } from "./map/shell/PlacePanel"; +import { GuildPanelContent } from "./map/shell/GuildPanel"; import { - MapDesktopSidePanel, - MapDrillStackBar, -} from "./map/MapSidePanel"; -import NationDetailModal from "./map/NationDetailModal"; -import { buildRegionInfo } from "./map/regionInfo"; + allGuilds, + findGuild, + guildKeyForId, + guildSeat, +} from "@/app/lib/map/guildProfile"; +import { buildPlaceProfile, placeMarkerIdForSearchKey } from "@/app/lib/map/placeProfile"; +import { HistoryIcon } from "./map/shell/MapIcons"; import type { CursorTooltip, HoverOverlay, MapId, MapMode, RegionInfo, + RegionRecord, } 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"; @@ -62,6 +81,7 @@ import { MapAccessError, fetchMapBlobUrl, fetchMapJson, + mapApiPathFromUrl, mapApiUrl, mapRequiresAuth, revokeMapBlobUrl, @@ -83,42 +103,66 @@ 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 + + + {/* The icon alone on a phone, where the top row is the search box's. */} + History ); } -const fitModeLabelClass = (active: boolean) => - `text-xs transition ${active ? "text-[var(--tfmc-cream)]" : "text-[var(--tfmc-stone)]"}`; +/** Modes whose tooltip describes the province under the pointer. */ +const PROVINCE_TOOLTIP_MODES = new Set([ + "trade", + "prosperity", + "terrain", + "fertility", + "infestation", + "province", +]); + +/** + * 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; @@ -135,6 +179,27 @@ type MapViewerProps = { day?: string | null; }; +/** + * Name layouts already worked out this page view, by map, mode and the data + * they were laid out from. A handful: one per mode a reader flips between. + */ +const labelGeometryCache = new Map(); +const LABEL_GEOMETRY_CACHE_SIZE = 8; +/** How long one slice of name layout may hold the page. */ +const LABEL_LAYOUT_SLICE_MS = 8; + +/** + * Phones and tablets: a touch screen as the main pointer. They pick on tap, + * where the half-size pick map's 2 px steps do not show, and they are the + * devices short of memory. + */ +/** Rows of the pick map copied into its canvas per frame. */ +const PICK_COPY_BAND = 256; + +function prefersSmallPickMap(): boolean { + return typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches; +} + const MapViewer = ({ mapId, day = null }: MapViewerProps) => { const chronicle = day !== null; /* @@ -161,7 +226,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 +235,27 @@ 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 + /** 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); + /** A clicked marker (settlement, installation, battle); exclusive with `selectedId`. */ + const [selectedPlaceId, setSelectedPlaceId] = useState(null); + /** A guild's card, `factionId/guildId`; exclusive with the two above. */ + const [selectedGuildKey, setSelectedGuildKey] = useState(null); + /** One card at a time: whatever opens clears the rest. */ + const select = useCallback( + (next: { region?: string; place?: string; guild?: string } = {}) => { + setSelectedId(next.region ?? null); + setSelectedPlaceId(next.place ?? null); + setSelectedGuildKey(next.guild ?? null); + }, + [] ); - const [modalOpen, setModalOpen] = useState(false); const [drillStack, setDrillStack] = useState([]); const [pendingDrillId, setPendingDrillId] = useState(null); const [cursorTooltip, setCursorTooltip] = useState( @@ -184,6 +264,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); @@ -243,6 +324,35 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { sessionToken: authToken, day, }); + /** + * Guilds are described in the realm data (`/data/nation`), which only the + * realm map loads. Other modes that show guilds (the Guilds map, a guild + * card opened from search) read it from here, fetched once per map. + */ + const [guildRealmData, setGuildRealmData] = useState<{ + mapId: MapId; + data: RegionRecord; + } | null>(null); + const needsGuildRealmData = day === null && mapType !== "nation"; + useEffect(() => { + if (!needsGuildRealmData || guildRealmData?.mapId === mapId) return; + let cancelled = false; + void fetchMapJson(`/${mapId}/data/nation`, { sessionToken: authToken }) + .then((data) => { + if (!cancelled) setGuildRealmData({ mapId, data }); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [needsGuildRealmData, guildRealmData, mapId, authToken]); + const guildData: RegionRecord | null = + mapType === "nation" + ? regionData + : guildRealmData?.mapId === mapId + ? guildRealmData.data + : null; + /** * `prosperity`, `infrastructure` and `infestation` under a stored day. These are drawn on the * live map as `/{mapId}/mapdata/{mode}`, a raster regenerated from today's @@ -281,17 +391,36 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { const mapMarkers = useMemo(() => { if (!isMarkerMapMode(mapType)) return []; const battleMarkers = warBattleMarkersFromWars(wars); + // Provinces a guild (not a realm's own) is based in: their settlements + // are guild seats. + const guildSeatProvinces = new Set( + allGuilds(regionData) + .map((guild) => guild.homeProvince) + .filter((province): province is number => province !== null) + ); return [ - ...settlements.map((settlement) => - settlementToMapMarker({ - ...settlement, - kind: visibleSettlementKind( - settlement.kind, - settlement.faction_id, - mapObjects - ), - }) - ), + ...settlements.map((settlement) => { + const kind = visibleSettlementKind( + settlement.kind, + settlement.faction_id, + mapObjects + ); + // Sized by what the place is, not how many live there: a big + // village no longer outshouts a small capital. + const markerSize = + kind === "faction_capital" + ? "large" + : kind === "guild_capital" || + (typeof settlement.province_id === "number" && + guildSeatProvinces.has(settlement.province_id)) + ? "medium" + : "small"; + return { + ...settlementToMapMarker({ ...settlement, kind }), + markerSize, + weight: settlement.population ?? 0, + } as const; + }), ...(installationsVisible ? installations.map((installation) => addInstallationLinkDetails( @@ -311,9 +440,14 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { installationsVisible, mapType, mapObjects, + regionData, ]); - const labelGeometry = useMemo(() => { + /** + * What the current mode's names are laid out from, and the content key a + * finished layout is cached under; null while something is still loading. + */ + const labelJob = useMemo(() => { if (!LABEL_MAP_MODES.has(mapType)) return null; if (!regionData || !neighbors || !centroids) return null; const needsTitleLayers = @@ -324,18 +458,24 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { if (needsTitleLayers && !titleLayers) { return null; } - return computeRegionLabelGeometry( + // Switching back to a mode fetches the same data again as a new object; + // keyed by content, a mode already seen this page view reuses its layout. + const key = [ + mapId, mapType, - regionData, - titleLayers, - neighbors, - centroids, - { + labelGrid ? "grid" : "", + labelNeighbors ? "label-neighbours" : "", + JSON.stringify(regionData), + needsTitleLayers ? JSON.stringify(titleLayers) : "", + ].join("\u0000"); + const start = () => + computeRegionLabelGeometrySteps(mapType, regionData, titleLayers, neighbors, centroids, { grid: labelGrid ?? undefined, labelNeighbors: labelNeighbors ?? neighbors, - } - ); + }); + return { key, start }; }, [ + mapId, mapType, regionData, titleLayers, @@ -345,6 +485,44 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { labelGrid, ]); + // A layout not yet cached is worked out in slices of a few milliseconds, + // with the browser free between them: done in one go, a first visit to the + // counties froze the page for a quarter of a second. + const [laidOut, setLaidOut] = useState<{ + key: string; + geometry: RegionLabelGeometryCache | null; + } | null>(null); + useEffect(() => { + if (!labelJob || labelGeometryCache.has(labelJob.key)) return; + const steps = labelJob.start(); + let timer: ReturnType | null = null; + const slice = () => { + const until = performance.now() + LABEL_LAYOUT_SLICE_MS; + let step = steps.next(); + while (!step.done && performance.now() < until) step = steps.next(); + if (!step.done) { + timer = setTimeout(slice, 0); + return; + } + labelGeometryCache.set(labelJob.key, step.value); + if (labelGeometryCache.size > LABEL_GEOMETRY_CACHE_SIZE) { + labelGeometryCache.delete(labelGeometryCache.keys().next().value!); + } + setLaidOut({ key: labelJob.key, geometry: step.value }); + }; + slice(); + return () => { + if (timer !== null) clearTimeout(timer); + }; + }, [labelJob]); + const labelGeometry = !labelJob + ? null + : labelGeometryCache.has(labelJob.key) + ? labelGeometryCache.get(labelJob.key)! + : laidOut?.key === labelJob.key + ? laidOut.geometry + : null; + const regionLabels = useMemo( () => filterRegionLabelsForMapObjects(labelGeometry, mapType, mapObjects), [labelGeometry, mapType, mapObjects] @@ -406,9 +584,48 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { // `day` only gates the fetch: the grid is geometry, shared by every day. }, [mapId, authToken, day]); + /** + * The same quarter-scale province grid on the live map, for the modes whose + * tooltip describes the province under the pointer. Hover then reads the id + * locally instead of asking the server on every mouse move, which made the + * trade tooltip flicker between the region and the province as answers + * landed. Fetched on first use and kept for the map. + */ + const [liveProvinceGrid, setLiveProvinceGrid] = useState<{ + mapId: MapId; + grid: ProvinceIdGrid; + } | null>(null); + const needsProvinceGrid = day === null && PROVINCE_TOOLTIP_MODES.has(mapType); + useEffect(() => { + if (!needsProvinceGrid || liveProvinceGrid?.mapId === mapId) return; + const controller = new AbortController(); + void fetchProvinceIdGridQ4(mapId, authToken, controller.signal) + .then((grid) => setLiveProvinceGrid({ mapId, grid })) + .catch((err) => { + if (!controller.signal.aborted) { + console.error("Failed to load province grid:", err); + } + }); + return () => controller.abort(); + }, [needsProvinceGrid, liveProvinceGrid, mapId, authToken]); + const hoverProvinceGrid = + chronicleGrid ?? (liveProvinceGrid?.mapId === mapId ? liveProvinceGrid.grid : null); + 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. @@ -435,7 +652,20 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return; - const path = `/${mapId}/mapdata/${mapType}`; + // The raster modes (prosperity, terrain, ...) hover provinces from the + // province grid and never pick a region, so their full-size image would + // be downloaded and decoded only to sit unread. Clear the last mode's + // instead, so nothing (a pin's realm lookup) reads a stale region. + if (provinceHoverBlocksRegionPick(mapType)) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + return; + } + + // Phones get a half-size pick map: the full one is a 6400 px canvas, + // over iOS Safari's canvas limit and enough, with the map, for Safari to + // run out of memory and reload the page over and over. + const pickScale = prefersSmallPickMap() ? 1 : 0; + const path = `/${mapId}/mapdata/${mapType}${pickScale ? `?scale=${pickScale}` : ""}`; let src = mapApiUrl(path); if (mapRequiresAuth(mapId, maps) && authToken) { try { @@ -452,19 +682,57 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { return; } + const paint = async (source: CanvasImageSource, width: number, height: number) => { + // Resizing re-allocates the (6400x6400 => ~164MB) backing store, so + // only touch the dimensions when the pick image actually changed size. + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + // A band of rows per frame, each cleared just before it is drawn (the + // map's transparent pixels must not keep the last mode's ids): the + // whole 6400 px map cleared and drawn at once held the page for + // ~200 ms; a 256-row band takes a few. + for (let y = 0; y < height; y += PICK_COPY_BAND) { + if (cancelled) return; + const rows = Math.min(PICK_COPY_BAND, height - y); + ctx.clearRect(0, y, width, rows); + ctx.drawImage(source, 0, y, width, rows, 0, y, width, rows); + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }; + + // Decoded off the main thread. Drawn straight from an , the 6400 px + // pick map was decoded inside drawImage, holding the page for about a + // third of a second on every switch to a region mode. Colours exactly as + // stored: they are read back as region ids. + try { + const res = await fetch(src, { credentials: "omit" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + // Premultiplied, as the canvas stores it anyway (pick pixels are fully + // opaque or fully transparent, so no region's colour changes): left + // unpremultiplied, the copy into the canvas took nearly three times as long. + const bitmap = await createImageBitmap(await res.blob(), { + colorSpaceConversion: "none", + }); + if (cancelled) { + bitmap.close(); + return; + } + await paint(bitmap, bitmap.width, bitmap.height); + bitmap.close(); + return; + } catch { + if (cancelled) return; + // Fall through to the plain image path below. + } + const img = new Image(); img.crossOrigin = "anonymous"; img.src = src; img.onload = () => { if (cancelled) return; - // Resizing re-allocates the (6400x6400 => ~164MB) backing store, so - // only touch the dimensions when the pick image actually changed size. - if (canvas.width !== img.width || canvas.height !== img.height) { - canvas.width = img.width; - canvas.height = img.height; - } - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(img, 0, 0); + void paint(img, img.width, img.height); }; img.onerror = () => { console.error("Failed to load pick map image:", src); @@ -475,9 +743,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. @@ -573,7 +839,13 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { regionData, ]); - const { onMouseMove, onMouseLeave: onHoverLeave, isHoveringClickable, pickRegionAtEvent } = useMapHover({ + const { + onMouseMove, + onMouseLeave: onHoverLeave, + isHoveringClickable, + pickRegionAtEvent, + pickMarkerAtEvent, + } = useMapHover({ mapId, mapType, loading, @@ -585,22 +857,23 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { setCursorTooltip, setHoveredOverlay, setHoveredFortZoc, - setRegionInfo, - setSelectedRegionId, + setRegionInfo: ignoreHoverRegionInfo, + setSelectedRegionId: setHoveredRegionId, getHoverRegion, mapDisplayName: displayName, mapObjects, markers: mapMarkers, + labels: regionLabels, forts, setHoveredMarkerId, day, - chronicleGrid, + chronicleGrid: hoverProvinceGrid, }); // Paint mode owns left-click and pointer tracking; the pick canvas is // pointer-events-none while it is on, but guard here too so no stale hover // state survives the switch. - const handleCanvasMouseMove = (event: React.MouseEvent) => { + const handleCanvasMouseMove = (event: React.MouseEvent) => { if (paint.enabled) return; onMouseMove(event); }; @@ -612,7 +885,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 +895,12 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { // mode-data effect re-running to repaint them. if (mode === mapType) return; resetMapObjects(); + select(); + // 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); } @@ -684,32 +962,42 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { ); }; - const handleMapClick = (event: React.MouseEvent) => { + const handleMapClick = (event: React.MouseEvent) => { if (paint.enabled) return; if (event.button !== 0) return; if (!regionData) return; + // Pins sit above the land: a click on one opens its place card. + const marker = event.ctrlKey || event.metaKey ? null : pickMarkerAtEvent(event); + if (marker) { + select({ place: marker.id }); + 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) { + clearSelection(); + } + 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; + // The Guilds map's areas are keyed by the guild that dominates them. + const guildKey = mapType === "trade" ? guildKeyForId(guildData, regionId) : null; + if (guildKey) { + select({ guild: guildKey }); + return; + } + select({ region: regionId }); + focusOnRegion(regionId); }; const handleMouseLeave = () => { @@ -718,11 +1006,259 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { setCursorTooltip(null); setHoveredOverlay(null); setHoveredFortZoc(null); - setRegionInfo(null); - setSelectedRegionId(null); + setHoveredRegionId(null); lastProvinceIdRef.current = null; }; + /** 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] + ); + + /** + * Focus the map on a selected region, as CK3 does with a realm: one with + * subjects opens to show them, anything else opens the realms above it so + * it is drawn as itself. The canvas dims the rest of the world. + */ + const focusOnRegion = useCallback( + (regionId: string) => { + if (!regionData?.[regionId]) return; + const chain = getAncestryChain(regionId, regionData); + const open = hasLandSubjects(regionId, regionData) ? chain : chain.slice(1); + const stack: DrillLayer[] = open.reverse().map((id) => ({ + regionId: id, + name: regionData[id]?.name || id, + rgb: regionData[id]?.rgb ?? "128,128,128", + })); + const unchanged = + stack.length === drillStack.length && + stack.every((layer, index) => layer.regionId === drillStack[index].regionId); + if (unchanged) return; + applyDrillStack(stack, regionData, resetDrillVisibility, drillDownRegion); + setDrillStack(stack); + }, + [regionData, drillStack, resetDrillVisibility, drillDownRegion] + ); + + /** Close the details and put the map back as it was. */ + const clearSelection = useCallback(() => { + select(); + setDrillStack([]); + if (regionData) resetDrillVisibility(regionData); + }, [select, regionData, resetDrillVisibility]); + + 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; + const guildKey = mapType === "trade" ? guildKeyForId(guildData, regionId) : null; + if (guildKey) { + select({ guild: guildKey }); + } else { + select({ region: regionId }); + focusOnRegion(regionId); + } + if (options.focus ?? true) focusRegion(regionId); + }, + [regionData, focusOnRegion, focusRegion, mapType, guildData, select] + ); + + /** + * A realm, from a guild card or a place card in any mode: the realm card + * only exists on the realm map, so switch to it first and open the realm + * once its data has loaded. + */ + const [pendingRealmId, setPendingRealmId] = useState(null); + const openRealm = useCallback( + (regionId: string) => { + if (mapType === "nation") { + selectRegion(regionId); + return; + } + setPendingRealmId(regionId); + handleMapTypeChange("nation"); + }, + // handleMapTypeChange is redefined each render but only reads current state. + // eslint-disable-next-line react-hooks/exhaustive-deps + [mapType, selectRegion] + ); + useEffect(() => { + if (!pendingRealmId || mapType !== "nation" || !regionData?.[pendingRealmId]) return; + // Wait for the realm map's layers too: the data lands first, and opening + // a realm's subjects before its layers exist would open nothing. + if (!mapObjects.some((obj) => (obj.baseId ?? obj.id) === pendingRealmId)) return; + selectRegion(pendingRealmId); + setPendingRealmId(null); + }, [pendingRealmId, mapType, regionData, mapObjects, selectRegion]); + + 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 if (entry.kind === "guild") { + select({ guild: entry.guildKey }); + const guild = findGuild(guildData, entry.guildKey); + const seat = guild ? guildSeat(guild, settlements) : null; + if (seat && typeof seat.map_x === "number" && typeof seat.map_y === "number") { + focusPoint(seat.map_x, seat.map_y); + } + } else { + const markerId = placeMarkerIdForSearchKey(entry.key); + if (markerId) { + select({ place: markerId }); + } + focusPoint(entry.mapX, entry.mapY); + } + }, + [selectRegion, focusPoint, select, guildData, settlements] + ); + + /** + * 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 focusRealmId = drillStack[drillStack.length - 1]?.regionId ?? null; + /** + * The region kept lit for the open card. On the trade map a click opens the + * card of the guild that dominates the area, and the areas are keyed by + * that guild's id, so the guild's card lights its areas. + */ + const litRegionId = + selectedId ?? + (mapType === "trade" && selectedGuildKey + ? selectedGuildKey.slice(selectedGuildKey.indexOf("/") + 1) + : null); + const selectedOverlay = useMemo(() => { + if (chronicle || !litRegionId || !regionData?.[litRegionId]) return null; + // An opened realm is lit by the focus itself; its own crop would cover + // the subjects it was opened to show. + if (litRegionId === focusRealmId) return null; + const target = getHoverRegion(mapType, mapId, litRegionId, 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, litRegionId, regionData, getHoverRegion, mapType, mapId, mapObjects, focusRealmId]); + + /** + * What the canvas keeps lit. An opened realm: its whole shape, with its own + * layers drawn inside. Otherwise the selected region alone. + */ + const mapFocus = useMemo(() => { + if (chronicle || !regionData) return null; + if (focusRealmId) { + const shape = mapObjects.find( + (obj) => !obj.nested && (obj.baseId ?? obj.id) === focusRealmId + ); + if (!shape) return null; + const objects = mapObjects.filter( + (obj) => + obj.visible && + getAncestryChain(obj.baseId ?? obj.id, regionData).includes(focusRealmId) + ); + return { + shapePath: `/${mapId}/regions/${mapType}/${shape.path}`, + overlay: shape.overlay, + objects, + lit: selectedId === focusRealmId, + }; + } + if (!selectedOverlay) return null; + const path = mapApiPathFromUrl(selectedOverlay.url); + return { + shapePath: path.endsWith("_hover") ? path.slice(0, -"_hover".length) : path, + overlay: selectedOverlay.overlay, + objects: [], + lit: true, + }; + }, [chronicle, regionData, focusRealmId, mapObjects, mapId, mapType, selectedOverlay, selectedId]); + + // Escape puts the details away, unless it is closing something in a field. + useEffect(() => { + if (!selectedId && !selectedPlaceId && !selectedGuildKey) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || isTypingTarget(event.target)) return; + clearSelection(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selectedId, selectedPlaceId, selectedGuildKey, clearSelection]); + + const selectedMarker = useMemo( + () => (selectedPlaceId ? mapMarkers.find((m) => m.id === selectedPlaceId) ?? null : null), + [selectedPlaceId, mapMarkers] + ); + // A pin that is no longer drawn (another mode, a stored day without it) + // closes its card. + useEffect(() => { + if (selectedPlaceId && mapMarkers.length > 0 && !selectedMarker) setSelectedPlaceId(null); + }, [selectedPlaceId, selectedMarker, mapMarkers.length]); + const selectedPlace = useMemo( + () => + selectedMarker + ? buildPlaceProfile(selectedMarker, settlements, installations, regionData) + : null, + [selectedMarker, settlements, installations, regionData] + ); + + const selectedGuild = useMemo( + () => (selectedGuildKey ? findGuild(guildData, selectedGuildKey) : null), + [selectedGuildKey, guildData] + ); + useEffect(() => { + if (selectedGuildKey && guildData && !selectedGuild) setSelectedGuildKey(null); + }, [selectedGuildKey, guildData, selectedGuild]); + + // 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 +1303,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { @@ -803,7 +1339,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => { @@ -811,12 +1347,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 (

@@ -826,193 +1364,245 @@ 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 = selectedGuild ? ( + select({ place: markerId })} + onFocusPoint={focusPoint} + onClose={clearSelection} + /> + ) : selectedPlace && selectedMarker ? ( + selectRegion(id)} + onFocusPoint={focusPoint} + onClose={clearSelection} + onSelectGuild={(key) => select({ guild: key })} + /> + ) : 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} + onSelectPlace={(markerId) => { + select({ place: markerId }); + }} + onFocusRegion={ + selectedRegion.overlay ? () => focusRegion(selectedId) : undefined } - mapModeSelectorDesktop={ - + onShowSubjects={ + canShowSubjects ? () => openSubjects(selectedId) : undefined } - drillStackBar={ - select({ guild: key })} + /> + ) : 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 ?? selectedPlaceId} + hoveredNationId={hoveredRegionId ?? litRegionId} + onMouseMove={handleCanvasMouseMove} + onMouseLeave={handleMouseLeave} + onClick={handleMapClick} + isHoveringClickable={isHoveringClickable} + focus={mapFocus} + 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/MapAuthImage.tsx b/frontend/app/components/map/MapAuthImage.tsx index 22faa29d..4404f34b 100644 --- a/frontend/app/components/map/MapAuthImage.tsx +++ b/frontend/app/components/map/MapAuthImage.tsx @@ -16,6 +16,12 @@ type MapAuthImageProps = { onLoad?: (event: SyntheticEvent) => void; onError?: (event: SyntheticEvent) => void; imgRef?: (node: HTMLImageElement | null) => void; + /** + * Swap the source on the same `` instead of mounting a new one, so + * the old picture stays up until the new one has loaded. For images whose + * path changes with zoom (reduced overlay copies). + */ + replaceInPlace?: boolean; }; // Only the base map (`/{mapId}/map`) has a low-res preview artifact generated @@ -41,6 +47,7 @@ export default function MapAuthImage({ onLoad, onError, imgRef, + replaceInPlace = false, }: MapAuthImageProps) { const { url } = useMapAssetUrl(mapId, path, sessionToken, Boolean(path)); @@ -73,12 +80,17 @@ export default function MapAuthImage({ const fullImage = ( {alt} { imgRef?.(node); if (node?.complete) { @@ -87,6 +99,9 @@ export default function MapAuthImage({ } }} onLoad={(event) => { + // Decode the full image once, off the main thread, so the first zoom + // after load is not the moment the browser does it. + if (hasPreview) void event.currentTarget.decode().catch(() => {}); setFullLoaded(true); onLoad?.(event); }} diff --git a/frontend/app/components/map/MapCanvas.tsx b/frontend/app/components/map/MapCanvas.tsx index 20efa7b1..70b07635 100644 --- a/frontend/app/components/map/MapCanvas.tsx +++ b/frontend/app/components/map/MapCanvas.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, + useLayoutEffect, + useMemo, useRef, useState, type Dispatch, @@ -13,6 +15,7 @@ import type { MapId, MapMode, MapObject, + OverlayBBox, WarExport, HubLink, } from "./types"; @@ -33,12 +36,24 @@ 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 { provinceHoverBlocksRegionPick } from "../../hooks/regionPick"; +import TileLayer from "./TileLayer"; +import { + prefetchTileBackdrops, + tileUrl, + useTileManifest, +} from "../../hooks/useTileManifest"; +import { overlayLod, tilePixelRatio } from "../../lib/map/tilePyramid"; +import { mapFiltersSupportedHere } from "../../lib/map/mapFilters"; +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"; -import { mapApiPathFromUrl } from "@/lib/map/api"; +import { mapApiPathFromUrl, mapApiUrl } from "@/lib/map/api"; import { PROVINCE_RASTER_MODES, showsLiveProvinceRaster, @@ -57,6 +72,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 +98,112 @@ function paintToolCursor(tool: UseMapPaintResult["tool"]): string { return "cursor-crosshair"; } + +/** + * The highlight drawn over a region: its own shape, a little brighter, with a + * light rim `rimPx` screen pixels wide. The overlay sits inside the scaled + * map, so the rim is sized in map pixels divided by the scale to stay + * constant on screen. Hover gets a thin rim, the region whose details are + * open a thicker one; both stand down mid-gesture (`.map-selected-region`). + */ +function regionHighlightStyle( + displayScale: number, + rimPx: number, + rimAlpha: number +): React.CSSProperties { + // Safari would draw the highlighted region not at all (see mapFilters). + // Unfiltered, it still stands out: drawn again over its own colour, and, + // when selected, over the faded rest of the world. + if (!mapFiltersSupportedHere()) return {}; + const px = displayScale > 0 ? rimPx / displayScale : 0; + const rim = `rgb(232 228 217 / ${rimAlpha})`; + return { + filter: [ + "brightness(1.18) saturate(1.08)", + `drop-shadow(${px}px 0 0 ${rim})`, + `drop-shadow(-${px}px 0 0 ${rim})`, + `drop-shadow(0 ${px}px 0 ${rim})`, + `drop-shadow(0 -${px}px 0 ${rim})`, + ].join(" "), + }; +} + +/** + * What the map is focused on: a selected region, or a realm opened to show + * its subjects. Every other region's colour fades; the focus keeps its own, + * drawn over the faded layer from `shapePath` (a region crop, as an API path) + * or, for an opened realm, from its own layers in `objects`. + */ +export type MapFocus = { + shapePath: string; + overlay?: OverlayBBox; + objects: MapObject[]; + /** + * The focus is itself the selection, and gets the selection's rim. An + * opened realm with one of its subjects selected keeps a thinner one. + */ + lit: boolean; +}; + +/** + * The flattened colours' opacity while something is focused. Low enough that + * the rest of the world reads as background, and that what shows through + * under the focus (drawn at full strength over it) tints it by only a few + * per cent. + */ +const FOCUS_MUTED_OPACITY = 0.3; + +/** + * Whether `url` has loaded, so the colours fade only once what replaces them + * is ready to draw. + */ +function useImageLoaded(url: string | null): boolean { + const [loaded, setLoaded] = useState(null); + useEffect(() => { + if (!url) return; + let cancelled = false; + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + if (!cancelled) setLoaded(url); + }; + image.src = url; + return () => { + cancelled = true; + }; + }, [url]); + return url !== null && loaded === url; +} + +/** + * Where the pointer's tooltip goes: below and to the right of it, flipped to + * the other side near the window's right or bottom edge so it is never cut off + * (on a phone the finger is often near the edge). + */ +function tooltipPosition(x: number, y: number): React.CSSProperties { + const gap = 14; + const width = typeof window === "undefined" ? Infinity : window.innerWidth; + const height = typeof window === "undefined" ? Infinity : window.innerHeight; + return { + ...(x + gap + 240 > width ? { right: Math.max(8, width - x + gap) } : { left: x + gap }), + ...(y + gap + 110 > height ? { bottom: Math.max(8, height - y + gap) } : { top: y + gap }), + }; +} + +/** Region modes the backend can flatten and tile (`regions-{mode}`). */ +const REGION_TILE_MODES = new Set([ + "nation", + "county", + "duchy", + "kingdom", + "empire", + "trade", +]); + +function pixelatedClass(displayScale: number): string { + return displayScale >= 1 ? "[image-rendering:pixelated]" : ""; +} + function applyNaturalMapSize( img: HTMLImageElement, setMapSize: Dispatch> @@ -95,6 +226,9 @@ function HoverOverlayImage({ opacity = HOVER_OVERLAY_OPACITY, alt = "Hovered region", imageClassName = "", + imageStyle, + lod = 0, + ownShape = false, }: { mapId: MapId; sessionToken?: string | null; @@ -104,9 +238,22 @@ function HoverOverlayImage({ opacity?: number; alt?: string; imageClassName?: string; + imageStyle?: React.CSSProperties; + /** Reduced copy to ask for; only region crops have them. */ + lod?: number; + /** + * Draw the region's own overlay rather than its `_hover` variant: the same + * colours as the map under it, so a highlight reads as that realm lit up, + * not swapped for a paler copy. + */ + ownShape?: boolean; }) { const [loadedUrl, setLoadedUrl] = useState(null); - const path = mapApiPathFromUrl(overlay.url); + const hoverPath = mapApiPathFromUrl(overlay.url); + const basePath = + ownShape && hoverPath.endsWith("_hover") ? hoverPath.slice(0, -"_hover".length) : hoverPath; + const path = + lod > 0 && basePath.includes("/regions/") ? `${basePath}?lod=${lod}` : basePath; const { url } = useMapAssetUrl(mapId, path, sessionToken, Boolean(path)); useEffect(() => { @@ -138,6 +285,7 @@ function HoverOverlayImage({ className={`${OVERLAY_TRANSITION_CLASS} z-10 ${imageClassName}`.trim()} style={{ ...positioned, + ...imageStyle, opacity: imageOpacity, }} onLoad={markLoaded} @@ -155,6 +303,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[]; @@ -165,10 +319,18 @@ type MapCanvasProps = { hoveredMarkerId?: string | null; hoveredNationId?: string | null; viewportCoordsRef?: MutableRefObject; - onMouseMove: (e: React.MouseEvent) => void; + onMouseMove: (e: React.MouseEvent) => void; onMouseLeave: () => void; - onClick: (e: React.MouseEvent) => void; + onClick: (e: React.MouseEvent) => void; isHoveringClickable?: boolean; + /** + * Live map only: the selected region or opened realm. Every other region's + * colour fades, and an opened realm's own layers are drawn over the + * flattened tiles, which otherwise stay as first shown. + */ + focus?: MapFocus | null; + /** 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 +371,7 @@ export default function MapCanvas({ canvasRef, mapObjects, hoveredOverlay, + selectedOverlay = null, hoveredFortZoc = null, cursorTooltip, labels = [], @@ -223,6 +386,8 @@ export default function MapCanvas({ onMouseLeave, onClick, isHoveringClickable = false, + focus = null, + controlsRef, fill = false, fitMode = "cover", paint, @@ -235,7 +400,185 @@ 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, + // Safari would otherwise back the scaled-down 6400 px map with a + // full-size layer and run out of memory (see restingZoom). + restingZoom: true, + // Mid-gesture the transform runs ahead of React; keep hover picking on + // what is actually on screen. + onLiveTransform: (live) => { + if (!viewportCoordsRef?.current) return; + viewportCoordsRef.current.displayScale = live.displayScale; + viewportCoordsRef.current.translateX = live.translateX; + viewportCoordsRef.current.translateY = live.translateY; + }, + }); + + /** + * Tiles for the base map and the raster modes, Google Maps style: only the + * tiles on screen, at the level the zoom needs. Public maps only — a staff + * map's assets need a bearer token per request, so it keeps the single + * images. Until a pyramid is ready the single image is drawn as before. + */ + const tilesAllowed = !sessionToken; + const baseTiles = useTileManifest(mapId, "base", tilesAllowed).manifest; + const rasterLayer = + PROVINCE_RASTER_MODES.has(mapType) && showsLiveProvinceRaster(mapType, day) + ? `mapdata-${mapType}` + : null; + const rasterTileState = useTileManifest(mapId, rasterLayer, tilesAllowed); + const rasterTiles = rasterTileState.manifest; + + /** + * A region mode's overlays as first shown, flattened and tiled by the + * backend. Switching mode then loads a few tiles instead of one image per + * region (85 for counties). They stay up when a realm is opened: only that + * realm's own layers are drawn separately, over them. + */ + const regionLayer = + regionOverlay === undefined && REGION_TILE_MODES.has(mapType) + ? `regions-${mapType}` + : null; + const regionTiles = useTileManifest(mapId, regionLayer, tilesAllowed); + const useRegionTiles = regionTiles.manifest !== null; + // Until the first answer, draw neither: guessing "no tiles" would start + // dozens of overlay downloads that the tiles make pointless. + const holdRegionOverlays = regionLayer !== null && regionTiles.status === "loading"; + + // Once the map has settled, warm every other region mode so switching to + // one shows its colour at once. + useEffect(() => { + if (!tilesAllowed || regionOverlay !== undefined) return; + const signal = { cancelled: false }; + const timer = setTimeout(() => { + const others = [ + ...[...REGION_TILE_MODES] + .filter((mode) => mode !== mapType) + .map((mode) => `regions-${mode}`), + ...[...PROVINCE_RASTER_MODES] + .filter((mode) => mode !== mapType && showsLiveProvinceRaster(mode, day)) + .map((mode) => `mapdata-${mode}`), + ]; + void prefetchTileBackdrops(mapId, others, signal); + }, 2500); + return () => { + signal.cancelled = true; + clearTimeout(timer); + }; + // Once per map: the current mode only shapes the first run's order. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapId, tilesAllowed, regionOverlay === undefined]); + + // Names wait for the colour under them, so a mode switch does not show + // floating text over bare terrain for the moment the shapes take to land. + const [regionTilesReadyKey, setRegionTilesReadyKey] = useState(null); + const regionTilesKey = regionTiles.manifest + ? `${mapType}:${regionTiles.manifest.version}` + : null; + const labelsShown = + !regionLayer || + (useRegionTiles + ? regionTilesReadyKey === regionTilesKey + : regionTiles.status !== "loading"); + + // The settled view the tile layers fetch for. A new object only when the + // committed transform changes, so mid-gesture the tile layers do not + // re-render at all. + const tileView = useMemo( + () => ({ + displayScale: viewport.displayScale, + translateX: viewport.translateX, + translateY: viewport.translateY, + viewportW: viewport.viewportSize.w, + viewportH: viewport.viewportSize.h, + }), + [ + viewport.displayScale, + viewport.translateX, + viewport.translateY, + viewport.viewportSize.w, + viewport.viewportSize.h, + ] + ); + + const devicePixelRatio = tilePixelRatio(); + // Zoomed out, realm overlays come as reduced copies: decoding a crop at + // full size only to draw it a few pixels across is what the tiles fix for + // the base map. + const lod = overlayLod(viewport.displayScale, devicePixelRatio); + /** The last press on the map was a finger (see the hit target below). */ + const touchPointerRef = useRef(false); + + // The focus. Its shape is the same crop the selection highlight draws, so + // once it has loaded the highlight is ready to stand in for the faded + // colour under it. + const focusShapePath = + focus && regionOverlay === undefined + ? lod > 0 && focus.shapePath.includes("/regions/") + ? `${focus.shapePath}?lod=${lod}` + : focus.shapePath + : ""; + const { url: focusShapeUrl } = useMapAssetUrl( + mapId, + focusShapePath, + sessionToken, + Boolean(focusShapePath) + ); + const focusShapeLoaded = useImageLoaded(focusShapeUrl); + // An opened realm draws its own layers instead; the colours fade once they + // have all loaded, so the realm never shows faded in between. + const focusObjects = useRegionTiles && focus ? focus.objects : []; + const focusObjectsKey = focusObjects.map((obj) => obj.id).join("|"); + const [focusLoaded, setFocusLoaded] = useState<{ key: string; ids: Set }>({ + key: "", + ids: new Set(), + }); + const markFocusObjectLoaded = useCallback( + (id: string) => + setFocusLoaded((current) => { + const ids = current.key === focusObjectsKey ? current.ids : new Set(); + if (ids.has(id)) return current; + return { key: focusObjectsKey, ids: new Set(ids).add(id) }; + }), + [focusObjectsKey] + ); + const focusReady = + focusObjects.length > 0 + ? focusLoaded.key === focusObjectsKey && + focusObjects.every((obj) => focusLoaded.ids.has(obj.id)) + : focusShapeLoaded; + + /** + * The pick canvas is read with `getImageData`, never seen. It used to sit + * in the map layer at full map size (6400 px square, transparent), where + * the browser still had to composite it at every zoom step. Detached, it is + * just memory; a plain div takes its pointer events. + */ + useLayoutEffect(() => { + const canvas = document.createElement("canvas"); + (canvasRef as React.MutableRefObject).current = canvas; + return () => { + const ref = canvasRef as React.MutableRefObject; + if (ref.current === canvas) ref.current = null; + canvas.width = 0; + canvas.height = 0; + }; + }, [canvasRef]); + + 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) => { @@ -265,9 +608,24 @@ export default function MapCanvas({ setMapSize({ w: mapFallbackSize(mapId), h: mapFallbackSize(mapId) }); }, [mapId]); + // With tiles there is no single base image to read a natural size from. useEffect(() => { - viewport.resetViewport({ animated: true }); - }, [mapId, mapType, viewport.resetViewport]); + if (!baseTiles) return; + setMapSize((current) => + current.w === baseTiles.width && current.h === baseTiles.height + ? current + : { w: baseTiles.width, h: baseTiles.height } + ); + }, [baseTiles]); + + // 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(() => { + if (previousMapIdRef.current === mapId) return; + previousMapIdRef.current = mapId; + viewport.resetViewport({ animated: false }); + }, [mapId, viewport.resetViewport]); /** * The province modes drawn as a full-map raster over the base map rather than as @@ -291,7 +649,6 @@ export default function MapCanvas({ ? overlayPathFromHoverUrl(hoveredOverlay.url) : null; - const paintEnabled = paint?.enabled ?? false; const interactionCursor = paintEnabled && !viewport.isPanning ? paintToolCursor(paint!.tool) @@ -305,15 +662,12 @@ export default function MapCanvas({ : `relative max-w-full overflow-hidden ${panelClass}` } > - {cursorTooltip?.text && ( + {cursorTooltip?.text && !viewport.isPanning && (
-

+

{cursorTooltip.text}

{cursorTooltip.hint && ( @@ -326,27 +680,57 @@ export default function MapCanvas({ - { - if (node?.complete) { - syncNaturalMapSize(node); - } - }} - onLoad={handleBaseMapLoad} - /> + {baseTiles ? ( + tileUrl(mapId, "base", baseTiles, level, x, y)} + view={tileView} + // Past one screen pixel per map pixel, show the map's own pixels + // sharp rather than smeared. + className={pixelatedClass(viewport.displayScale)} + /> + ) : ( + { + if (node?.complete) { + syncNaturalMapSize(node); + } + }} + onLoad={handleBaseMapLoad} + /> + )} {showProvinceOverlay && - (liveProvinceRaster ? ( + (liveProvinceRaster && rasterTiles ? ( + + tileUrl(mapId, `mapdata-${mapType}`, rasterTiles, level, x, y) + } + view={tileView} + className={pixelatedClass(viewport.displayScale)} + style={{ opacity: PROVINCE_MODE_OVERLAY_OPACITY }} + // A newer pyramid replaced the one this manifest names (the + // server hands out the last finished one while it builds). + onTileError={rasterTileState.refresh} + /> + ) : liveProvinceRaster ? ( + tileUrl(mapId, `regions-${mapType}`, regionTiles.manifest!, level, x, y) + } + view={tileView} + className="transition-opacity duration-200 ease-out" + style={{ + opacity: focusReady ? FOCUS_MUTED_OPACITY : DRILL_STACK_OVERLAY_OPACITY, + }} + onReady={() => setRegionTilesReadyKey(regionTilesKey)} + onTileError={regionTiles.refresh} + /> + ) : regionOverlay === undefined && holdRegionOverlays ? null : regionOverlay === undefined ? mapObjects .filter((obj) => obj.visible) .map((obj) => ( 0 ? `?lod=${lod}` : "" + }`} sessionToken={sessionToken} crossOrigin="anonymous" alt={`Overlay ${obj.id}`} + // A zoom that changes the reduction swaps the source on the + // same element, so the old copy stays up until the new one + // has loaded rather than the overlay blinking out. + replaceInPlace className={OVERLAY_TRANSITION_CLASS} style={{ ...overlayStyle(obj.overlay, mapSize.w, mapSize.h, { @@ -379,12 +790,47 @@ export default function MapCanvas({ }), opacity: DRILL_STACK_OVERLAY_OPACITY, }} + onLoad={(e) => { + e.currentTarget.style.display = ""; + }} onError={(e) => { e.currentTarget.style.display = "none"; }} /> )) : regionOverlay} + {focusObjects.length > 0 && ( + // One group, so the rim follows the realm's outer edge rather than + // every subject's, and the opacity applies once over the lot. +
+ {focusObjects.map((obj) => ( + 0 ? `?lod=${lod}` : ""}`} + sessionToken={sessionToken} + crossOrigin="anonymous" + alt={`Overlay ${obj.id}`} + replaceInPlace + className={OVERLAY_TRANSITION_CLASS} + style={overlayStyle(obj.overlay, mapSize.w, mapSize.h, { + expand: hoveredPath && obj.path === hoveredPath ? HOVER_OVERLAY_EXPAND : 0, + })} + onLoad={() => markFocusObjectLoaded(obj.id)} + /> + ))} +
+ )} {/* `regionOverlay` is only ever passed by the chronicle. Fort ZoC is a server-rendered `/zoc/{id}.png` regenerated from *today's* state, so @@ -402,6 +848,23 @@ export default function MapCanvas({ alt="Fort zone of control" /> )} + {regionOverlay === undefined && selectedOverlay && ( + + )} {regionOverlay === undefined && hoveredOverlay && ( )} {isMarkerMapMode(mapType) && wars.length > 0 && ( @@ -435,6 +903,14 @@ export default function MapCanvas({ displayScale={viewport.displayScale} layer="base" /> +
+
- { + touchPointerRef.current = event.pointerType === "touch"; + if (touchPointerRef.current) onMouseLeave(); + }} + onPointerMove={(event) => { + if (event.pointerType === "mouse") touchPointerRef.current = false; + }} + onMouseMove={(event) => { + if (touchPointerRef.current && !provinceHoverBlocksRegionPick(mapType)) { + onMouseLeave(); + return; + } + onMouseMove(event); + }} onMouseLeave={onMouseLeave} - onClick={onClick} + onClick={(event) => { + if (viewport.consumeDragClick()) return; + // A tap's own click, not the mouse move a browser may or may not + // send before it, is what shows a raster mode's province. + if (touchPointerRef.current && provinceHoverBlocksRegionPick(mapType)) { + onMouseMove(event); + } + onClick(event); + }} /> {paint ? ( () + : hiddenMarkerLabels(markers, displayScale); + const layerZ = layer === "hovered" ? MARKER_LAYER_Z_HOVERED : MARKER_LAYER_Z_ABOVE_LABELS; @@ -80,7 +88,8 @@ function MapMarkerLayer({ marker.mapX, marker.mapY, marker.markerSize, - marker.kind + marker.kind, + markerZoomScale(displayScale) ); const visible = alwaysVisible || shouldShowMapMarker(marker, displayScale); @@ -88,7 +97,10 @@ function MapMarkerLayer({ const baseScale = marker.baseScale ?? 1; const scale = baseScale * (hovered ? MARKER_HOVER_SCALE : 1); const src = resolveMarkerImageSrc(marker.kind, marker.markerSize); - const showLabel = !marker.showLabelOnlyOnHover || hovered; + // Hover always names a pin, even one whose label gave way to a + // more prominent neighbour. + const showLabel = + hovered || (!marker.showLabelOnlyOnHover && !hiddenLabels.has(marker.id)); const iconOffset = (layout.size - layout.iconSize) / 2; const ringSize = layout.iconSize * 1.2; diff --git a/frontend/app/components/map/MapPageLayout.tsx b/frontend/app/components/map/MapPageLayout.tsx deleted file mode 100644 index e6106524..00000000 --- a/frontend/app/components/map/MapPageLayout.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import type { ReactNode } from "react"; -import DraggablePanel from "./DraggablePanel"; - -type MapPageLayoutProps = { - mapDisplayName: string; - children: ReactNode; - mapModeSelectorMobile: ReactNode; - mapModeSelectorDesktop: ReactNode; - drillStackBar: ReactNode; - desktopSidePanel: ReactNode; - headerAction?: ReactNode; - /** Cover/contain fit-mode switch, rendered inside the Controls legend. */ - fitModeToggle?: ReactNode; - /** War-planning paint toolbar, floated as its own draggable panel. */ - paintPanel?: ReactNode; -}; - -const overlayPanelClass = - "rounded-lg border border-[color-mix(in_srgb,var(--tfmc-cream)_15%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_92%,transparent)] shadow-xl backdrop-blur-sm"; - -/** - * Full-bleed map shell: the map fills the entire area below the global nav, - * pannable/zoomable edge to edge, with controls floating on top of it instead - * of sharing page layout with it. Modeled on map viewers like WesterosCraft's - * Dynmap page — mode selector top-left, nation detail top-right, active-layer - * breadcrumb bottom-left. - * - * `children` (the map itself) is rendered exactly once. Its size comes purely - * from CSS on the wrapper around it — `aspect-square` on mobile (there's no - * room to float controls over a map that dense, so the mode selector and - * title stay in normal document flow above it instead), `flex-1` filling the - * remaining viewport height on desktop. Rendering the map twice to get - * different mobile/desktop treatments would double every fetch, canvas - * layer, and ResizeObserver it owns — only the cheap overlay controls - * (a dropdown, some text) get a separate mobile/desktop copy. - */ -export default function MapPageLayout({ - mapDisplayName, - children, - mapModeSelectorMobile, - mapModeSelectorDesktop, - drillStackBar, - desktopSidePanel, - headerAction, - fitModeToggle, - paintPanel, -}: MapPageLayoutProps) { - return ( -
- {/* 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 0156d6d5..00000000 --- a/frontend/app/components/map/MapToolbar.test.ts +++ /dev/null @@ -1,27 +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", - "infrastructure", - "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(12); - 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 b69cbaca..00000000 --- a/frontend/app/components/map/MapToolbar.tsx +++ /dev/null @@ -1,142 +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: "infrastructure", label: "Infrastructure" }, - { 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..34285601 100644 --- a/frontend/app/components/map/MapViewport.tsx +++ b/frontend/app/components/map/MapViewport.tsx @@ -5,8 +5,15 @@ import type { Size } from "../../lib/mapViewportMath"; export type MapViewportProps = { mapSize: Size; viewportRef: RefObject; + /** The scaled layer; `useMapViewport` moves it directly during gestures. */ + contentRef?: RefObject; transformStyle: string; transformTransition?: string; + /** + * CSS `zoom` on the map content, the resting scale `useMapViewport` keeps + * out of the transform (see its `restingZoom`). 1 by default. + */ + zoom?: number; cursorClassName: string; isPanning: boolean; children: ReactNode; @@ -18,17 +25,25 @@ 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({ mapSize, viewportRef, + contentRef, transformStyle, transformTransition, + zoom = 1, cursorClassName, isPanning, children, fill = false, + capturesTouch = false, }: MapViewportProps) { const { w: mapW, h: mapH } = mapSize; @@ -37,24 +52,29 @@ export default function MapViewport({ ? { aspectRatio: `${mapW} / ${mapH}` } : undefined; + // The transform moves (and, mid-gesture, scales) the zoomed content; the + // map itself is laid out at its size on screen. const innerStyle: CSSProperties = { - width: mapW, - height: mapH, + width: mapW * zoom, + height: mapH * zoom, transform: transformStyle, transformOrigin: "0 0", transition: transformTransition, }; + const zoomedStyle: CSSProperties = { width: mapW, height: mapH, zoom }; return (
-
- {children} +
+
+ {children} +
); 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 - * + +
+ Leader + {guild.leader ? ( + + {guild.leader} + + ) : ( + + Character not yet known + + )} +
+ +
+ {guild.members} + {tradeProvinces !== null ? ( + + {tradeProvinces} {tradeProvinces === 1 ? "province" : "provinces"} + + ) : null} + + {seat ? ( + + ) : ( + "—" + )} + +
+ +
+ {seatPlaced ? ( + + ) : null} + +
+ + {guild.branches.length > 0 ? ( +
+
    + {guild.branches.map((branch) => ( +
  • + + {branch.label} + + + Level {branch.level} + +
  • + ))} +
+
+ ) : null} + + ); +} + +/** A clickable list of guilds, for the realm and place cards. */ +export function GuildList({ + guilds, + onSelectGuild, +}: { + guilds: GuildProfile[]; + onSelectGuild: (key: string) => void; +}) { + return ( +
    + {guilds.map((guild) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/frontend/app/components/map/shell/MapDrillBreadcrumb.tsx b/frontend/app/components/map/shell/MapDrillBreadcrumb.tsx new file mode 100644 index 00000000..49e9cab5 --- /dev/null +++ b/frontend/app/components/map/shell/MapDrillBreadcrumb.tsx @@ -0,0 +1,76 @@ +import { Fragment } from "react"; + +import { cleanRegionName } from "@/app/lib/mapLabels"; + +import type { DrillLayer } from "../drillUtils"; +import { ChevronIcon, CloseIcon } from "./MapIcons"; + +type MapDrillBreadcrumbProps = { + rootLabel: string; + drillStack: DrillLayer[]; + onSelectLayer: (index: number) => void; + onReset: () => void; +}; + +/** + * Where the map is drilled to, as a trail: the mode, then each realm whose + * subjects are open. Any step goes back to that level; the cross closes them + * all. + */ +export default function MapDrillBreadcrumb({ + rootLabel, + drillStack, + onSelectLayer, + onReset, +}: MapDrillBreadcrumbProps) { + if (drillStack.length === 0) return null; + + 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..724e5e2f --- /dev/null +++ b/frontend/app/components/map/shell/MapIcons.tsx @@ -0,0 +1,234 @@ +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. A shield for realms, crowns for the title tiers, a thing each for the world modes. */ + +export const RealmIcon = (props: IconProps) => ( + + + + +); + +/* + * The title tiers, as one family of crowns that grows with rank, the way CK3 + * marks them: a county's low coronet of pearls, a duchy's taller one of leaves, + * a kingdom's crown with an arch and cross, an empire's double-arched crown + * with an orb. Each reads from its silhouette alone at bar size. + */ +export const CountyIcon = (props: IconProps) => ( + + + + + + + +); + +export const DuchyIcon = (props: IconProps) => ( + + + + + +); + +export const KingdomIcon = (props: IconProps) => ( + + + + + + +); + +export const EmpireIcon = (props: IconProps) => ( + + + + + + + + + + +); + +/* + * The world modes: each a thing the mode is about, not a chart. Provinces is + * land cut into territories, trade a merchant's cog, prosperity a full purse, + * infrastructure a bridge, infestation the monsters' skull. + */ +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 InfrastructureIcon = (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, + infrastructure: InfrastructureIcon, + 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..36c5e568 --- /dev/null +++ b/frontend/app/components/map/shell/MapLayersMenu.tsx @@ -0,0 +1,122 @@ +"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"; + /** Just the icon, as Google Maps' phone app does; the word stays for screen readers. */ + iconOnly?: boolean; +}; + +/** + * 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", + iconOnly = false, +}: 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-[color-mix(in_srgb,var(--tfmc-cream)_10%,transparent)] 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..320b37e1 --- /dev/null +++ b/frontend/app/components/map/shell/MapModeBar.tsx @@ -0,0 +1,93 @@ +"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 icon buttons in a tray along the bottom edge: + * realms, then the title tiers from empires down, then the world 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) { + // iOS Safari draws its scroll indicator under a swiped row whatever the + // scrollbar CSS says. The row gets room below the chips for it, and the + // wrapper clips that strip off. + 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..3ad47c06 --- /dev/null +++ b/frontend/app/components/map/shell/MapPlaque.tsx @@ -0,0 +1,39 @@ +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. On a phone it + * is one slim row, search and actions: the search box already names the map + * ("Search Vardera"), and the screen has little height to spare. + */ +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..af581c21 --- /dev/null +++ b/frontend/app/components/map/shell/MapSearch.tsx @@ -0,0 +1,175 @@ +"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(); + } + }} + // 16 px on phones: Safari zooms the page into any smaller field. + className="h-10 min-w-0 flex-1 bg-transparent text-base text-[var(--tfmc-cream)] md:text-sm 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-[color-mix(in_srgb,var(--tfmc-cream)_10%,transparent)]" : "" + }`} + > + {entry.kind !== "place" ? ( + + ) : ( + + )} + + + {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..6ffe1286 --- /dev/null +++ b/frontend/app/components/map/shell/MapShell.tsx @@ -0,0 +1,173 @@ +"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, along the bottom while no sheet is open. */ + modeChips: ReactNode; + /** Drill-down breadcrumb, top centre; null when not drilled in. */ + breadcrumb?: ReactNode; + /** A short note while a map mode loads; null when idle. */ + status?: string | null; + /** 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, top left under the search, opening down. */ + 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, laid out like Google + * Maps. 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: one slim search + * row across the top with layers (and the breadcrumb) under it, the mode + * chips along the bottom, details in a bottom sheet, and no zoom buttons + * (pinch zooms). 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, + status, + 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]); + + // The map fills the screen below the header and nothing else is on the + // page, so the page itself must not move: on an iPhone a drag on the panels + // scrolled or rubber-banded it, sliding them under the header. Scrolling + // inside the panels is unaffected. + useEffect(() => { + const root = document.documentElement; + root.classList.add("map-fullscreen"); + return () => root.classList.remove("map-fullscreen"); + }, []); + + const hasDetails = Boolean(details); + const chronicleTop = chronicle ? "max-xl:top-[10.75rem]" : ""; + + return ( + // pan-x pan-y: panels still scroll, but a pinch on them cannot zoom the + // page (the map handles its own pinch). +
+
{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} + {layersMobile || breadcrumb ? ( +
+ {layersMobile ?
{layersMobile}
: null} + {breadcrumb ?
{breadcrumb}
: null} +
+ ) : null} +
+ + {details ? ( +
+ +
+ {details} +
+
+ ) : null} +
+ + {breadcrumb ? ( + + ) : null} + + {status ? ( +
+ {status} +
+ ) : null} + + {paintPanel ? ( + + ) : null} + + {/* Over the left column, so the Layers popover opens above the panel. */} +
+
{layers}
+
{modeBar}
+
{zoomControls}
+
+ + {/* Phone: the mode chips along the bottom while no sheet is open (a + sheet takes the bottom, and changing mode closes it anyway). */} + {hasDetails ? null : ( +
+
{modeChips}
+
+ )} +
+ ); +} diff --git a/frontend/app/components/map/shell/MapZoomControls.tsx b/frontend/app/components/map/shell/MapZoomControls.tsx new file mode 100644 index 00000000..443ed1dc --- /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/PlacePanel.tsx b/frontend/app/components/map/shell/PlacePanel.tsx new file mode 100644 index 00000000..edd3f330 --- /dev/null +++ b/frontend/app/components/map/shell/PlacePanel.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { fetchMapJson } from "@/lib/map/api"; +import type { PlaceProfile } from "@/app/lib/map/placeProfile"; +import { + buildProvinceCountyNames, + type CountyNameEntry, +} from "@/app/lib/map/provinceCounty"; +import { cleanRegionName } from "@/app/lib/mapLabels"; +import { resolveMarkerImageSrc, type MapMarker } from "@/app/lib/mapMarkers"; + +import type { MapId, RegionRecord } from "../types"; +import { Fact, RegionLink, Section } from "./RealmPanel"; +import { GuildList } from "./GuildPanel"; +import { guildsInProvince } from "@/app/lib/map/guildProfile"; +import { CloseIcon, FocusIcon, RealmIcon } from "./MapIcons"; + +/** Province id -> county name per map, shared by every place panel. */ +const countyNamesByMap = new Map>>(); + +function countyNames(mapId: MapId, sessionToken?: string | null) { + let names = countyNamesByMap.get(mapId); + if (!names) { + names = fetchMapJson>(`/${mapId}/data/county`, { + sessionToken, + }) + .then(buildProvinceCountyNames) + .catch(() => new Map()); + countyNamesByMap.set(mapId, names); + } + return names; +} + +function useCountyName( + mapId: MapId, + provinces: number[], + sessionToken?: string | null +): string | null { + const [name, setName] = useState(null); + const first = provinces[0]; + useEffect(() => { + setName(null); + if (first === undefined) return; + let cancelled = false; + void countyNames(mapId, sessionToken).then((names) => { + if (!cancelled) setName(names.get(first) ?? null); + }); + return () => { + cancelled = true; + }; + }, [mapId, first, sessionToken]); + return name; +} + +type PlacePanelProps = { + /** Open a guild's card. */ + onSelectGuild: (guildKey: string) => void; + mapId: MapId; + place: PlaceProfile; + marker: MapMarker; + regionData: RegionRecord | null; + sessionToken?: string | null; + onSelectRegion: (regionId: string) => void; + onFocusPoint: (mapX: number, mapY: number) => void; + onClose: () => void; +}; + +/** + * Details for a settlement, installation or battle marker: the place-card a + * map site opens when you click a pin. Names the owning realm with a link to + * its own panel. + */ +export function PlacePanelContent({ + mapId, + place, + marker, + regionData, + sessionToken, + onSelectRegion, + onFocusPoint, + onClose, + onSelectGuild, +}: PlacePanelProps) { + const guildsHere = + place.provinces.length > 0 && place.kindLabel !== "Battle" + ? guildsInProvince(regionData, place.provinces[0]) + : []; + const county = useCountyName(mapId, place.provinces, sessionToken); + const owner = place.ownerId ? regionData?.[place.ownerId] : undefined; + const ownerName = place.ownerId + ? cleanRegionName(owner?.name ?? place.ownerId) || place.ownerId + : null; + // A settlement is a capital of its realm or of a guild, never the other way + // round: say whose, by name. + const onlyGuild = guildsHere.length === 1 ? guildsHere[0] : null; + const eyebrow = + place.kindLabel === "Capital" && ownerName + ? `Capital of ${ownerName}` + : place.kindLabel === "Guild capital" && onlyGuild + ? `Capital of ${onlyGuild.name}` + : place.kindLabel; + + return ( +
+
+
+ +
+
+

{eyebrow}

+

+ {place.name} +

+ {onlyGuild && place.kindLabel === "Guild capital" ? ( +

+ + + {place.ownerId ? ( + <> + · + + + ) : null} +

+ ) : place.ownerId ? ( +

+ + +

+ ) : place.kindLabel !== "Battle" ? ( +

No realm

+ ) : null} +
+
+ + + + {place.note ? ( +

{place.note}

+ ) : null} + +
+ {place.population !== null ? {place.population} : null} + {place.hubSlots !== null ? ( + + {place.hubs ?? 0} of {place.hubSlots} + + ) : null} + {county ? {county} : null} + {place.provinces.length > 1 ? ( + {place.provinces.length} + ) : null} + + + x {Math.round(place.mapX)}, z {Math.round(place.mapY)} + + +
+ +
+ + {place.ownerId ? ( + + ) : null} +
+ + {guildsHere.length > 0 ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/frontend/app/components/map/shell/RealmPanel.tsx b/frontend/app/components/map/shell/RealmPanel.tsx new file mode 100644 index 00000000..08445a4f --- /dev/null +++ b/frontend/app/components/map/shell/RealmPanel.tsx @@ -0,0 +1,437 @@ +"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 { realmGuilds } from "@/app/lib/map/guildProfile"; + +import MapAuthImage from "../MapAuthImage"; +import { buildRegionInfo } from "../regionInfo"; +import type { MapId, MapMode, RegionRecord, SettlementMarker } from "../types"; +import { CloseIcon, FocusIcon, SubjectsIcon } from "./MapIcons"; +import { GuildList } from "./GuildPanel"; + +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; + /** Open a settlement's own card, e.g. the capital's. */ + onSelectPlace?: (markerId: string) => void; + /** Open a guild's card. */ + onSelectGuild?: (guildKey: string) => 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; +} + +export 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 ( +
+ +
+ ); +} + +export 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 ( + + ); +} + +export function Fact({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +export 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, + onSelectPlace, + } = props; + const capital = realmCapitalSettlement(profile, settlements); + const relations = sortRealmRelations(profile.relations); + const guilds = realmGuilds(profile.id, regionData[profile.id]); + 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} + + {props.onSelectGuild && guilds.length > 0 ? ( +
    + +
    + ) : null} + + {relations.length > 0 ? ( +
    +
      + {relations.map((relation) => ( + + ))} +
    +
    + ) : null} + + ); +} + +/** + * The detail view for whatever is selected on the map: banner, 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 ? ( + <> + {/* Always there, so the ruler never seems to have gone missing. A + realm whose leader SimpleFactions has not yet seen online under + a character says so, rather than naming their account. */} +
    + + {profile.rulerTitle ?? "Ruler"} + + {profile.leader ? ( + + {profile.leader} + + ) : ( + + Character not yet known + + )} +
    + + + ) : ( + <> +
    + {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/regionPick.ts b/frontend/app/hooks/regionPick.ts index abdd9b14..2ac46362 100644 --- a/frontend/app/hooks/regionPick.ts +++ b/frontend/app/hooks/regionPick.ts @@ -32,6 +32,25 @@ export function provinceHoverBlocksRegionPick(mapType: string): boolean { ); } +/** A region named outright (a pointed-at name), resolved as a pick would be. */ +export function resolveRegionById( + pickId: string, + getHoverRegion: GetHoverRegion, + mapType: string, + mapId: string, + regionData: RegionRecord | null +): RegionPickResult | null { + if (!regionData?.[pickId]) return null; + const { regionId, imagePath, overlay, region } = getHoverRegion( + mapType, + mapId, + pickId, + regionData + ); + if (!regionId || !region) return null; + return { pickId, regionId, region, imagePath, overlay }; +} + export function resolveRegionAtPickPixel( ctx: CanvasRenderingContext2D, x: number, diff --git a/frontend/app/hooks/useMapHover.ts b/frontend/app/hooks/useMapHover.ts index 9d82a4b7..e984326f 100644 --- a/frontend/app/hooks/useMapHover.ts +++ b/frontend/app/hooks/useMapHover.ts @@ -9,12 +9,19 @@ import { useRegionHover } from "./useRegionHover"; import { provinceHoverBlocksRegionPick, resolveRegionAtPickPixel, + resolveRegionById, } from "./regionPick"; import type { MapId, MapMode, MapObject, RegionInfo, RegionRecord, FortMarker } from "../components/map/types"; import type { HoverOverlay } from "../components/map/types"; import type { MapMarker } from "../lib/mapMarkers"; -import { filterVisibleMapMarkers, isMarkerMapMode, pickMapMarkerAt } from "../lib/mapMarkers"; +import { + filterVisibleMapMarkers, + hiddenMarkerLabels, + isMarkerMapMode, + pickMapMarkerAt, +} from "../lib/mapMarkers"; import { lookupFortZocOverlay } from "../lib/fortZoc"; +import { pickRegionLabelAt, type NationLabelSpec } from "../lib/mapLabels"; import type { ProvinceIdGrid } from "../lib/map/chroniclePaint"; type UseMapHoverProps = { @@ -44,6 +51,8 @@ type UseMapHoverProps = { mapDisplayName: string; mapObjects: MapObject[]; markers?: MapMarker[]; + /** The region names drawn on the map: pointing at one is pointing at its region. */ + labels?: NationLabelSpec[]; forts?: FortMarker[]; setHoveredMarkerId?: (id: string | null) => void; setHoveredFortZoc?: (overlay: HoverOverlay | null) => void; @@ -79,6 +88,17 @@ export function mapObjectsVisibilityKey(mapObjects: MapObject[]): string { .join("|"); } +/** The region whose drawn name is under map point (x, y), where names are hoverable. */ +function labelRegionAt( + props: UseMapHoverProps, + x: number, + y: number, + displayScale: number +): string | null { + if (!props.labels?.length || provinceHoverBlocksRegionPick(props.mapType)) return null; + return pickRegionLabelAt(props.labels, x, y, displayScale); +} + export function useMapHover(props: UseMapHoverProps) { const { canvasRef, @@ -131,6 +151,9 @@ export function useMapHover(props: UseMapHoverProps) { return id ? id : null; }, []); + /** Re-runs hover at the last pointer position; set below, once it can. */ + const replayHoverRef = useRef<() => void>(() => {}); + const { handleProvinceHover } = useProvinceHover({ mapId, mapType, @@ -138,7 +161,11 @@ export function useMapHover(props: UseMapHoverProps) { guildNameCacheRef, sessionToken: props.sessionToken, day: props.day ?? null, - resolveProvinceId, + // Only offered once a grid is in memory, so the hover hook knows when it + // must fall back to asking the server. + resolveProvinceId: props.chronicleGrid ? resolveProvinceId : undefined, + // Show what the pointer (or the last tap) is over once the figures land. + onDataReady: () => replayHoverRef.current(), }); const { handleRegionHover, resetHoverCache } = useRegionHover({ @@ -155,11 +182,11 @@ export function useMapHover(props: UseMapHoverProps) { handleProvinceHoverRef.current = handleProvinceHover; const rafRef = useRef(null); - const pendingEventRef = useRef | null>(null); + const pendingEventRef = useRef | null>(null); const lastPointerRef = useRef(null); const [isHoveringClickable, setIsHoveringClickable] = useState(false); - const processHover = useCallback((event: React.MouseEvent) => { + const processHover = useCallback((event: React.MouseEvent) => { const current = propsRef.current; if (current.loading) return; @@ -191,19 +218,44 @@ export function useMapHover(props: UseMapHoverProps) { ? filterVisibleMapMarkers(current.markers, displayScale) : []; const markerHit = visibleMarkers.length - ? pickMapMarkerAt(visibleMarkers, coords.x, coords.y) + ? pickMapMarkerAt( + visibleMarkers, + coords.x, + coords.y, + displayScale, + hiddenMarkerLabels(current.markers ?? [], displayScale) + ) : null; current.setHoveredMarkerId?.(markerHit?.id ?? null); if (markerHit) { + // A pin sits inside a realm: keep that realm lit under the pointer, as + // if the pin were not there, and let only the tooltip belong to the pin. + const markerPickPixel = mapPixelToPickCanvas( + coords.x, + coords.y, + current.viewportCoordsRef.current?.mapSize, + canvas + ); + if (markerPickPixel) { + handleRegionHoverRef.current( + ctx, + markerPickPixel.x, + markerPickPixel.y, + coords.screenX, + coords.screenY, + () => {} + ); + } else { + current.setHoveredOverlay(null); + current.setSelectedRegionId(null); + resetHoverCacheRef.current(); + } current.setCursorTooltip(markerHit.hoverText ? { x: coords.screenX, y: coords.screenY, text: markerHit.hoverText, hint: markerHit.hoverHint, } : null); - current.setHoveredOverlay(null); - current.setSelectedRegionId(null); - resetHoverCacheRef.current(); if (isMarkerMapMode(current.mapType)) { current.setHoveredFortZoc?.( lookupFortZocOverlay(markerHit, current.forts ?? []) @@ -217,14 +269,38 @@ export function useMapHover(props: UseMapHoverProps) { current.setHoveredFortZoc?.(null); - if ( - handleProvinceHoverRef.current( - coords.x, - coords.y, - coords.screenX, - coords.screenY - ) - ) { + // A name drawn across water or a neighbour's land still names its region. + const labelRegionId = labelRegionAt(current, coords.x, coords.y, displayScale); + if (labelRegionId) { + setIsHoveringClickable( + handleRegionHoverRef.current( + ctx, + 0, + 0, + coords.screenX, + coords.screenY, + current.setCursorTooltip, + labelRegionId + ) + ); + return; + } + + const province = handleProvinceHoverRef.current( + coords.x, + coords.y, + coords.screenX, + coords.screenY + ); + if (province.consumed) { + // `undefined`: the answer is on its way and will draw itself. + if (province.lines !== undefined) { + current.setCursorTooltip( + province.lines + ? { x: coords.screenX, y: coords.screenY, text: province.lines.join("\n") } + : null + ); + } if (provinceHoverBlocksRegionPick(current.mapType)) { current.setSelectedRegionId(null); } @@ -232,6 +308,27 @@ export function useMapHover(props: UseMapHoverProps) { return; } + // Trade: the province's breakdown rides in the region's tooltip, one box + // drawn once per frame, instead of two tooltips taking turns. + const provinceLines = province.lines ?? null; + const setTooltip: typeof current.setCursorTooltip = provinceLines + ? (tooltip) => + current.setCursorTooltip( + tooltip + ? { + ...tooltip, + hint: [...provinceLines.slice(1), tooltip.hint] + .filter(Boolean) + .join("\n"), + } + : { + x: coords.screenX, + y: coords.screenY, + text: provinceLines.slice(1).join("\n"), + } + ) + : current.setCursorTooltip; + const pickPixel = mapPixelToPickCanvas( coords.x, coords.y, @@ -252,11 +349,21 @@ export function useMapHover(props: UseMapHoverProps) { pickPixel.y, coords.screenX, coords.screenY, - current.setCursorTooltip + setTooltip ); setIsHoveringClickable(clickable); }, []); + replayHoverRef.current = () => { + const pointer = lastPointerRef.current; + if (!pointer || propsRef.current.loading) return; + resetHoverCacheRef.current(); + processHover({ + clientX: pointer.clientX, + clientY: pointer.clientY, + } as React.MouseEvent); + }; + const mapObjectsVisibility = useMemo( () => mapObjectsVisibilityKey(mapObjects), [mapObjects] @@ -277,10 +384,10 @@ export function useMapHover(props: UseMapHoverProps) { processHover({ clientX: pointer.clientX, clientY: pointer.clientY, - } as React.MouseEvent); + } as React.MouseEvent); }, [loading, mapObjectsVisibility, fortsKey, markers?.length, processHover]); - const onMouseMove = (event: React.MouseEvent) => { + const onMouseMove = (event: React.MouseEvent) => { if (loading) return; lastPointerRef.current = { @@ -316,7 +423,7 @@ export function useMapHover(props: UseMapHoverProps) { }; const pickRegionAtEvent = useCallback( - (event: React.MouseEvent): string | null => { + (event: React.MouseEvent): string | null => { const current = propsRef.current; if (current.loading || !current.regionData) return null; @@ -340,7 +447,13 @@ export function useMapHover(props: UseMapHoverProps) { : []; if ( visibleMarkers.length && - pickMapMarkerAt(visibleMarkers, coords.x, coords.y) + pickMapMarkerAt( + visibleMarkers, + coords.x, + coords.y, + displayScale, + hiddenMarkerLabels(current.markers ?? [], displayScale) + ) ) { return null; } @@ -349,6 +462,17 @@ export function useMapHover(props: UseMapHoverProps) { return null; } + const labelRegionId = labelRegionAt(current, coords.x, coords.y, displayScale); + if (labelRegionId) { + return resolveRegionById( + labelRegionId, + current.getHoverRegion, + current.mapType, + current.mapId, + current.regionData + )?.regionId ?? null; + } + const pickPixel = mapPixelToPickCanvas( coords.x, coords.y, @@ -372,5 +496,36 @@ export function useMapHover(props: UseMapHoverProps) { [rgbToId] ); - return { onMouseMove, onMouseLeave, isHoveringClickable, pickRegionAtEvent }; + /** The marker under a click, if any; markers sit above regions. */ + const pickMarkerAtEvent = useCallback( + (event: React.MouseEvent): MapMarker | null => { + const current = propsRef.current; + const canvas = current.canvasRef.current; + if (!canvas || !current.markers?.length) return null; + const coords = getMapCoords( + event, + canvas, + current.mapId, + current.viewportCoordsRef.current + ); + if (!coords) return null; + const displayScale = current.viewportCoordsRef.current?.displayScale ?? 0; + return pickMapMarkerAt( + filterVisibleMapMarkers(current.markers, displayScale), + coords.x, + coords.y, + displayScale, + hiddenMarkerLabels(current.markers, displayScale) + ); + }, + [] + ); + + return { + onMouseMove, + onMouseLeave, + isHoveringClickable, + pickRegionAtEvent, + pickMarkerAtEvent, + }; } diff --git a/frontend/app/hooks/useMapViewport.ts b/frontend/app/hooks/useMapViewport.ts index 8ccf0ce6..6a46b4a9 100644 --- a/frontend/app/hooks/useMapViewport.ts +++ b/frontend/app/hooks/useMapViewport.ts @@ -1,388 +1,420 @@ 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; + /** + * Called on every frame of a gesture with the transform actually on + * screen, before React state catches up. Hover picking reads it so a + * pointer over the map resolves against what the reader sees. + */ + onLiveTransform?: (live: { + displayScale: number; + translateX: number; + translateY: number; + }) => void; + /** + * Hold the resting scale as CSS `zoom` on the content, leaving the transform + * a plain translate once the map settles; gestures and animations still + * scale with the transform, relative to that zoom. + * + * WebKit (Safari, every iPhone browser) sizes a layer's backing store from + * its own CSS size times the screen density, ignoring an ancestor's scale. + * The 6400 px map scaled down to fit a phone got a store 19,200 px square + * (~1.5 GB) once something made it its own layer, as a mode switch did; + * iOS killed the page and Safari reported "a problem repeatedly occurred". + * Zoomed instead, the content is laid out at its size on screen. + */ + restingZoom?: boolean; }; - +export type MapFocusInset = { + left?: number; + right?: number; + top?: number; + bottom?: number; +}; export type UseMapViewportResult = { - viewportRef: RefObject; - + /** + * The scaled content element. While a gesture runs the hook moves it + * directly, so wheel, drag and pinch cost one style write per frame + * instead of a React render of every overlay, label and marker. + */ + contentRef: RefObject; userScale: number; - + /** Measured size of the viewport element, in CSS pixels. */ + viewportSize: Size; translateX: number; - translateY: number; - displayScale: number; - fitScale: number; - isPanning: boolean; - transformStyle: string; - transformTransition?: string; - + /** CSS `zoom` for the content (see `restingZoom`); 1 when not in use. */ + zoom: number; 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)`; +/** + * How long the map must be still before a gesture's transform is committed to + * React state. Until then labels and markers keep their last layout and the + * GPU scales the cached layer, which is what makes the zoom smooth. + */ +const GESTURE_SETTLE_MS = 140; 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, + onLiveTransform, + restingZoom = false, }: UseMapViewportOptions): UseMapViewportResult { - const viewportRef = useRef(null); - + const contentRef = useRef(null); const [viewportSize, setViewportSize] = useState({ w: 0, h: 0 }); - const [transform, setTransform] = useState(INITIAL_TRANSFORM); - const [isPanning, setIsPanning] = useState(false); + const [transition, setTransition] = useState(undefined); - const [isResetAnimating, setIsResetAnimating] = useState(false); - - - + /** + * The transform on screen. Equal to `transform` except mid-gesture, when it + * runs ahead of React state until `commitLive` catches state up. + */ const transformRef = useRef(transform); - - transformRef.current = transform; - - - + /** The scale held as CSS `zoom` (see `restingZoom`); 1 when not in use. */ + const [zoomBase, setZoomBase] = useState(1); + const appliedZoom = restingZoom ? zoomBase : 1; + const appliedZoomRef = useRef(appliedZoom); + appliedZoomRef.current = appliedZoom; + const liveActiveRef = useRef(false); + if (!liveActiveRef.current) transformRef.current = transform; + const onLiveTransformRef = useRef(onLiveTransform); + onLiveTransformRef.current = onLiveTransform; + const settleTimerRef = useRef | null>(null); 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); + + /** Hand the on-screen transform back to React state. */ + const commitLive = useCallback(() => { + if (settleTimerRef.current !== null) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; } + if (!liveActiveRef.current) return; + liveActiveRef.current = false; + contentRef.current?.removeAttribute("data-gesturing"); + setTransform(transformRef.current); + }, []); - setIsResetAnimating(false); + /** + * Put `next` on screen now, outside React. Falls back to state when the + * content element is not attached (the editor's canvas). + */ + const presentLive = useCallback( + (next: ViewportTransform) => { + const content = contentRef.current; + if (!content) { + setTransform(next); + return; + } + transformRef.current = next; + liveActiveRef.current = true; + const displayScale = computeDisplayScale( + computeFitScale(viewportSizeRef.current, mapSizeRef.current, fitModeRef.current), + next.userScale + ); + // No `will-change` here: on a 6400 px layer it makes the browser + // rasterise one enormous texture, which measured slower than letting it + // re-tile. `data-gesturing` lets costly effects (the selection's + // outline filter) stand down until the map settles. + content.setAttribute("data-gesturing", ""); + content.style.transition = "none"; + content.style.transform = viewportTransformStyle( + displayScale / appliedZoomRef.current, + next.translateX, + next.translateY + ); + onLiveTransformRef.current?.({ + displayScale, + translateX: next.translateX, + translateY: next.translateY, + }); + if (settleTimerRef.current !== null) clearTimeout(settleTimerRef.current); + settleTimerRef.current = setTimeout(commitLive, GESTURE_SETTLE_MS); + }, + [commitLive] + ); + const clearTransition = useCallback(() => { + if (transitionTimerRef.current !== null) { + clearTimeout(transitionTimerRef.current); + transitionTimerRef.current = null; + } + setTransition(undefined); }, []); - + /** Apply `next` with a CSS transition that is dropped once it has run. */ + const animateTo = useCallback( + (next: ViewportTransform, css: string, ms: number) => { + commitLive(); + clearTransition(); + // The live path wrote `transition: none` straight onto the element; + // React will not clear what it did not set. + contentRef.current?.style.removeProperty("transition"); + setTransform(next); + if (prefersReducedMotion()) return; + setTransition(css); + transitionTimerRef.current = setTimeout(() => { + transitionTimerRef.current = null; + setTransition(undefined); + }, ms); + }, + [clearTransition, commitLive] + ); 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); + commitLive(); + }, [commitLive]); 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 +423,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 +431,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 +445,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 +456,366 @@ 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); + }; - - setTransform((current) => - + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + clearTransition(); + const cursor = toViewportPoint(event); + presentLive( applyClampedTransform( - zoomAtPoint( - viewportSizeRef.current, - mapSizeRef.current, - - current, - + transformRef.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 + ); + presentLive( + 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( - + presentLive( 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(); - - }; - - - - const endPanOnLeave = () => { - - if (!panStateRef.current) 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 endPanOnBlur = () => { - - if (!panStateRef.current) return; - - cancelPan(); - + const endOnBlur = () => { + if (gestureRef.current) endGesture(); }; - - 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, presentLive]); useEffect(() => { + if (!enabled || !keyboard) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + if (isTypingTarget(event.target)) return; + // Only keys aimed at the map (or at nothing in particular): with a card, + // a button or a scrolling panel focused, arrows belong to that. + const target = event.target; + const fromMap = + target === document.body || + target === document.documentElement || + (target instanceof Node && viewportRef.current?.contains(target) === true); + if (!fromMap) 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); + } + if (settleTimerRef.current !== null) { + clearTimeout(settleTimerRef.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; - }, - [] - ); - - + // Once nothing is moving the map, hand its scale to `zoom`. Until then (a + // gesture, an animated move) the transform carries the change relative to + // the zoom already applied, so nothing jumps. + useEffect(() => { + if (!restingZoom || transition !== undefined || isPanning) return; + if (!(displayScale > 0) || displayScale === zoomBase) return; + setZoomBase(displayScale); + }, [restingZoom, displayScale, transition, isPanning, zoomBase]); + + // At rest the position sits on a whole device pixel, so the tiles' snapped + // edges (see TileLayer) land on pixel boundaries too; a sub-pixel shift no + // one can see. + const restingOnZoom = restingZoom && appliedZoom === displayScale; + const devicePixelRatio = + typeof window === "undefined" ? 1 : window.devicePixelRatio || 1; + const onDevicePixel = (value: number) => + restingOnZoom ? Math.round(value * devicePixelRatio) / devicePixelRatio : value; const transformStyle = viewportTransformStyle( - - displayScale, - - transform.translateX, - - transform.translateY - + displayScale / appliedZoom, + onDevicePixel(transform.translateX), + onDevicePixel(transform.translateY) ); - - - const cursorClassName = isPanning ? "cursor-grabbing" : "cursor-grab"; - - const transformTransition = - - isResetAnimating && !isPanning ? VIEWPORT_RESET_TRANSITION : undefined; - - + const transformTransition = isPanning ? undefined : transition; return { - viewportRef, - + contentRef, userScale: transform.userScale, - + viewportSize, translateX: transform.translateX, - translateY: transform.translateY, - displayScale, - fitScale, - isPanning, - transformStyle, - transformTransition, - + zoom: appliedZoom, cursorClassName, - resetViewport, - + zoomBy, + focusMapRect, + consumeDragClick, screenToMapFromClient, - }; - } - - diff --git a/frontend/app/hooks/useProvinceHover.ts b/frontend/app/hooks/useProvinceHover.ts index 2507765c..91888593 100644 --- a/frontend/app/hooks/useProvinceHover.ts +++ b/frontend/app/hooks/useProvinceHover.ts @@ -1,5 +1,5 @@ // hooks/mapHover/useProvinceHover.ts -import { useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { MapId } from "../components/map/types"; import { fetchMapJson } from "@/lib/map/api"; @@ -112,6 +112,31 @@ export function indexStoredProvinceData( return byId; } +/** + * What a province hover produced this frame. + * + * - `consumed`: the mode is a province-tooltip mode and owns the pointer; + * region hover must not run. + * - `lines`: the tooltip lines, worked out synchronously from data already in + * memory; `null` when there is nothing to show here (sea, no data); and + * `undefined` when the answer is coming over the network and the tooltip will + * be set when it lands (the fallback when no province grid is available). + */ +export type ProvinceHoverResult = { + consumed: boolean; + lines: string[] | null | undefined; +}; + +/** Live modes whose hover reads `/compiled_data/provinces`. */ +const PROVINCE_FIGURE_MODES = new Set([ + "terrain", + "fertility", + "prosperity", + "infrastructure", + "infestation", + "trade", +]); + export function useProvinceHover({ mapId, mapType, @@ -120,6 +145,7 @@ export function useProvinceHover({ sessionToken, day = null, resolveProvinceId, + onDataReady, }: { mapId: MapId; mapType: string; @@ -137,10 +163,17 @@ export function useProvinceHover({ day?: string | null; /** * Map pixel -> province id, resolved client-side from the quarter-scale - * province id grid the chronicle already downloads. Only used on the day - * path; the live path keeps the server's `/meta` lookup. + * province id grid. With it, a hover costs no request: the id comes from the + * grid and the province's figures from data fetched once. Without it (the + * grid not loaded yet) the live path falls back to the server's `/meta`. */ resolveProvinceId?: (x: number, y: number) => number | null; + /** + * Called once the province figures have arrived, so whatever is under the + * pointer can be shown. A mouse would show it on its next move; a tap has + * no next move. + */ + onDataReady?: () => void; }) { const provinceCache = useRef>({}); /** Stored `province_data`, indexed by id, keyed by `mapId:day`. */ @@ -156,6 +189,40 @@ export function useProvinceHover({ } | null>(null); const countyPendingRef = useRef(null); + /** `/compiled_data/provinces` for the live map, fetched once per map. */ + const liveCompiledRef = useRef<{ key: string; byId: Record } | null>( + null + ); + const liveCompiledPendingRef = useRef(null); + + const onDataReadyRef = useRef(onDataReady); + onDataReadyRef.current = onDataReady; + + /** Fetch the live province figures once per map, then say so. */ + const loadLiveCompiled = useCallback(() => { + if (liveCompiledRef.current?.key === mapId || liveCompiledPendingRef.current === mapId) { + return; + } + liveCompiledPendingRef.current = mapId; + void fetchMapJson>(`/${mapId}/compiled_data/provinces`, { + sessionToken, + }) + .then((all) => { + liveCompiledRef.current = { key: mapId, byId: all ?? {} }; + onDataReadyRef.current?.(); + }) + .catch(() => { + liveCompiledPendingRef.current = null; + }); + }, [mapId, sessionToken]); + + // Fetched on entering a mode that reads them, not on the first hover, so + // the first tap on a phone already has them. + useEffect(() => { + if (day !== null || !PROVINCE_FIGURE_MODES.has(mapType)) return; + loadLiveCompiled(); + }, [day, mapType, loadLiveCompiled]); + const capitalize = (v: string) => v[0].toUpperCase() + v.slice(1); const handleProvinceHover = ( @@ -163,7 +230,7 @@ export function useProvinceHover({ y: number, screenX: number, screenY: number - ): boolean => { + ): ProvinceHoverResult => { const active = mapType === "terrain" || mapType === "fertility" || @@ -181,14 +248,11 @@ export function useProvinceHover({ mapType === "infestation" || mapType === "province"; - if (!active) return false; + if (!active) return { consumed: false, lines: null }; - const render = (data: any) => { - if (!data) return; - if (data.terrain === "sea" || data.terrain === "water") { - setCursorTooltip(null); - return; - } + /** The tooltip lines for one province's data, or null for none. */ + const buildLines = (data: any): string[] | null => { + if (!data || data.terrain === "sea" || data.terrain === "water") return null; const lines = [`x: ${x} z: ${y}`]; @@ -247,6 +311,17 @@ export function useProvinceHover({ } } + return lines; + }; + + const render = (data: any) => { + if (!data) return; + const lines = buildLines(data); + // Water and sea have nothing to show; clear the last province's tooltip. + if (!lines) { + setCursorTooltip(null); + return; + } setCursorTooltip({ x: screenX, y: screenY, @@ -254,6 +329,60 @@ export function useProvinceHover({ }); }; + // Live map with the province grid in memory: answer this frame, with no + // request. Trade and prosperity read the same compiled figures. + if (day === null && mapType !== "province" && resolveProvinceId) { + const compiled = liveCompiledRef.current; + if (compiled?.key !== mapId) { + loadLiveCompiled(); + return { consumed: consumesHover, lines: null }; + } + const pid = resolveProvinceId(x, y); + return { + consumed: consumesHover, + lines: pid ? buildLines(compiled.byId[pid]) : null, + }; + } + + if (mapType === "province" && resolveProvinceId) { + const pid = resolveProvinceId(x, y); + if (!pid) return { consumed: consumesHover, lines: null }; + const compiled = liveCompiledRef.current; + const names = countyCacheRef.current?.key === mapId ? countyCacheRef.current.names : null; + if (compiled?.key !== mapId && liveCompiledPendingRef.current !== mapId) { + liveCompiledPendingRef.current = mapId; + void fetchMapJson>(`/${mapId}/compiled_data/provinces`, { + sessionToken, + }) + .then((all) => { + liveCompiledRef.current = { key: mapId, byId: all ?? {} }; + }) + .catch(() => { + liveCompiledPendingRef.current = null; + }); + } + if (!names && countyPendingRef.current !== mapId) { + countyPendingRef.current = mapId; + void fetchMapJson>(`/${mapId}/data/county`, { + sessionToken, + }) + .then((counties) => { + countyCacheRef.current = { key: mapId, names: buildProvinceCountyNames(counties) }; + }) + .catch(() => { + countyCacheRef.current = { key: mapId, names: new Map() }; + }); + } + const terrain = compiled?.key === mapId ? compiled.byId[pid]?.terrain : undefined; + const lines = [`x: ${x} z: ${y}`, `Province: ${pid}`]; + if (typeof terrain === "string" && terrain.length > 0) { + lines.push(`Terrain: ${capitalize(terrain)}`); + } + const county = names?.get(pid); + if (county) lines.push(`County: ${county}`); + return { consumed: consumesHover, lines }; + } + if (mapType === "province") { const renderProvince = ( pid: number, @@ -303,7 +432,7 @@ export function useProvinceHover({ }) .catch(() => {}); - return consumesHover; + return { consumed: consumesHover, lines: undefined }; } if (day !== null) { @@ -311,20 +440,14 @@ export function useProvinceHover({ // province's own numbers come from that day's `province_data`. No live // request is issued from this path at all. const pid = resolveProvinceId?.(x, y) ?? null; - if (!pid) return consumesHover; + if (!pid) return { consumed: consumesHover, lines: null }; const key = `${mapId}:${day}`; const cached = dayCacheRef.current; if (cached?.key === key) { - // Deferred to a microtask so the ordering matches the live map's. - // For `trade` (and `prosperity`) `consumesHover` is false, so the - // caller goes on to run region hover, which writes its own tooltip - // *after* this one. Live, that race is won by this path because its - // data arrives over the network; from a warm cache a synchronous - // render would lose it, and the province breakdown would flash once - // and never be seen again. - queueMicrotask(() => render(cached.byId[pid])); - return consumesHover; + // Returned rather than drawn: the caller folds these lines into the + // region tooltip for trade, so the two never take turns on screen. + return { consumed: consumesHover, lines: buildLines(cached.byId[pid]) }; } if (dayPendingRef.current !== key) { @@ -350,9 +473,15 @@ export function useProvinceHover({ }); } - return consumesHover; + return { consumed: consumesHover, lines: undefined }; } + // Trade shares its tooltip with region hover. An answer that lands a + // moment later would replace the region tooltip and then be replaced back + // on the next move — the flicker this path used to cause — so without the + // grid, trade just shows the region. + if (!consumesHover) return { consumed: false, lines: null }; + void fetchMapJson<{ province_id?: number }>( `/${mapId}/province/${x},${y}/meta`, { sessionToken, cache: "no-store" } @@ -376,7 +505,7 @@ export function useProvinceHover({ }) .catch(() => {}); - return consumesHover; + return { consumed: consumesHover, lines: undefined }; }; return { handleProvinceHover }; diff --git a/frontend/app/hooks/useRegionHover.ts b/frontend/app/hooks/useRegionHover.ts index 900161d4..946ba188 100644 --- a/frontend/app/hooks/useRegionHover.ts +++ b/frontend/app/hooks/useRegionHover.ts @@ -1,7 +1,7 @@ import { useCallback, useRef } from "react"; import { buildRegionInfo } from "../components/map/regionInfo"; import { canDrillIntoRegion } from "../components/map/drillUtils"; -import { resolveRegionAtPickPixel } from "./regionPick"; +import { resolveRegionAtPickPixel, resolveRegionById } from "./regionPick"; import type { HoverOverlay, MapMode, MapObject, RegionInfo, RegionRecord } from "../components/map/types"; export function useRegionHover({ @@ -59,18 +59,22 @@ export function useRegionHover({ y: number, screenX: number, screenY: number, - setCursorTooltip: (tooltip: { x: number; y: number; text: string; hint?: string } | null) => void + setCursorTooltip: (tooltip: { x: number; y: number; text: string; hint?: string } | null) => void, + /** Hover this region rather than what the pick canvas has at (x, y). */ + regionIdOverride?: string ): boolean => { - const picked = resolveRegionAtPickPixel( - ctx, - x, - y, - rgbToId, - getHoverRegion, - mapType, - mapId, - regionData - ); + const picked = regionIdOverride + ? resolveRegionById(regionIdOverride, getHoverRegion, mapType, mapId, regionData) + : resolveRegionAtPickPixel( + ctx, + x, + y, + rgbToId, + getHoverRegion, + mapType, + mapId, + regionData + ); if (!picked) { clearHover(setCursorTooltip); @@ -94,10 +98,11 @@ export function useRegionHover({ ? info.title : `${info.title} · ${info.tier}`; - const hintLines = ["Click to view"]; - if (canDrillIntoRegion(regionId, regionData!, mapObjects)) { - hintLines.push("CTRL+Click to see subjects"); - } + const hintLines = [ + canDrillIntoRegion(regionId, regionData!, mapObjects) + ? "Click for details and subjects" + : "Click for details", + ]; setCursorTooltip({ x: screenX, diff --git a/frontend/app/hooks/useTileManifest.ts b/frontend/app/hooks/useTileManifest.ts new file mode 100644 index 00000000..d0db8a33 --- /dev/null +++ b/frontend/app/hooks/useTileManifest.ts @@ -0,0 +1,184 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import type { MapId } from "@/app/components/map/types"; +import { fetchMapJson, mapApiUrl } from "@/lib/map/api"; +import { + allTiles, + backdropLevel, + isTileManifest, + type TileManifest, +} from "@/app/lib/map/tilePyramid"; + +/** + * Manifests already fetched this page view, by `mapId/layer`. Switching back + * to a mode, or to one `prefetchTileBackdrops` warmed, then needs no round + * trip before its tiles can start. + */ +const manifestCache = new Map(); + +function cacheKey(mapId: MapId, layer: string): string { + return `${mapId}/${layer}`; +} + +async function fetchManifest(mapId: MapId, layer: string): Promise { + const body = await fetchMapJson( + `/${mapId}/tiles/${encodeURIComponent(layer)}/manifest` + ); + if (!isTileManifest(body)) return null; + manifestCache.set(cacheKey(mapId, layer), body); + return body; +} + +export function tileUrl( + mapId: MapId, + layer: string, + manifest: TileManifest, + level: number, + x: number, + y: number +): string { + return mapApiUrl( + `/${mapId}/tiles/${encodeURIComponent(layer)}/${manifest.version}/${level}/${x}/${y}.webp` + ); +} + +/** + * Warm the layers a reader is likely to open next: their manifests and the + * few backdrop tiles each, so switching to one shows colour at once while its + * sharper tiles load. Runs one layer at a time, after the map has settled. + */ +export async function prefetchTileBackdrops( + mapId: MapId, + layers: string[], + signal: { cancelled: boolean } +): Promise { + for (const layer of layers) { + if (signal.cancelled) return; + try { + const manifest = + manifestCache.get(cacheKey(mapId, layer)) ?? (await fetchManifest(mapId, layer)); + if (!manifest || signal.cancelled) continue; + const level = backdropLevel(manifest); + await Promise.all( + allTiles(manifest, level).map( + (tile) => + new Promise((resolve) => { + const image = new Image(); + image.decoding = "async"; + image.onload = image.onerror = () => resolve(); + image.src = tileUrl(mapId, layer, manifest, level, tile.x, tile.y); + }) + ) + ); + } catch { + // Best effort: a layer that cannot be warmed loads normally when opened. + } + } +} + +/** How long to wait before asking again while the backend builds the pyramid. */ +const NOT_READY_RETRY_MS = 5_000; +const MAX_ATTEMPTS = 24; + +/** + * - `loading`: the first answer has not arrived yet + * - `building`: the backend is still making the pyramid; draw the fallback + * - `ready`: `manifest` is set + * - `unavailable`: no tiles for this layer (or tiles are off); draw the fallback + */ +export type TileManifestStatus = "loading" | "building" | "ready" | "unavailable"; + +export type TileManifestState = { + manifest: TileManifest | null; + status: TileManifestStatus; + /** Ask again, e.g. after a tile 404s because the pyramid was replaced. */ + refresh: () => void; +}; + +/** + * The tile pyramid for one of a map's tiled layers (`base`, `mapdata-{mode}`, + * `regions-{mode}`). Every failure degrades to the caller's single-image + * fallback, which is how the map worked before tiles. + * + * Only for maps a plain `` can load. Staff maps need a bearer token per + * request, and fetching hundreds of tiles as blobs would cost more than the + * single image it replaces, so callers keep those on the single image. + */ +export function useTileManifest( + mapId: MapId, + layer: string | null, + enabled: boolean +): TileManifestState { + const [manifest, setManifest] = useState(null); + const [status, setStatus] = useState("loading"); + /** Which `mapId/layer` the two states above describe. */ + const [stateKey, setStateKey] = useState(null); + const [generation, setGeneration] = useState(0); + const key = enabled && layer ? cacheKey(mapId, layer) : null; + + const refresh = useCallback(() => { + if (layer) manifestCache.delete(cacheKey(mapId, layer)); + setGeneration((value) => value + 1); + }, [mapId, layer]); + + useEffect(() => { + setStateKey(key); + if (!enabled || !layer) { + setManifest(null); + setStatus("unavailable"); + return; + } + const cached = manifestCache.get(cacheKey(mapId, layer)); + if (cached) { + setManifest(cached); + setStatus("ready"); + return; + } + setManifest(null); + setStatus("loading"); + + let cancelled = false; + let timer: ReturnType | null = null; + let attempts = 0; + + const load = () => { + attempts += 1; + void fetchManifest(mapId, layer) + .then((body) => { + if (cancelled) return; + if (body) { + setManifest(body); + setStatus("ready"); + } else if (attempts < MAX_ATTEMPTS) { + setStatus("building"); + timer = setTimeout(load, NOT_READY_RETRY_MS); + } else { + setStatus("unavailable"); + } + }) + .catch(() => { + // No tiles for this layer (or an older backend): the fallback stays. + if (!cancelled) setStatus("unavailable"); + }); + }; + load(); + + return () => { + cancelled = true; + if (timer !== null) clearTimeout(timer); + }; + }, [mapId, layer, enabled, generation, key]); + + // Right after the layer changes, the state still describes the previous + // one until the effect runs. Answer for the new layer straight away, from + // the cache when it can, so nobody builds the new layer's tile URLs out of + // the old layer's version. + if (stateKey !== key) { + if (!key) return { manifest: null, status: "unavailable", refresh }; + const cached = manifestCache.get(key); + return { manifest: cached ?? null, status: cached ? "ready" : "loading", refresh }; + } + return { manifest, status, refresh }; +} diff --git a/frontend/app/internal/globals.css b/frontend/app/internal/globals.css index 08b926d2..c49246f8 100644 --- a/frontend/app/internal/globals.css +++ b/frontend/app/internal/globals.css @@ -34,6 +34,32 @@ body { font-family: var(--font-source-sans), system-ui, sans-serif; } +/* While the full-screen map is open (MapShell), the page does not scroll or + bounce: only the map and its panels move. */ +html.map-fullscreen, +html.map-fullscreen body, +html.map-fullscreen .map-route { + overflow: hidden; + overscroll-behavior: none; +} + +/* Nothing on the map page selects, drags or pops up a long-press menu: a long + press is a press on the map, not a text selection or "Save image". The + search box is the exception, or Safari would not let it be typed in. */ +html.map-fullscreen body { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} +html.map-fullscreen img { + -webkit-user-drag: none; +} +html.map-fullscreen input, +html.map-fullscreen textarea { + -webkit-user-select: text; + user-select: text; +} + /* Themed scrollbars (Firefox + Chromium/WebKit) */ * { scrollbar-width: thin; @@ -285,3 +311,173 @@ body { .char-toast-out { animation: char-toast-out 0.28s ease-in both; } + +/* ------------------------------------------------------------------------ + * Map chrome + * + * Every floating control on the world map shares one quiet panel style, the + * same forest and cream as the rest of the site, so the map stays the thing + * being looked at. + * ---------------------------------------------------------------------- */ + +/* 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 color-mix(in srgb, var(--tfmc-cream) 12%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--tfmc-forest-deep) 94%, transparent); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); + color: var(--tfmc-cream); + backdrop-filter: blur(8px); + } + + .map-frame-header { + border-bottom: 1px solid color-mix(in srgb, var(--tfmc-cream) 10%, transparent); + } + + /* Section heading inside a panel. */ + .map-rule { + font-size: 0.75rem; + font-weight: 600; + color: var(--tfmc-mist); + } + + .map-tooltip { + border: 1px solid color-mix(in srgb, var(--tfmc-cream) 12%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--tfmc-forest-deep) 96%, transparent); + box-shadow: 0 4px 14px rgb(0 0 0 / 0.4); + } + + /* Round icon buttons for the map modes. */ + .map-medallion { + display: inline-flex; + height: 2.5rem; + width: 2.5rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 9999px; + color: var(--tfmc-stone); + transition: + color 120ms ease, + background-color 120ms ease; + } + + .map-medallion:hover { + color: var(--tfmc-cream); + background: color-mix(in srgb, var(--tfmc-cream) 10%, transparent); + } + + .map-medallion[aria-pressed="true"] { + color: var(--tfmc-forest-deep); + background: color-mix(in srgb, var(--tfmc-cream) 92%, transparent); + } + + .map-medallion:focus-visible, + .map-control:focus-visible { + outline: 2px solid var(--tfmc-accent); + outline-offset: 2px; + } + + /* Rectangular controls: zoom, layers, panel buttons. */ + .map-control { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 1px solid color-mix(in srgb, var(--tfmc-cream) 12%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--tfmc-forest-deep) 94%, transparent); + color: var(--tfmc-stone); + transition: + color 120ms ease, + border-color 120ms ease, + background-color 120ms ease; + } + + .map-control:hover:not(:disabled) { + color: var(--tfmc-cream); + border-color: color-mix(in srgb, var(--tfmc-cream) 25%, transparent); + background: color-mix(in srgb, var(--tfmc-forest) 94%, transparent); + } + + .map-control[aria-pressed="true"], + .map-control[aria-expanded="true"] { + color: var(--tfmc-forest-deep); + border-color: transparent; + background: color-mix(in srgb, var(--tfmc-cream) 92%, transparent); + } + + .map-control:disabled { + opacity: 0.45; + cursor: default; + } + + /* A realm's banner. */ + .map-banner-frame { + border: 1px solid color-mix(in srgb, var(--tfmc-cream) 15%, transparent); + border-radius: 4px; + background: var(--tfmc-forest-deep); + overflow: hidden; + } + + /* The selection's outline is a stack of drop-shadow filters, redrawn at + * every scale; while the map is being zoomed or dragged it stands down. */ + [data-gesturing] .map-selected-region { + filter: none !important; + } + + /* An in-text link to another realm or place. */ + .map-link { + color: var(--tfmc-cream); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--tfmc-cream) 35%, transparent); + text-underline-offset: 2px; + } + + .map-link:hover { + text-decoration-color: var(--tfmc-cream); + } +} + +@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/labelBlobGeometry.test.ts b/frontend/app/lib/labelBlobGeometry.test.ts index a4da777b..5e56c238 100644 --- a/frontend/app/lib/labelBlobGeometry.test.ts +++ b/frontend/app/lib/labelBlobGeometry.test.ts @@ -11,6 +11,7 @@ import { insetLabelEndpoints, isLabelCorridorWaterCell, labelCorridorMargin, + LongestFirstPairs, parseProvinceLabelGrid, tryRadialSegment, type ProvinceLabelGrid, @@ -453,3 +454,20 @@ describe("sub-rect restriction", () => { expect(rect.x1).toBeGreaterThan(14); }); }); + +describe("LongestFirstPairs", () => { + it("hands out pairs exactly as a stable longest-first sort would", () => { + // A grid of points gives plenty of equal lengths, so ties are exercised. + const candidates = []; + for (let y = 0; y < 6; y += 1) { + for (let x = 0; x < 7; x += 1) candidates.push({ x: x * 10, y: y * 10 }); + } + const pairs = new LongestFirstPairs(candidates); + const expected = [...Array(pairs.count).keys()].sort( + (left, right) => pairs.len[right] - pairs.len[left] + ); + const actual = expected.map((_, k) => pairs.at(k)); + expect(actual).toEqual(expected); + expect(pairs.at(pairs.count)).toBe(-1); + }); +}); diff --git a/frontend/app/lib/labelBlobGeometry.ts b/frontend/app/lib/labelBlobGeometry.ts index 1d1c45fa..fc2e9216 100644 --- a/frontend/app/lib/labelBlobGeometry.ts +++ b/frontend/app/lib/labelBlobGeometry.ts @@ -444,37 +444,108 @@ function collectCandidates( return points.filter((_, index) => index % stride === 0); } +/** + * Every pair of candidates, longest first (ties in the order the pairs were + * listed), handed out lazily from a heap. A 150-candidate blob has some + * 11,000 pairs, but a label usually fits within the first thousand or so, so + * sorting them all was most of the work of placing it. Built once per label + * and shared by the passes at each margin: the order does not depend on it. + */ +export class LongestFirstPairs { + readonly a: Uint32Array; + readonly b: Uint32Array; + readonly len: Float64Array; + private readonly heap: Uint32Array; + private heapSize: number; + /** The pairs handed out so far, in order. */ + private readonly order: Uint32Array; + private handedOut = 0; + + constructor(candidates: CandidatePoint[]) { + const n = candidates.length; + const count = (n * (n - 1)) / 2; + this.a = new Uint32Array(count); + this.b = new Uint32Array(count); + this.len = new Float64Array(count); + let k = 0; + for (let i = 0; i < n; i += 1) { + for (let j = i + 1; j < n; j += 1) { + this.a[k] = i; + this.b[k] = j; + this.len[k] = segmentPixelLength( + candidates[i].x, + candidates[i].y, + candidates[j].x, + candidates[j].y + ); + k += 1; + } + } + this.heap = new Uint32Array(count); + for (let i = 0; i < count; i += 1) this.heap[i] = i; + this.heapSize = count; + for (let i = (count >> 1) - 1; i >= 0; i -= 1) this.siftDown(i); + this.order = new Uint32Array(count); + } + + get count(): number { + return this.len.length; + } + + /** The `k`th longest pair's index, or -1 past the last. */ + at(k: number): number { + while (this.handedOut <= k && this.heapSize > 0) { + this.order[this.handedOut] = this.heap[0]; + this.handedOut += 1; + this.heapSize -= 1; + this.heap[0] = this.heap[this.heapSize]; + this.siftDown(0); + } + return k < this.handedOut ? this.order[k] : -1; + } + + /** Strictly before: longer, or as long and listed earlier. */ + private before(x: number, y: number): boolean { + const lx = this.len[x]; + const ly = this.len[y]; + return lx > ly || (lx === ly && x < y); + } + + private siftDown(start: number): void { + const { heap } = this; + let i = start; + for (;;) { + const left = 2 * i + 1; + if (left >= this.heapSize) return; + const right = left + 1; + let best = left; + if (right < this.heapSize && this.before(heap[right], heap[left])) best = right; + if (!this.before(heap[best], heap[i])) return; + const swap = heap[i]; + heap[i] = heap[best]; + heap[best] = swap; + i = best; + } + } +} + function tryFindSegment( candidates: CandidatePoint[], + pairs: LongestFirstPairs, text: string, grid: ProvinceLabelGrid, dist: Float32Array, marginScale: number ): LabelEndpoints | null { - const pairs: Array<{ a: CandidatePoint; b: CandidatePoint; len: number }> = - []; - - for (let i = 0; i < candidates.length; i += 1) { - for (let j = i + 1; j < candidates.length; j += 1) { - const a = candidates[i]; - const b = candidates[j]; - pairs.push({ - a, - b, - len: segmentPixelLength(a.x, a.y, b.x, b.y), - }); - } - } - - pairs.sort((left, right) => right.len - left.len); - - for (const pair of pairs) { - const fontSize = fontSizeForLabel(pair.len, text); - const margin = labelCorridorMargin(pair.len, fontSize) * marginScale; - if ( - corridorClear(pair.a.x, pair.a.y, pair.b.x, pair.b.y, margin, grid, dist) - ) { - return { x1: pair.a.x, y1: pair.a.y, x2: pair.b.x, y2: pair.b.y }; + for (let k = 0; k < pairs.count; k += 1) { + const index = pairs.at(k); + const pairLen = pairs.len[index]; + const a = candidates[pairs.a[index]]; + const b = candidates[pairs.b[index]]; + const fontSize = fontSizeForLabel(pairLen, text); + const margin = labelCorridorMargin(pairLen, fontSize) * marginScale; + if (corridorClear(a.x, a.y, b.x, b.y, margin, grid, dist)) { + return { x1: a.x, y1: a.y, x2: b.x, y2: b.y }; } } @@ -676,8 +747,9 @@ export function insetLabelEndpoints( ); if (candidates.length) { + const pairs = new LongestFirstPairs(candidates); for (const marginScale of [1, 0.75, 0.5]) { - const found = tryFindSegment(candidates, text, grid, dist, marginScale); + const found = tryFindSegment(candidates, pairs, text, grid, dist, marginScale); if (found) { return found; } diff --git a/frontend/app/lib/map/chronicleDayRoute.test.ts b/frontend/app/lib/map/chronicleDayRoute.test.ts index 24da59c1..1c461b3f 100644 --- a/frontend/app/lib/map/chronicleDayRoute.test.ts +++ b/frontend/app/lib/map/chronicleDayRoute.test.ts @@ -7,6 +7,8 @@ import { chronicleStudioHref, isValidChronicleDay, liveMapHref, + liveMapIdFrom, + setLiveMapId, parseChronicleDayRange, parseMapRouteSegment, } from "./chronicleDayRoute"; @@ -74,7 +76,13 @@ describe("isValidChronicleDay", () => { describe("chronicle route hrefs", () => { it("maps the dev map id onto its public segment", () => { + expect(liveMapHref("main")).toBe("/map"); + setLiveMapId("dev"); + expect(liveMapHref("dev")).toBe("/map"); expect(liveMapHref("main")).toBe("/map/main"); + setLiveMapId("main"); + expect(liveMapIdFrom([{ id: "main" }, { id: "dev", live: true }])).toBe("dev"); + expect(liveMapIdFrom([{ id: "main" }])).toBe("main"); 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..47bb945c 100644 --- a/frontend/app/lib/map/chronicleDayRoute.ts +++ b/frontend/app/lib/map/chronicleDayRoute.ts @@ -54,14 +54,40 @@ 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)}`; } +/** + * The map this site shows at `/map`: `main` on the public site, the Dev + * server's map on the dev site. Set from the backend's map list once it has + * loaded (`setLiveMapId`), so every link below agrees with the page. + */ +let liveMapIdForLinks: MapId = "main"; + +export function setLiveMapId(mapId: MapId): void { + liveMapIdForLinks = mapId; +} + +/** The live map in a `/maps/accessible` list; `main` if none says so. */ +export function liveMapIdFrom(maps: readonly { id: string; live?: boolean }[]): MapId { + return maps.find((entry) => entry.live)?.id ?? "main"; +} + +/** `/map` for the site's live map, else `/map/r3b1rth` or `/map/{archive}`. */ +export function liveMapHref(mapId: MapId): string { + return mapId === liveMapIdForLinks ? "/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/guildProfile.test.ts b/frontend/app/lib/map/guildProfile.test.ts new file mode 100644 index 00000000..8a675ec2 --- /dev/null +++ b/frontend/app/lib/map/guildProfile.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + buildGuildProfile, + findGuild, + guildKeyForId, + guildSeat, + guildsInProvince, + guildTypeLabel, + realmGuilds, +} from "./guildProfile"; + +const thalendor = { + name: "Thalendor", + guilds: [ + { id: "Thalendor", name: "Thalendor", type: "realm", leader: "GeoffLive", capital: 159, members: ["a", "b"] }, + { + id: "Oyfthyr", + name: "§x§a§3§a§1§8§4Oyfthyr", + type: "guild", + leader: "someplayer", + "leader character": "Oyf the Elder", + "leader character of": "someplayer", + rgb: "10,20,30", + capital: 163, + members: ["someplayer", "another", "third"], + balance: 1041.62, + loans: [{ amount: 50 }], + branches: [ + { id: "guild_halls", level: 6 }, + { id: "storehouses", level: 11 }, + { bad: true }, + ], + }, + { id: "Small", name: "Small", type: "guild", leader: "x", capital: 161, members: ["x"] }, + ], +}; + +const regionData = { Thalendor: thalendor as never }; + +describe("buildGuildProfile", () => { + it("reads the public facts and nothing else", () => { + const guild = buildGuildProfile("Thalendor", thalendor.guilds[1]); + expect(guild).toMatchObject({ + key: "Thalendor/Oyfthyr", + name: "Oyfthyr", + typeLabel: "Guild", + leader: "Oyf the Elder", + members: 3, + homeProvince: 163, + branches: [ + { id: "guild_halls", label: "Guild halls", level: 6 }, + { id: "storehouses", label: "Storehouses", level: 11 }, + ], + }); + const serialised = JSON.stringify(guild); + expect(serialised).not.toContain("1041"); + expect(serialised).not.toContain("another"); + expect(serialised).not.toContain("someplayer"); + }); + + it("never names the leader by account", () => { + expect(buildGuildProfile("Thalendor", thalendor.guilds[2])?.leader).toBeNull(); + }); +}); + +describe("realm guilds", () => { + it("lists the guilds, not the realm's own, largest first", () => { + expect(realmGuilds("Thalendor", thalendor).map((guild) => guild.id)).toEqual([ + "Oyfthyr", + "Small", + ]); + }); + + it("finds a guild by key and by province", () => { + expect(findGuild(regionData, "Thalendor/Oyfthyr")?.name).toBe("Oyfthyr"); + expect(findGuild(regionData, "Nope/Oyfthyr")).toBeNull(); + expect(guildsInProvince(regionData, 161).map((guild) => guild.id)).toEqual(["Small"]); + }); + + it("finds the settlement a guild is based in", () => { + const guild = findGuild(regionData, "Thalendor/Oyfthyr")!; + expect( + guildSeat(guild, [ + { id: "Elsewhere", name: "Elsewhere", province_id: 1 }, + { id: "Oyfthyr", name: "Oyfthyr", province_id: 163, faction_id: "Thalendor" }, + ])?.id + ).toBe("Oyfthyr"); + expect(guildSeat({ ...guild, homeProvince: null }, [])).toBeNull(); + }); + + it("finds a realm's own guild by key, and any guild by id", () => { + expect(findGuild(regionData, "Thalendor/Thalendor")?.typeLabel).toBe("Realm guild"); + expect(guildKeyForId(regionData, "Oyfthyr")).toBe("Thalendor/Oyfthyr"); + expect(guildKeyForId(regionData, "Thalendor")).toBe("Thalendor/Thalendor"); + expect(guildKeyForId(regionData, "Nobody")).toBeNull(); + }); + + it("labels guild types", () => { + expect(guildTypeLabel("realm")).toBe("Realm guild"); + expect(guildTypeLabel("trade_league")).toBe("Trade league"); + }); +}); diff --git a/frontend/app/lib/map/guildProfile.ts b/frontend/app/lib/map/guildProfile.ts new file mode 100644 index 00000000..df2a760d --- /dev/null +++ b/frontend/app/lib/map/guildProfile.ts @@ -0,0 +1,143 @@ +import type { RegionRecord, SettlementMarker } from "@/app/components/map/types"; +import { cleanRegionName } from "@/app/lib/mapLabels"; + +import { leaderCharacterName } from "./realmProfile"; + +/** + * What the map shows about a guild, read from the `guilds` list inside each + * realm of `/data/nation`. + * + * The same allowlist rule as `RealmProfile`: those entries also carry bank + * balances, loans, ledgers, dividend lists and member usernames, none of + * which this reads. Members are a count, the leader is their character name + * (never the account), and branches are names and levels. + */ +export type GuildProfile = { + /** `factionId/guildId`, unique across the map. */ + key: string; + id: string; + factionId: string; + name: string; + /** SimpleFactions' guild type, e.g. "realm" or "guild". */ + type: string; + typeLabel: string; + rgb: string | null; + leader: string | null; + members: number; + /** Province the guild is based in. */ + homeProvince: number | null; + branches: { id: string; label: string; level: number }[]; +}; + +type RawGuild = Record; + +export function guildKey(factionId: string, guildId: string): string { + return `${factionId}/${guildId}`; +} + +function words(id: string): string { + const spaced = id.replace(/_/g, " ").trim(); + return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : id; +} + +export function guildTypeLabel(type: string): string { + if (type === "realm") return "Realm guild"; + if (type === "guild") return "Guild"; + return words(type); +} + +export function buildGuildProfile(factionId: string, raw: RawGuild): GuildProfile | null { + const id = typeof raw.id === "string" ? raw.id : null; + if (!id) return null; + const type = typeof raw.type === "string" ? raw.type : "guild"; + const capital = typeof raw.capital === "number" && raw.capital >= 0 ? raw.capital : null; + const members = Array.isArray(raw.members) + ? raw.members.length + : typeof raw.members === "number" + ? raw.members + : 0; + const branches = Array.isArray(raw.branches) + ? raw.branches.flatMap((branch) => { + if (!branch || typeof branch !== "object") return []; + const { id: branchId, level } = branch as { id?: unknown; level?: unknown }; + if (typeof branchId !== "string" || typeof level !== "number") return []; + return [{ id: branchId, label: words(branchId), level }]; + }) + : []; + return { + key: guildKey(factionId, id), + id, + factionId, + name: (typeof raw.name === "string" && cleanRegionName(raw.name)) || words(id), + type, + typeLabel: guildTypeLabel(type), + rgb: typeof raw.rgb === "string" ? raw.rgb : null, + leader: leaderCharacterName(raw), + members, + homeProvince: capital, + branches, + }; +} + +/** + * A realm's guilds other than its own realm guild (which is the realm itself + * in guild form, and would only repeat the realm panel), largest first. + */ +export function realmGuilds(factionId: string, region: unknown): GuildProfile[] { + const raw = (region as { guilds?: unknown } | undefined)?.guilds; + if (!Array.isArray(raw)) return []; + return raw + .map((entry) => (entry && typeof entry === "object" ? buildGuildProfile(factionId, entry as RawGuild) : null)) + .filter((guild): guild is GuildProfile => guild !== null && guild.type !== "realm") + .sort((a, b) => b.members - a.members || a.name.localeCompare(b.name)); +} + +export function allGuilds(regionData: RegionRecord | null): GuildProfile[] { + return Object.entries(regionData ?? {}).flatMap(([factionId, region]) => + realmGuilds(factionId, region) + ); +} + +/** Every guild of a realm, its own realm guild included. */ +function everyGuildOf(factionId: string, region: unknown): GuildProfile[] { + const raw = (region as { guilds?: unknown } | undefined)?.guilds; + if (!Array.isArray(raw)) return []; + return raw + .map((entry) => (entry && typeof entry === "object" ? buildGuildProfile(factionId, entry as RawGuild) : null)) + .filter((guild): guild is GuildProfile => guild !== null); +} + +/** A guild by `factionId/guildId`, realm guilds included. */ +export function findGuild(regionData: RegionRecord | null, key: string): GuildProfile | null { + const slash = key.indexOf("/"); + if (slash <= 0) return null; + const factionId = key.slice(0, slash); + return everyGuildOf(factionId, regionData?.[factionId]).find((guild) => guild.key === key) ?? null; +} + +/** + * The key of the guild with this id, in any realm. The trade map's areas are + * keyed by the id of the guild that dominates them. + */ +export function guildKeyForId(regionData: RegionRecord | null, guildId: string): string | null { + for (const [factionId, region] of Object.entries(regionData ?? {})) { + const match = everyGuildOf(factionId, region).find((guild) => guild.id === guildId); + if (match) return match.key; + } + return null; +} + +/** Guilds based in a province, e.g. the one a settlement sits in. */ +export function guildsInProvince(regionData: RegionRecord | null, province: number): GuildProfile[] { + return allGuilds(regionData).filter((guild) => guild.homeProvince === province); +} + +/** The settlement a guild is based in, matched by province. */ +export function guildSeat( + guild: Pick, + settlements: SettlementMarker[] +): SettlementMarker | null { + if (guild.homeProvince === null) return null; + const here = settlements.filter((settlement) => settlement.province_id === guild.homeProvince); + return here.find((settlement) => settlement.faction_id === guild.factionId) ?? here[0] ?? null; +} diff --git a/frontend/app/lib/map/mapFilters.test.ts b/frontend/app/lib/map/mapFilters.test.ts new file mode 100644 index 00000000..ccbf54ed --- /dev/null +++ b/frontend/app/lib/map/mapFilters.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { mapFiltersSupported } from "./mapFilters"; + +describe("mapFiltersSupported", () => { + it("is off for WebKit: Safari, and every iPhone browser", () => { + expect( + mapFiltersSupported( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" + ) + ).toBe(false); + expect( + mapFiltersSupported( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1" + ) + ).toBe(false); + expect( + mapFiltersSupported( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15" + ) + ).toBe(false); + }); + + it("is on for Chromium and Firefox", () => { + expect( + mapFiltersSupported( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + ) + ).toBe(true); + expect( + mapFiltersSupported( + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36" + ) + ).toBe(true); + expect( + mapFiltersSupported("Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0") + ).toBe(true); + }); +}); diff --git a/frontend/app/lib/map/mapFilters.ts b/frontend/app/lib/map/mapFilters.ts new file mode 100644 index 00000000..973b1916 --- /dev/null +++ b/frontend/app/lib/map/mapFilters.ts @@ -0,0 +1,17 @@ +/** + * Whether CSS filters can be used on the map's overlays. + * + * WebKit does not draw a filtered image inside the scaled map: the highlight + * of a selected or hovered region (brightness and a drop-shadow rim), and an + * opened realm drawn as one filtered group, rendered as nothing at all. That + * is Safari, and every browser on iOS, which must use WebKit; their user agents + * say AppleWebKit without naming Chromium's or Firefox's own engines. + */ +export function mapFiltersSupported(userAgent: string): boolean { + if (!/AppleWebKit/.test(userAgent)) return true; + return /Chrome\/|Chromium\/|Edg\/|OPR\//.test(userAgent); +} + +export function mapFiltersSupportedHere(): boolean { + return typeof navigator === "undefined" || mapFiltersSupported(navigator.userAgent); +} diff --git a/frontend/app/lib/map/mapSearch.test.ts b/frontend/app/lib/map/mapSearch.test.ts new file mode 100644 index 00000000..d1b9f49d --- /dev/null +++ b/frontend/app/lib/map/mapSearch.test.ts @@ -0,0 +1,133 @@ +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); + }); +}); + +describe("guild search", () => { + const withGuilds = buildMapSearchIndex({ + regionData: { + Thalendor: { + name: "Thalendor", + rgb: "1,1,1", + guilds: [ + { id: "Thalendor", name: "Thalendor", type: "realm", members: [] }, + { id: "Oyfthyr", name: "Oyfthyr", type: "guild", rgb: "2,2,2", members: ["a"] }, + ], + } as never, + }, + tierLabel: "Realms", + settlements: [{ id: "Oyfthyr", name: "Oyfthyr", map_x: 1, map_y: 2, faction_id: "Thalendor" }], + installations: [], + }); + + it("finds guilds, not the realm's own guild, and ranks them between realms and places", () => { + const results = searchMap(withGuilds, "oyf"); + expect(results.map((entry) => entry.kind)).toEqual(["guild", "place"]); + expect(results[0]).toMatchObject({ guildKey: "Thalendor/Oyfthyr", detail: "Guild · Thalendor" }); + expect(searchMap(withGuilds, "thal").map((entry) => entry.kind)).toEqual(["region"]); + }); +}); + diff --git a/frontend/app/lib/map/mapSearch.ts b/frontend/app/lib/map/mapSearch.ts new file mode 100644 index 00000000..642cfbd5 --- /dev/null +++ b/frontend/app/lib/map/mapSearch.ts @@ -0,0 +1,202 @@ +import type { + InstallationMarker, + RegionRecord, + SettlementMarker, +} from "@/app/components/map/types"; +import { cleanRegionName } from "@/app/lib/mapLabels"; +import { allGuilds } from "./guildProfile"; + +export type MapSearchEntry = + | { + kind: "region"; + key: string; + regionId: string; + label: string; + detail: string; + rgb: string | null; + } + | { + kind: "guild"; + key: string; + guildKey: 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, + }); + } + + // Guilds live inside the realm map's data; other modes have none. + for (const guild of allGuilds(regionData)) { + const realm = ownerName(guild.factionId, regionData); + entries.push({ + kind: "guild", + key: `guild:${guild.key}`, + guildKey: guild.key, + label: guild.name, + detail: realm ? `${guild.typeLabel} · ${realm}` : guild.typeLabel, + rgb: guild.rgb, + }); + } + + const guildCapitals = new Set( + allGuilds(regionData) + .map((guild) => guild.homeProvince) + .filter((province): province is number => province !== 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.kind ?? "settlement") === "settlement" && + typeof settlement.province_id === "number" && + guildCapitals.has(settlement.province_id) + ? "Guild capital" + : 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; +} + +/** On an equal match: realms and titles, then guilds, then places. */ +function kindRank(entry: MapSearchEntry): number { + return entry.kind === "region" ? 0 : entry.kind === "guild" ? 1 : 2; +} + +/** + * 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 || + kindRank(a.entry) - kindRank(b.entry) || + 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/placeProfile.test.ts b/frontend/app/lib/map/placeProfile.test.ts new file mode 100644 index 00000000..68fe7235 --- /dev/null +++ b/frontend/app/lib/map/placeProfile.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { buildPlaceProfile, placeMarkerIdForSearchKey } from "./placeProfile"; + +const regionData = { Huoyaoguo: { name: "Huǒyàoguó", rgb: "1,1,1" } }; + +const settlements = [ + { + id: "Porokhgrad", + name: "§x§a§3§a§1§8§4Porokhgrad", + faction_id: "Huoyaoguo", + kind: "faction_capital" as const, + population: 8, + province_id: 696, + provinces: [696, 697], + map_x: 995, + map_y: 2546, + }, +]; + +const installations = [ + { + id: "harbour", + name: "Grand Port", + kind: "port" as const, + faction_id: "Gone", + province_id: 12, + hub_slots: 3, + hubs: 1, + map_x: 5, + map_y: 6, + }, +]; + +const marker = (id: string, kind: string, extra = {}) => ({ + id, + kind, + mapX: 1, + mapY: 2, + label: "Label", + title: "Title", + ...extra, +}); + +describe("buildPlaceProfile", () => { + it("describes a capital with its realm, population and provinces", () => { + expect( + buildPlaceProfile(marker("Porokhgrad", "faction_capital"), settlements, [], regionData) + ).toMatchObject({ + name: "Porokhgrad", + kindLabel: "Capital", + ownerId: "Huoyaoguo", + population: 8, + provinces: [696, 697], + hubs: null, + }); + }); + + it("describes an installation's hubs, and drops an owner the map does not know", () => { + expect( + buildPlaceProfile(marker("installation:harbour", "port"), [], installations, regionData) + ).toMatchObject({ + name: "Grand Port", + kindLabel: "Port", + ownerId: null, + provinces: [12], + hubs: 1, + hubSlots: 3, + }); + }); + + it("falls back to the marker for battles and unknown places", () => { + expect( + buildPlaceProfile(marker("war-1-slot", "battle", { title: "Siege of X" }), [], [], null) + ).toMatchObject({ kindLabel: "Battle", note: "Siege of X" }); + expect(buildPlaceProfile(marker("ghost", "settlement"), [], [], null)).toMatchObject({ + name: "Label", + kindLabel: "Place", + }); + }); +}); + +describe("guild capitals", () => { + it("calls a settlement a guild's capital when a guild has made it one", () => { + const withGuild = { + Thalendor: { + name: "Thalendor", + guilds: [{ id: "Oyfthyr", name: "Oyfthyr", type: "guild", capital: 163, members: [] }], + }, + }; + const town = [{ id: "Oyfthyr", name: "Oyfthyr", faction_id: "Thalendor", kind: "settlement" as const, province_id: 163 }]; + expect( + buildPlaceProfile(marker("Oyfthyr", "settlement"), town, [], withGuild as never).kindLabel + ).toBe("Guild capital"); + expect( + buildPlaceProfile(marker("Oyfthyr", "settlement"), town, [], regionData).kindLabel + ).toBe("Settlement"); + }); +}); + +describe("placeMarkerIdForSearchKey", () => { + it("maps search results to marker ids", () => { + expect(placeMarkerIdForSearchKey("settlement:Porokhgrad")).toBe("Porokhgrad"); + expect(placeMarkerIdForSearchKey("installation:harbour")).toBe("installation:harbour"); + expect(placeMarkerIdForSearchKey("region:X")).toBeNull(); + }); +}); diff --git a/frontend/app/lib/map/placeProfile.ts b/frontend/app/lib/map/placeProfile.ts new file mode 100644 index 00000000..9a68c192 --- /dev/null +++ b/frontend/app/lib/map/placeProfile.ts @@ -0,0 +1,120 @@ +import type { + InstallationMarker, + RegionRecord, + SettlementMarker, +} from "@/app/components/map/types"; +import type { MapMarker } from "@/app/lib/mapMarkers"; +import { isBattleMarkerKind } from "@/app/lib/mapMarkers"; +import { cleanRegionName } from "@/app/lib/mapLabels"; +import { guildsInProvince } from "./guildProfile"; + +/** + * What the details panel shows for a marker: a settlement, an installation or + * a battle. Read from the marker export the map already holds; nothing here + * costs a request. + */ +export type PlaceProfile = { + markerId: string; + name: string; + /** "Capital", "Guild seat", "Port", "Battle", ... */ + kindLabel: string; + /** The realm that owns it, if it has one the map knows. */ + ownerId: string | null; + population: number | null; + /** Provinces the settlement spans. */ + provinces: number[]; + hubs: number | null; + hubSlots: number | null; + mapX: number; + mapY: number; + /** Free text for kinds with nothing structured (battles). */ + note: string | null; +}; + +const SETTLEMENT_KIND_LABELS: Record = { + faction_capital: "Capital", + guild_capital: "Guild seat", + settlement: "Settlement", +}; + +const INSTALLATION_KIND_LABELS: Record = { + fort: "Fort", + port: "Port", + airport: "Airfield", + train_station: "Station", +}; + +/** The marker id a settlement or installation search result stands for. */ +export function placeMarkerIdForSearchKey(key: string): string | null { + if (key.startsWith("settlement:")) return key.slice("settlement:".length); + if (key.startsWith("installation:")) return key; + return null; +} + +export function buildPlaceProfile( + marker: MapMarker, + settlements: SettlementMarker[], + installations: InstallationMarker[], + regionData: RegionRecord | null +): PlaceProfile { + const base: PlaceProfile = { + markerId: marker.id, + name: marker.label, + kindLabel: "Place", + ownerId: null, + population: null, + provinces: [], + hubs: null, + hubSlots: null, + mapX: marker.mapX, + mapY: marker.mapY, + note: null, + }; + const known = (id: string | undefined) => (id && regionData?.[id] ? id : null); + + if (marker.id.startsWith("installation:")) { + const id = marker.id.slice("installation:".length); + const installation = installations.find((entry) => entry.id === id); + if (!installation) return base; + return { + ...base, + name: cleanRegionName(installation.name) || base.name, + kindLabel: INSTALLATION_KIND_LABELS[installation.kind] ?? "Installation", + ownerId: known(installation.faction_id), + provinces: + typeof installation.province_id === "number" ? [installation.province_id] : [], + hubs: (installation.hub_slots ?? 0) > 0 ? installation.hubs ?? 0 : null, + hubSlots: (installation.hub_slots ?? 0) > 0 ? installation.hub_slots ?? 0 : null, + }; + } + + if (isBattleMarkerKind(marker.kind)) { + return { ...base, kindLabel: "Battle", note: marker.title || null }; + } + + const settlement = settlements.find((entry) => entry.id === marker.id); + if (!settlement) return base; + const raw = settlement as SettlementMarker & { provinces?: unknown }; + const provinces = Array.isArray(raw.provinces) + ? raw.provinces.filter((value): value is number => typeof value === "number") + : typeof settlement.province_id === "number" + ? [settlement.province_id] + : []; + // A plain settlement that a guild has made its capital is that guild's + // capital first and foremost. + const kind = settlement.kind ?? "settlement"; + const guildCapital = + kind === "settlement" && + provinces.length > 0 && + guildsInProvince(regionData, provinces[0]).length > 0; + return { + ...base, + name: cleanRegionName(settlement.name) || base.name, + kindLabel: guildCapital + ? "Guild capital" + : SETTLEMENT_KIND_LABELS[kind] ?? "Settlement", + ownerId: known(settlement.faction_id), + population: typeof settlement.population === "number" ? settlement.population : null, + provinces, + }; +} diff --git a/frontend/app/lib/map/realmProfile.test.ts b/frontend/app/lib/map/realmProfile.test.ts new file mode 100644 index 00000000..d2f1007b --- /dev/null +++ b/frontend/app/lib/map/realmProfile.test.ts @@ -0,0 +1,196 @@ +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", + "leader character": "§6Ginger of the Hill", + "leader character of": "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: "Ginger of the Hill", + 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("never shows the Minecraft username as the ruler", () => { + expect(buildRealmProfile("Grunk", { leader: "rushork" }).leader).toBeNull(); + expect(JSON.stringify(buildRealmProfile("Rat_Hill", raw))).not.toContain("GingerBAR"); + }); + + it("drops a character name that belongs to a previous leader", () => { + expect( + buildRealmProfile("Grunk", { + leader: "newking", + "leader character": "Grunk the Bold", + "leader character of": "rushork", + }).leader + ).toBeNull(); + expect( + buildRealmProfile("Grunk", { + leader: "RUSHORK", + "leader character": "Grunk the Bold", + "leader character of": "rushork", + }).leader + ).toBe("Grunk the Bold"); + }); + + 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..1199cd85 --- /dev/null +++ b/frontend/app/lib/map/realmProfile.ts @@ -0,0 +1,212 @@ +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; + /** + * The ruler's roleplay character name, as SimpleFactions last saw it in + * RPCharacters. Never the Minecraft username: the map names people by their + * character, and a realm whose leader has not been seen online since the + * export learned this shows no ruler name rather than an account name. + */ + 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, + }; +} + +/** + * `leader character`, but only while it still belongs to the current leader + * (`leader character of`). SimpleFactions already drops a stale name on save; + * checking again here covers an export written between a leadership change + * and the next save. + */ +export function leaderCharacterName(raw: Record): string | null { + const name = text(raw["leader character"]); + if (!name) return null; + const of = raw["leader character of"]; + const leader = raw.leader; + if (typeof of === "string" && typeof leader === "string") { + if (of.toLowerCase() !== leader.toLowerCase()) return null; + } + return name; +} + +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: leaderCharacterName(raw), + 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/map/tilePyramid.test.ts b/frontend/app/lib/map/tilePyramid.test.ts new file mode 100644 index 00000000..266b0894 --- /dev/null +++ b/frontend/app/lib/map/tilePyramid.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + allTiles, + backdropLevel, + isTileManifest, + overlayLod, + pickTileLevel, + visibleTiles, + type TileManifest, + tilePixelRatio, +} from "./tilePyramid"; + +// The live map's shape: 6400 px square, five halvings to one tile. +const manifest: TileManifest = { + ready: true, + version: "123", + width: 6400, + height: 6400, + tile_size: 256, + max_level: 5, + levels: [ + { width: 200, height: 200 }, + { width: 400, height: 400 }, + { width: 800, height: 800 }, + { width: 1600, height: 1600 }, + { width: 3200, height: 3200 }, + { width: 6400, height: 6400 }, + ], +}; + +describe("isTileManifest", () => { + it("accepts a ready manifest and rejects the rest", () => { + expect(isTileManifest(manifest)).toBe(true); + expect(isTileManifest({ ready: false })).toBe(false); + expect(isTileManifest({ ...manifest, levels: [] })).toBe(false); + expect(isTileManifest(null)).toBe(false); + }); +}); + +describe("pickTileLevel", () => { + it("picks the lowest level that is not stretched", () => { + // Whole map on a 900 px screen: 0.14 screen px per map px, so the + // 800 px level (0.125) would be stretched and the 1600 px one is used. + expect(pickTileLevel(manifest, 0.14)).toBe(3); + expect(pickTileLevel(manifest, 0.125)).toBe(2); // exactly 800/6400 + expect(pickTileLevel(manifest, 0.5)).toBe(4); + }); + + it("accounts for high-density screens", () => { + expect(pickTileLevel(manifest, 0.14, 2)).toBe(4); + }); + + it("stretches full size once zoomed past it", () => { + expect(pickTileLevel(manifest, 3)).toBe(5); + }); +}); + +describe("backdropLevel", () => { + it("is the sharpest level still a few tiles across", () => { + // 800 px = 4 tiles across; 1600 px would be 7. + expect(backdropLevel(manifest)).toBe(2); + expect(backdropLevel(manifest, 1)).toBe(0); + }); +}); + +describe("tiles", () => { + it("places edge tiles at their real size in map pixels", () => { + const level0 = allTiles(manifest, 0); + expect(level0).toHaveLength(1); + expect(level0[0]).toMatchObject({ left: 0, top: 0, width: 6400, height: 6400 }); + + // 6400 / 256 = 25 tiles across at full size, the last one whole. + const top = allTiles(manifest, 5); + expect(top).toHaveLength(625); + // 1600 px is 6.25 tiles: the seventh column is a quarter tile wide. + const edge = allTiles(manifest, 3).find((tile) => tile.x === 6 && tile.y === 0); + expect(edge).toMatchObject({ left: 6 * 1024, width: 64 * 4 }); + }); + + it("only returns tiles on screen, plus a margin", () => { + // Full-size level, zoomed so 1 screen px = 1 map px, viewing the top-left + // 1000 x 500 px of the map. + const view = { + displayScale: 1, + translateX: 0, + translateY: 0, + viewportW: 1000, + viewportH: 500, + }; + const tiles = visibleTiles(manifest, 5, view, 0); + expect(tiles.map((tile) => tile.x).sort((a, b) => a - b).at(-1)).toBe(3); + expect(Math.max(...tiles.map((tile) => tile.y))).toBe(1); + expect(tiles).toHaveLength(4 * 2); + + const withMargin = visibleTiles(manifest, 5, view, 1); + expect(withMargin).toHaveLength(5 * 3); + }); + + it("follows the pan", () => { + const tiles = visibleTiles( + manifest, + 5, + { displayScale: 1, translateX: -2560, translateY: -2560, viewportW: 256, viewportH: 256 }, + 0 + ); + expect(tiles.map((tile) => tile.key)).toEqual(["5/10/10"]); + }); + + it("returns nothing for an unmeasured viewport", () => { + expect( + visibleTiles(manifest, 3, { + displayScale: 0, + translateX: 0, + translateY: 0, + viewportW: 0, + viewportH: 0, + }) + ).toEqual([]); + }); +}); + +describe("overlayLod", () => { + it("reduces overlays when zoomed out, never below a pixel per device pixel", () => { + expect(overlayLod(0.14)).toBe(2); + expect(overlayLod(0.14, 2)).toBe(1); + expect(overlayLod(0.6)).toBe(0); + expect(overlayLod(2)).toBe(0); + expect(overlayLod(0.01)).toBe(3); + expect(overlayLod(0)).toBe(0); + }); +}); + +describe("tilePixelRatio", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("caps dense screens at 2x", () => { + vi.stubGlobal("window", { devicePixelRatio: 3 }); + expect(tilePixelRatio()).toBe(2); + vi.stubGlobal("window", { devicePixelRatio: 1.5 }); + expect(tilePixelRatio()).toBe(1.5); + }); +}); diff --git a/frontend/app/lib/map/tilePyramid.ts b/frontend/app/lib/map/tilePyramid.ts new file mode 100644 index 00000000..6e5de45f --- /dev/null +++ b/frontend/app/lib/map/tilePyramid.ts @@ -0,0 +1,192 @@ +/** + * Tile pyramid maths for the map's full-size rasters, served by the backend's + * `/{map}/tiles/{layer}` routes. Level 0 is the whole map in one tile; each + * level above doubles the resolution up to `max_level`, which is full size. + * The client only loads the tiles on screen, at the level matching its zoom. + */ + +export type TileLevel = { width: number; height: number }; + +export type TileManifest = { + ready: true; + version: string; + width: number; + height: number; + tile_size: number; + max_level: number; + levels: TileLevel[]; +}; + +/** What part of the map is on screen, in the viewport's own terms. */ +export type TileView = { + displayScale: number; + translateX: number; + translateY: number; + viewportW: number; + viewportH: number; +}; + +/** One tile, positioned in map pixels (the coordinate space of the map layer). */ +export type PlacedTile = { + key: string; + level: number; + x: number; + y: number; + left: number; + top: number; + width: number; + height: number; +}; + +export function isTileManifest(value: unknown): value is TileManifest { + if (!value || typeof value !== "object") return false; + const m = value as Partial; + return ( + m.ready === true && + typeof m.version === "string" && + typeof m.width === "number" && + typeof m.height === "number" && + typeof m.tile_size === "number" && + m.tile_size > 0 && + typeof m.max_level === "number" && + Array.isArray(m.levels) && + m.levels.length === m.max_level + 1 + ); +} + +/** Level pixels per map pixel. */ +function levelScale(manifest: TileManifest, level: number): number { + return manifest.levels[level].width / manifest.width; +} + +/** + * The lowest level with at least one level pixel per device pixel at this + * zoom, so tiles are never stretched when a sharper level exists. Past full + * size there is nothing sharper: the top level is stretched. + */ +export function pickTileLevel( + manifest: TileManifest, + displayScale: number, + devicePixelRatio = 1 +): number { + const wanted = displayScale * Math.max(1, devicePixelRatio); + for (let level = 0; level <= manifest.max_level; level++) { + if (levelScale(manifest, level) >= wanted * 0.999) return level; + } + return manifest.max_level; +} + +/** + * The always-loaded backdrop: the sharpest level that is still only a few + * tiles across. Drawn under everything, so a fast pan or zoom shows a soft + * map while sharper tiles arrive rather than holes. + */ +export function backdropLevel(manifest: TileManifest, maxTilesAcross = 4): number { + let best = 0; + for (let level = 0; level <= manifest.max_level; level++) { + const { width, height } = manifest.levels[level]; + const across = Math.ceil(Math.max(width, height) / manifest.tile_size); + if (across <= maxTilesAcross) best = level; + } + return best; +} + +function placeTile(manifest: TileManifest, level: number, x: number, y: number): PlacedTile { + const size = manifest.tile_size; + const { width: lw, height: lh } = manifest.levels[level]; + const toMap = manifest.width / lw; + const pixelW = Math.min(size, lw - x * size); + const pixelH = Math.min(size, lh - y * size); + return { + key: `${level}/${x}/${y}`, + level, + x, + y, + left: x * size * toMap, + top: y * size * toMap, + width: pixelW * toMap, + height: pixelH * toMap, + }; +} + +/** Every tile of a level. */ +export function allTiles(manifest: TileManifest, level: number): PlacedTile[] { + const { width, height } = manifest.levels[level]; + const size = manifest.tile_size; + const tiles: PlacedTile[] = []; + for (let y = 0; y < Math.ceil(height / size); y++) { + for (let x = 0; x < Math.ceil(width / size); x++) { + tiles.push(placeTile(manifest, level, x, y)); + } + } + return tiles; +} + +/** + * The tiles of `level` that intersect the viewport, plus `margin` tiles on + * each side so a short pan does not reveal an edge before the next settle. + */ +export function visibleTiles( + manifest: TileManifest, + level: number, + view: TileView, + margin = 1 +): PlacedTile[] { + if (view.displayScale <= 0 || view.viewportW <= 0 || view.viewportH <= 0) { + return []; + } + const { width: lw, height: lh } = manifest.levels[level]; + const size = manifest.tile_size; + const scale = levelScale(manifest, level); + const cols = Math.ceil(lw / size); + const rows = Math.ceil(lh / size); + + // Viewport corners in map pixels, then in level pixels. + const mapLeft = -view.translateX / view.displayScale; + const mapTop = -view.translateY / view.displayScale; + const mapRight = (view.viewportW - view.translateX) / view.displayScale; + const mapBottom = (view.viewportH - view.translateY) / view.displayScale; + + const x0 = Math.max(0, Math.floor((mapLeft * scale) / size) - margin); + const y0 = Math.max(0, Math.floor((mapTop * scale) / size) - margin); + // Right and bottom edges are exclusive: a tile that only touches the + // viewport edge is not on screen. + const x1 = Math.min(cols - 1, Math.ceil((mapRight * scale) / size) - 1 + margin); + const y1 = Math.min(rows - 1, Math.ceil((mapBottom * scale) / size) - 1 + margin); + + const tiles: PlacedTile[] = []; + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + tiles.push(placeTile(manifest, level, x, y)); + } + } + return tiles; +} + +/** + * Reduction for a single overlay image at this zoom: 2**lod smaller, as long + * as that still has a pixel per device pixel. Capped to what the backend + * serves. + */ +export function overlayLod( + displayScale: number, + devicePixelRatio = 1, + maxLod = 3 +): number { + const wanted = displayScale * Math.max(1, devicePixelRatio); + if (!(wanted > 0)) return 0; + return Math.max(0, Math.min(maxLod, Math.floor(Math.log2(1 / wanted)))); +} + +/** + * The screen density map imagery is fetched for. A 3x phone would otherwise + * load about twice the tiles of a 2x one, for detail it can barely show, and + * hold them all decoded; iOS Safari reloads a page that uses too much memory. + * Google Maps serves phones 2x tiles at most, too. + */ +export const MAX_TILE_PIXEL_RATIO = 2; + +export function tilePixelRatio(): number { + if (typeof window === "undefined") return 1; + return Math.min(window.devicePixelRatio || 1, MAX_TILE_PIXEL_RATIO); +} 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/mapLabels.test.ts b/frontend/app/lib/mapLabels.test.ts index 0a04f9ae..030e891d 100644 --- a/frontend/app/lib/mapLabels.test.ts +++ b/frontend/app/lib/mapLabels.test.ts @@ -33,9 +33,11 @@ import { LABEL_MIN_SCREEN_PX, labelScreenFontSize, shouldShowLabelAtScreenSize, + LABEL_MAX_SCREEN_PX, type LabelMapObject, type ProvinceCentroids, type ProvinceNeighbors, + pickRegionLabelAt, } from "./mapLabels"; import type { ProvinceLabelGrid } from "./labelBlobGeometry"; import type { TitleLayers } from "./titleProvinces"; @@ -251,6 +253,12 @@ describe("shouldShowLabelAtScreenSize", () => { expect(shouldShowLabelAtScreenSize(24, displayScale)).toBe(true); }); + it("hides labels once they would fill the screen", () => { + expect(shouldShowLabelAtScreenSize(LABEL_MAX_SCREEN_PX, 1)).toBe(true); + expect(shouldShowLabelAtScreenSize(LABEL_MAX_SCREEN_PX + 1, 1)).toBe(false); + expect(shouldShowLabelAtScreenSize(80, 2)).toBe(false); + }); + it("hides labels when display scale is zero", () => { expect(shouldShowLabelAtScreenSize(24, 0)).toBe(false); }); @@ -1020,3 +1028,42 @@ describe("computeVisibleRegionLabels", () => { ); }); }); + +describe("pickRegionLabelAt", () => { + // A level name across x 1000..2000 at y 1000: its text is about 350 px + // wide, centred at x 1500, with its letters centred on y 1000. + const fontSize = 100; + const text = "Abcdef"; + const ext = extendLabelEndpoints(1000, 1000, 2000, 1000); + const offset = labelPathCenterOffset(ext.x1, ext.y1, ext.x2, ext.y2, fontSize); + const label = { + nationId: "Realm", + componentIndex: 0, + text, + scope: "full" as const, + x1: 1000, + y1: 1000, + x2: 2000, + y2: 1000, + cx: 1500, + cy: 1000, + angleDeg: 0, + segmentPx: 1000, + fontSize, + pathD: labelArcPathD(ext.x1, ext.y1, ext.x2, ext.y2), + pathOffsetX: offset.dx, + pathOffsetY: offset.dy, + }; + const scale = 0.2; // 20 px on screen: drawn + + it("names the region under its letters", () => { + expect(pickRegionLabelAt([label], 1500, 1000, scale)).toBe("Realm"); + expect(pickRegionLabelAt([label], 1640, 1010, scale)).toBe("Realm"); + }); + + it("ignores points off the text, and names not drawn at this zoom", () => { + expect(pickRegionLabelAt([label], 1500, 1200, scale)).toBeNull(); + expect(pickRegionLabelAt([label], 1900, 1000, scale)).toBeNull(); + expect(pickRegionLabelAt([label], 1500, 1000, 0.01)).toBeNull(); + }); +}); diff --git a/frontend/app/lib/mapLabels.ts b/frontend/app/lib/mapLabels.ts index 79c6e469..34d8fd99 100644 --- a/frontend/app/lib/mapLabels.ts +++ b/frontend/app/lib/mapLabels.ts @@ -52,6 +52,12 @@ export const LABEL_FONT_WEIGHT = 500; export const DEFAULT_MAP_ZOOM = 1; /** Minimum on-screen font size (px) for a label to appear. */ export const LABEL_MIN_SCREEN_PX = 6; +/** + * Realm names are drawn in map units, so they grow with zoom. Past this + * screen size a name is a wall of ink over the land it names; it fades out + * and the reader is close enough to see settlements instead. + */ +export const LABEL_MAX_SCREEN_PX = 140; export function labelScreenFontSize( fontSize: number, @@ -66,7 +72,7 @@ export function shouldShowLabelAtScreenSize( ): boolean { if (displayScale <= 0 || fontSize <= 0) return false; const screenPx = labelScreenFontSize(fontSize, displayScale); - return screenPx >= LABEL_MIN_SCREEN_PX; + return screenPx >= LABEL_MIN_SCREEN_PX && screenPx <= LABEL_MAX_SCREEN_PX; } export type ProvinceNeighbors = Record; @@ -438,6 +444,84 @@ export function labelPathCenterOffset( return { dx: -nx * offsetPx, dy: -ny * offsetPx }; } +/** + * The region whose drawn name is under map point (`x`, `y`), or null. The + * names ignore the pointer so the land under them stays hoverable, but a + * name laid across water or a neighbour's land is still read as its region: + * pointing at "The Clockwork" over the sea between its islands means The + * Clockwork. Only names drawn at this zoom count. + * + * Follows the text the way `LabelLayer` draws it: along the arched, extended + * baseline (`labelArcPathD`, shifted by `pathOffset`), centred on it, glyph + * centres `LABEL_TEXT_CENTER_OFFSET_EM` above it, about as long as + * `estimatedLabelWidthPx`. + */ +export function pickRegionLabelAt( + labels: NationLabelSpec[], + x: number, + y: number, + displayScale: number +): string | null { + const SAMPLES = 48; + let best: { id: string; distance: number } | null = null; + for (const label of labels) { + if (!shouldShowLabelAtScreenSize(label.fontSize, displayScale)) continue; + const halfWidth = estimatedLabelWidthPx(label.fontSize, label.text) / 2; + const reach = halfWidth + label.fontSize; + if (Math.abs(x - label.cx) > reach + label.segmentPx || Math.abs(y - label.cy) > reach + label.segmentPx) { + continue; + } + + const extended = extendLabelEndpoints(label.x1, label.y1, label.x2, label.y2); + const { x1: ax, y1: ay, x2: bx, y2: by } = orientLabelEndpoints( + extended.x1, + extended.y1, + extended.x2, + extended.y2 + ); + const len = Math.hypot(bx - ax, by - ay); + if (len === 0) continue; + // Toward the top of the letters, as labelArcPathD bulges. + const nx = (by - ay) / len; + const ny = -(bx - ax) / len; + const bulge = len * LABEL_ARC_BULGE_RATIO; + const qx = (ax + bx) / 2 + nx * bulge; + const qy = (ay + by) / 2 + ny * bulge; + const lift = label.fontSize * LABEL_TEXT_CENTER_OFFSET_EM; + const ox = label.pathOffsetX + nx * lift; + const oy = label.pathOffsetY + ny * lift; + + let travelled = 0; + let prevX = ax + ox; + let prevY = ay + oy; + let nearest = Infinity; + let nearestAt = 0; + const along: number[] = []; + for (let i = 0; i <= SAMPLES; i += 1) { + const t = i / SAMPLES; + const u = 1 - t; + const px = u * u * ax + 2 * u * t * qx + t * t * bx + ox; + const py = u * u * ay + 2 * u * t * qy + t * t * by + oy; + if (i > 0) travelled += Math.hypot(px - prevX, py - prevY); + along.push(travelled); + const distance = Math.hypot(x - px, y - py); + if (distance < nearest) { + nearest = distance; + nearestAt = i; + } + prevX = px; + prevY = py; + } + const fromMiddle = Math.abs(along[nearestAt] - travelled / 2); + // The width is an estimate; a third of a letter's slack catches the ends. + if (nearest > label.fontSize * 0.55 || fromMiddle > halfWidth + label.fontSize * 0.35) { + continue; + } + if (!best || nearest < best.distance) best = { id: label.nationId, distance: nearest }; + } + return best?.id ?? null; +} + function centroidOf( provinceId: number, centroids: ProvinceCentroids @@ -835,14 +919,19 @@ export function provincesForRegionLabelGeometry( return { provinces, scope: "full" }; } -export function computeRegionLabelGeometry( +/** + * `computeRegionLabelGeometry` a region at a time: yields after each one, so a + * caller can spread a mode's layout over several tasks. Laid out in one go, + * the counties held the page for a quarter of a second on a first visit. + */ +export function* computeRegionLabelGeometrySteps( mapType: MapMode, regionData: Record, titleLayers: TitleLayers | null, neighbors: ProvinceNeighbors, centroids: ProvinceCentroids, options?: ComputeNationLabelsOptions -): RegionLabelGeometryCache | null { +): Generator { if (mapType === "nation") { const nations: NationLabelGeometry[] = []; const occupation = buildOccupationIndex(regionData); @@ -892,6 +981,7 @@ export function computeRegionLabelGeometry( } nations.push({ nationId, full, direct }); + yield; } return { mapType: "nation", nations }; @@ -936,11 +1026,34 @@ export function computeRegionLabelGeometry( options ) ); + yield; } return { mapType, labels }; } +/** A mode's name layout, in one go. See `computeRegionLabelGeometrySteps`. */ +export function computeRegionLabelGeometry( + mapType: MapMode, + regionData: Record, + titleLayers: TitleLayers | null, + neighbors: ProvinceNeighbors, + centroids: ProvinceCentroids, + options?: ComputeNationLabelsOptions +): RegionLabelGeometryCache | null { + const steps = computeRegionLabelGeometrySteps( + mapType, + regionData, + titleLayers, + neighbors, + centroids, + options + ); + let step = steps.next(); + while (!step.done) step = steps.next(); + return step.value; +} + export function filterRegionLabelsForMapObjects( cache: RegionLabelGeometryCache | null, mapType: MapMode, diff --git a/frontend/app/lib/mapMarkers.ts b/frontend/app/lib/mapMarkers.ts index 57fd068f..4d7565d9 100644 --- a/frontend/app/lib/mapMarkers.ts +++ b/frontend/app/lib/mapMarkers.ts @@ -10,6 +10,8 @@ export const MARKER_VISIBILITY_TRANSITION = "opacity 200ms ease-out"; export const MARKER_SMALL_PX = 100; export const MARKER_LARGE_PX = 90; export const MARKER_LABEL_FONT_SMALL = 48; +/** Guild seats: between a plain settlement and a realm capital. */ +export const MARKER_LABEL_FONT_MEDIUM = 58; export const MARKER_LABEL_FONT_LARGE = 72; export const MARKER_LABEL_GAP = 2; export const MARKER_LABEL_COLOR = "var(--tfmc-cream)"; @@ -36,6 +38,20 @@ export const MARKER_CHIP_TRANSITION = export const MARKER_ICON_HOVER_GLOW = "drop-shadow(0 0 6px color-mix(in srgb, var(--tfmc-accent) 45%, transparent))"; export const MARKER_LABEL_MIN_SCREEN_PX = 9; + +/** + * Markers are sized in map pixels, so they grow as the map zooms in. Past + * this display scale they stop growing and hold their screen size, the way + * map pins do on Google Maps: at 0.35 a small marker's icon is 35 px across. + */ +export const MARKER_MAX_DISPLAY_SCALE = 0.35; + +/** Factor to shrink a marker's map-pixel size by so it holds its screen size. */ +export function markerZoomScale(displayScale: number): number { + return displayScale > MARKER_MAX_DISPLAY_SCALE + ? MARKER_MAX_DISPLAY_SCALE / displayScale + : 1; +} export const INSTALLATION_ICON_SCALE = 0.75; export const BATTLE_ICON_SCALE = INSTALLATION_ICON_SCALE; // Marker chips sit above the serif nation labels (z-15) so a settlement name is @@ -43,7 +59,12 @@ export const BATTLE_ICON_SCALE = INSTALLATION_ICON_SCALE; export const MARKER_LAYER_Z_ABOVE_LABELS = 16; export const MARKER_LAYER_Z_HOVERED = 17; -export type MapMarkerSize = "small" | "large"; +/** + * How prominent a pin is. Settlements are sized by what they are, not by + * population: realm capitals `large`, guild seats `medium`, everything else + * `small`. Prominence also decides which label wins when two would overlap. + */ +export type MapMarkerSize = "small" | "medium" | "large"; export const INSTALLATION_MARKER_KINDS = new Set([ "fort", @@ -55,6 +76,8 @@ export const BATTLE_MARKER_KIND = "battle"; export type MapMarker = { id: string; + /** Tie-break between equally prominent labels; higher wins (population). */ + weight?: number; kind: string; markerSize?: MapMarkerSize; mapX: number; @@ -170,6 +193,12 @@ export function markerDimensions(markerSize: MapMarkerSize | undefined): { fontSize: MARKER_LABEL_FONT_LARGE, }; } + if (markerSize === "medium") { + return { + size: MARKER_SMALL_PX, + fontSize: MARKER_LABEL_FONT_MEDIUM, + }; + } return { size: MARKER_SMALL_PX, fontSize: MARKER_LABEL_FONT_SMALL, @@ -191,9 +220,12 @@ export function markerLayout( mapX: number, mapY: number, markerSize: MapMarkerSize | undefined, - kind?: string + kind?: string, + zoomScale = 1 ): MapMarkerLayout { - const { size, fontSize } = markerDimensions(markerSize); + const dimensions = markerDimensions(markerSize); + const size = dimensions.size * zoomScale; + const fontSize = dimensions.fontSize * zoomScale; const iconSize = size * markerIconScale(kind); const imageY = mapY - size / 2; return { @@ -204,7 +236,7 @@ export function markerLayout( size, iconSize, fontSize, - textY: imageY + size + MARKER_LABEL_GAP, + textY: imageY + size + MARKER_LABEL_GAP * zoomScale, }; } @@ -250,20 +282,26 @@ export function markerHitBounds( export function pickMapMarkerAt( markers: MapMarker[], x: number, - y: number + y: number, + displayScale = 0, + /** Labels decluttered away: only their pins can be pointed at. */ + hiddenLabels: ReadonlySet = EMPTY_SET ): MapMarker | null { + // Hit-test the marker at the size it is drawn, not its map-pixel size. + const zoomScale = markerZoomScale(displayScale); for (let i = markers.length - 1; i >= 0; i--) { const marker = markers[i]; const layout = markerLayout( marker.mapX, marker.mapY, marker.markerSize, - marker.kind + marker.kind, + zoomScale ); const bounds = markerHitBounds( layout, marker.label, - !marker.showLabelOnlyOnHover + !marker.showLabelOnlyOnHover && !hiddenLabels.has(marker.id) ); if ( x >= bounds.x && @@ -285,3 +323,101 @@ export function markerHoverTransform( const scale = hovered ? MARKER_HOVER_SCALE : 1; return `translate(${mapX} ${mapY}) scale(${scale}) translate(${-mapX} ${-mapY})`; } + +const EMPTY_SET: ReadonlySet = new Set(); + +const SIZE_RANK: Record = { large: 0, medium: 1, small: 2 }; + +/** Screen gap kept between labels, in pixels. */ +const LABEL_CLEARANCE_PX = 3; + +type Rect = { x: number; y: number; w: number; h: number }; + +function overlaps(a: Rect, b: Rect): boolean { + return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; +} + +function iconRect(layout: MapMarkerLayout): Rect { + const offset = (layout.size - layout.iconSize) / 2; + return { + x: layout.imageX + offset, + y: layout.imageY + offset, + w: layout.iconSize, + h: layout.iconSize, + }; +} + +/** Where a marker's name chip is drawn, in map pixels. */ +export function markerLabelRect(layout: MapMarkerLayout, label: string): Rect { + const chipPadX = layout.fontSize * (MARKER_CHIP_PAD_X_EM + MARKER_CHIP_BORDER_EM) * 2; + const chipPadY = layout.fontSize * (MARKER_CHIP_PAD_Y_EM + MARKER_CHIP_BORDER_EM) * 2; + const w = label.length * layout.fontSize * 0.55 + chipPadX; + return { + x: layout.mapX - w / 2, + y: layout.textY, + w, + h: layout.fontSize + chipPadY, + }; +} + +const declutterCache = new WeakMap }>(); + +/** + * Which visible markers' names to leave off so that no two overlap, the way + * map sites thin labels out as you zoom out. + * + * Labels are placed most prominent first (capital, guild seat, settlement; + * then the larger population), and a label that would cross a name already + * placed, or another pin, is dropped. Its pin stays, and zooming in brings the + * name back once there is room. Cached per marker list and scale so the layer + * that draws the labels and the hover that hit-tests them always agree. + */ +export function hiddenMarkerLabels(markers: MapMarker[], displayScale: number): Set { + const cached = declutterCache.get(markers); + if (cached && cached.scale === displayScale) return cached.hidden; + + const hidden = new Set(); + if (displayScale > 0) { + const zoomScale = markerZoomScale(displayScale); + const pad = LABEL_CLEARANCE_PX / displayScale; + const visible = filterVisibleMapMarkers(markers, displayScale); + const layouts = new Map( + visible.map((marker) => [ + marker.id, + markerLayout(marker.mapX, marker.mapY, marker.markerSize, marker.kind, zoomScale), + ]) + ); + const ordered = [...visible].sort( + (a, b) => + // Hover-only pins (installations, battles) carry no label to place; + // their icons go first so no name is laid over them. + Number(!a.showLabelOnlyOnHover) - Number(!b.showLabelOnlyOnHover) || + SIZE_RANK[a.markerSize ?? "small"] - SIZE_RANK[b.markerSize ?? "small"] || + (b.weight ?? 0) - (a.weight ?? 0) || + a.label.localeCompare(b.label) + ); + + // A label gives way only to names and pins placed before it, i.e. ones at + // least as prominent. A capital's name may run over a hamlet's pin; a + // hamlet's name never covers the capital. + const placedLabels: Rect[] = []; + const placedIcons: Rect[] = []; + for (const marker of ordered) { + const layout = layouts.get(marker.id)!; + if (!marker.showLabelOnlyOnHover && marker.label) { + const base = markerLabelRect(layout, marker.label); + const rect = { x: base.x - pad, y: base.y - pad, w: base.w + pad * 2, h: base.h + pad * 2 }; + const blocked = + placedLabels.some((other) => overlaps(rect, other)) || + placedIcons.some((icon) => overlaps(rect, icon)); + if (blocked) hidden.add(marker.id); + else placedLabels.push(rect); + } + placedIcons.push(iconRect(layout)); + } + } + + declutterCache.set(markers, { scale: displayScale, hidden }); + return hidden; +} + diff --git a/frontend/app/lib/mapMarkersDeclutter.test.ts b/frontend/app/lib/mapMarkersDeclutter.test.ts new file mode 100644 index 00000000..26b45809 --- /dev/null +++ b/frontend/app/lib/mapMarkersDeclutter.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + hiddenMarkerLabels, + markerDimensions, + pickMapMarkerAt, + type MapMarker, +} from "./mapMarkers"; + +const pin = (id: string, x: number, y: number, extra: Partial = {}): MapMarker => ({ + id, + kind: "settlement", + markerSize: "small", + mapX: x, + mapY: y, + label: id, + title: id, + ...extra, +}); + +// 0.3 screen px per map px: every marker visible, labels a sensible size. +const SCALE = 0.3; + +describe("hiddenMarkerLabels", () => { + it("keeps every label when nothing overlaps", () => { + const markers = [pin("Alpha", 0, 0), pin("Bravo", 3000, 3000)]; + expect(hiddenMarkerLabels(markers, SCALE).size).toBe(0); + }); + + it("lets the capital's name win over a settlement's", () => { + const markers = [ + pin("Prospero", 1000, 1000), + pin("The Guildhouse", 1010, 1000, { kind: "faction_capital", markerSize: "large" }), + ]; + expect([...hiddenMarkerLabels(markers, SCALE)]).toEqual(["Prospero"]); + }); + + it("breaks a tie by population", () => { + const markers = [ + pin("Small", 1000, 1000, { weight: 3 }), + pin("Bigger", 1000, 1020, { weight: 9 }), + ]; + expect([...hiddenMarkerLabels(markers, SCALE)]).toEqual(["Small"]); + }); + + it("brings names back when zoomed in far enough", () => { + const markers = [pin("Prospero", 1000, 1000), pin("Guildhouse", 1100, 1000, { markerSize: "large" })]; + expect(hiddenMarkerLabels(markers, SCALE).size).toBe(1); + expect(hiddenMarkerLabels(markers, 4).size).toBe(0); + }); + + it("ignores hover-only labels (installations)", () => { + const markers = [ + pin("Town", 1000, 1000), + pin("Port", 1005, 1000, { kind: "port", showLabelOnlyOnHover: true }), + ]; + // The port's pin still blocks the town's name, but the port has no label to hide. + expect(hiddenMarkerLabels(markers, SCALE).has("Port")).toBe(false); + }); + + it("does not hit-test a hidden label, only its pin", () => { + const markers = [pin("Prospero", 1000, 1000), pin("Guildhouse", 1010, 1000, { markerSize: "large" })]; + const hidden = hiddenMarkerLabels(markers, SCALE); + // A point in Prospero's would-be label area, well clear of both pins. + const labelY = 1000 + markerDimensions("small").size / 2 + 20; + expect(pickMapMarkerAt([markers[0]], 1000, labelY, SCALE)?.id).toBe("Prospero"); + expect(pickMapMarkerAt([markers[0]], 1000, labelY, SCALE, hidden)).toBeNull(); + }); +}); + +describe("marker sizes", () => { + it("sets a guild seat's name between a settlement's and a capital's", () => { + const small = markerDimensions("small").fontSize; + const medium = markerDimensions("medium").fontSize; + const large = markerDimensions("large").fontSize; + expect(small).toBeLessThan(medium); + expect(medium).toBeLessThan(large); + }); +}); diff --git a/frontend/app/lib/mapViewportMath.test.ts b/frontend/app/lib/mapViewportMath.test.ts index f699829c..81d69ec4 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 }; @@ -40,90 +43,40 @@ describe("computeFitScale", () => { }); describe("clampTranslate", () => { - it("forces translate to zero at user zoom 1", () => { - const fitScale = computeFitScale(viewport, map); - const displayScale = computeDisplayScale(fitScale, 1); - expect(clampTranslate(viewport, map, displayScale, -200, -300)).toEqual({ - x: 0, - y: 0, - }); - }); - - it("cannot pan past edges at zoom 2", () => { - const fitScale = computeFitScale(viewport, map); - const displayScale = computeDisplayScale(fitScale, 2); - const minX = viewport.w - map.w * displayScale; - const minY = viewport.h - map.h * displayScale; - - expect( - clampTranslate(viewport, map, displayScale, minX - 100, minY - 100) - ).toEqual({ x: minX, y: minY }); - expect( - clampTranslate(viewport, map, displayScale, 100, 100) - ).toEqual({ x: 0, y: 0 }); + // viewport 1000 x 1000, map 2000 x 2000. + it("leaves a translate inside the bounds alone", () => { + expect(clampTranslate(viewport, map, 0.5, -200, -300)).toEqual({ x: -200, y: -300 }); }); - it("cannot pan past edges at zoom 3", () => { - const fitScale = computeFitScale(viewport, map); - const displayScale = computeDisplayScale(fitScale, 3); - const minX = viewport.w - map.w * displayScale; - const minY = viewport.h - map.h * displayScale; - - expect(clampTranslate(viewport, map, displayScale, minX - 50, 50)).toEqual( - { x: minX, y: 0 } - ); - }); - - it("cannot pan past edges at max zoom 4.5", () => { - const fitScale = computeFitScale(viewport, map); - const displayScale = computeDisplayScale(fitScale, 4.5); - const minX = viewport.w - map.w * displayScale; - - expect(clampTranslate(viewport, map, displayScale, minX - 25, 0)).toEqual( - { x: minX, y: 0 } - ); - }); -}); - -describe("clampTranslate centers a slack axis instead of pinning it to the edge", () => { - // Cover-fit (the actual fit mode) rarely leaves slack — it exists to - // guarantee both axes are fully covered. But nothing in clampTranslate - // itself assumes cover-fit produced displayScale; it must still do the - // right thing if a caller (or a future fit mode) ever passes a scale that - // leaves one axis short of the viewport. Constructing displayScale - // directly here, rather than through computeFitScale, tests that - // independently of which fit mode is active. - it("centers a narrower-than-viewport map horizontally instead of pinning it left", () => { - const wide = { w: 1600, h: 800 }; - const displayScale = 0.3; // displayW = 600, short of the 1600 viewport - const displayW = map.w * displayScale; - - const result = clampTranslate(wide, map, displayScale, 0, 0); - expect(result.x).toBe((wide.w - displayW) / 2); + it("lets a map corner reach the middle of the screen, and no further", () => { + const displayScale = 1; // the map is 2000 px across on a 1000 px screen + // Top-left corner at the centre: translate (500, 500). + expect(clampTranslate(viewport, map, displayScale, 900, 900)).toEqual({ x: 500, y: 500 }); + // Bottom-right corner at the centre: translate (500 - 2000, ...). + expect(clampTranslate(viewport, map, displayScale, -5000, -5000)).toEqual({ + x: -1500, + y: -1500, + }); }); - it("centers a shorter-than-viewport map vertically instead of pinning it to the top", () => { - const tall = { w: 800, h: 1600 }; - const displayScale = 0.3; // displayH = 600, short of the 1600 viewport - const displayH = map.h * displayScale; - - const result = clampTranslate(tall, map, displayScale, 0, 0); - expect(result.y).toBe((tall.h - displayH) / 2); + it("keeps at least a quarter of the screen on the map at any zoom", () => { + for (const userScale of [1, 2, 3, MAP_ZOOM_MAX]) { + const displayScale = computeDisplayScale(computeFitScale(viewport, map), userScale); + for (const t of [-1e6, 1e6]) { + const { x, y } = clampTranslate(viewport, map, displayScale, t, t); + const visibleW = Math.min(viewport.w, x + map.w * displayScale) - Math.max(0, x); + const visibleH = Math.min(viewport.h, y + map.h * displayScale) - Math.max(0, y); + expect(visibleW).toBeGreaterThanOrEqual(viewport.w / 2 - 1e-9); + expect(visibleH).toBeGreaterThanOrEqual(viewport.h / 2 - 1e-9); + } + } }); - it("still clamps to the edges once the map is at least as large as the viewport", () => { + it("applies the same rule to a map smaller than the screen", () => { const wide = { w: 1600, h: 800 }; - const displayScale = 0.8; // displayW = 1600, exactly covers the viewport - const minX = wide.w - map.w * displayScale; - - expect(clampTranslate(wide, map, displayScale, 100, 0)).toEqual({ - x: 0, - y: 0, - }); - expect(clampTranslate(wide, map, displayScale, minX - 100, 0)).toEqual({ - x: minX, - y: 0, - }); + const displayScale = 0.3; // 600 px across + expect(clampTranslate(wide, map, displayScale, 5000, 0).x).toBe(800); + expect(clampTranslate(wide, map, displayScale, -5000, 0).x).toBe(800 - 600); }); }); @@ -233,3 +186,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..43f998ce 100644 --- a/frontend/app/lib/mapViewportMath.ts +++ b/frontend/app/lib/mapViewportMath.ts @@ -1,5 +1,9 @@ export const MAP_ZOOM_MIN = 1; -export const MAP_ZOOM_MAX = 4.5; +/** + * Deep enough to read individual map pixels: at 16x a contain-fit 6400 px map + * shows about two screen pixels per map pixel on a 900 px tall viewport. + */ +export const MAP_ZOOM_MAX = 16; export const MAP_ZOOM_WHEEL_FACTOR = 1.1; export type Size = { @@ -54,6 +58,14 @@ export function clampUserScale(scale: number): number { return Math.min(MAP_ZOOM_MAX, Math.max(MAP_ZOOM_MIN, scale)); } +/** + * How far the map may be dragged: until one of its corners reaches the middle + * of the screen, and no further. Zoomed in, that lets any edge or corner of + * the world be brought to the centre of the view instead of stopping at the + * screen edge; at any zoom, at least a quarter of the screen is still map, so + * the map can never be lost off-screen. The same rule OpenFront uses + * ("up to half of the viewport can be outside the map on each side"). + */ export function clampTranslate( viewport: Size, map: Size, @@ -64,29 +76,17 @@ export function clampTranslate( const displayW = map.w * displayScale; const displayH = map.h * displayScale; - let tx = translateX; - let ty = translateY; - - // A square map in a non-square (full-bleed) viewport leaves slack on - // whichever axis isn't the fit-limiting one — e.g. a wide screen has empty - // space left and right of a contain-fit square map. Centering that slack - // matches every other map viewer's default view; pinning it to 0 would - // shove the map into a corner with dead space beside it. - if (displayW <= viewport.w) { - tx = (viewport.w - displayW) / 2; - } else { - const minX = viewport.w - displayW; - tx = Math.min(0, Math.max(minX, tx)); - } - - if (displayH <= viewport.h) { - ty = (viewport.h - displayH) / 2; - } else { - const minY = viewport.h - displayH; - ty = Math.min(0, Math.max(minY, ty)); - } + // The map's left edge may come no further right than the screen's middle, + // and its right edge no further left; likewise vertically. + const minX = viewport.w / 2 - displayW; + const maxX = viewport.w / 2; + const minY = viewport.h / 2 - displayH; + const maxY = viewport.h / 2; - return { x: tx, y: ty }; + return { + x: Math.min(maxX, Math.max(minX, translateX)), + y: Math.min(maxY, Math.max(minY, translateY)), + }; } /** @@ -169,26 +169,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 +217,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/[map]/page.tsx b/frontend/app/map/[map]/page.tsx index 7f2a9824..b0b5b39c 100644 --- a/frontend/app/map/[map]/page.tsx +++ b/frontend/app/map/[map]/page.tsx @@ -1,7 +1,6 @@ import { notFound } from "next/navigation"; -import { MapEngineProvider } from "../../core/MapEngineContext"; -import MapViewer from "../../components/MapViewer"; +import SiteMap from "../../components/map/SiteMap"; import { parseMapRouteSegment } from "../../lib/map/chronicleDayRoute"; export default async function Page({ @@ -12,9 +11,5 @@ export default async function Page({ const mapId = parseMapRouteSegment((await params).map); if (!mapId) notFound(); - return ( - - - - ); + return ; } diff --git a/frontend/app/map/layout.tsx b/frontend/app/map/layout.tsx index 174083ea..d8f9fe31 100644 --- a/frontend/app/map/layout.tsx +++ b/frontend/app/map/layout.tsx @@ -1,3 +1,18 @@ +import type { Viewport } from "next"; + +/** + * No page zoom on the map: pinching the map zooms the map, and Safari + * zooming the whole page in (when the search box takes focus, or a pinch + * lands on a panel) only throws the layout off. Safari ignores + * user-scalable for pinches, hence also `touch-action` on the map shell. + */ +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + export default function MapLayout({ children, }: { @@ -5,7 +20,7 @@ export default function MapLayout({ }) { return (
    {children} diff --git a/frontend/app/map/page.tsx b/frontend/app/map/page.tsx new file mode 100644 index 00000000..cd9b62e9 --- /dev/null +++ b/frontend/app/map/page.tsx @@ -0,0 +1,6 @@ +import SiteMap from "../components/map/SiteMap"; + +/** The site's live map: `main` on the public site, the Dev server's on dev. */ +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/lib/map/api.ts b/frontend/lib/map/api.ts index 19b0fdbf..fc7ff170 100644 --- a/frontend/lib/map/api.ts +++ b/frontend/lib/map/api.ts @@ -14,6 +14,8 @@ export type AccessibleMapEntry = { chapter_name?: string; /** Present on archived chapters only. True when the chronicle index has days. */ has_chronicle_days?: boolean; + /** The map this site's `/map` shows (`main` on the public site). */ + live?: boolean; }; export type AccessibleMapsResponse = {