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 (
- {/* 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}
-
- }
- 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 = (
{
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.
+