Skip to content
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
b9663db
Rebuild the world map as a Google Maps-style viewer in CK3 frames
ryanbarlow97 Oct 3, 2026
2e625af
Keep the map on screen and in place when switching modes
ryanbarlow97 Oct 3, 2026
e5d64ee
Drop the gold trim for the site's own panel style
ryanbarlow97 Oct 3, 2026
29ea8ae
Make zooming smooth and allow zooming in to 16x
ryanbarlow97 Oct 3, 2026
6b26c93
Fade realm names out when zoomed in close
ryanbarlow97 Oct 3, 2026
b277b02
Draw the map from tiles, the way Google Maps does
ryanbarlow97 Oct 3, 2026
66eed40
Tile each map mode's region colours so mode switches are instant
ryanbarlow97 Oct 3, 2026
e69cb97
Preload map-mode colours and fix tile state carrying across modes
ryanbarlow97 Oct 3, 2026
75a2982
Place composite overlays by their crop box
ryanbarlow97 Oct 3, 2026
3a8b58a
Stop the trade tooltip flickering on mouse moves
ryanbarlow97 Oct 3, 2026
88960cc
Name realm rulers by their character, never their account
ryanbarlow97 Oct 3, 2026
f7f608e
Open a place card when a settlement or installation is clicked
ryanbarlow97 Oct 3, 2026
271caee
Make the place card's pin larger and drop the tiny banner in View realm
ryanbarlow97 Oct 3, 2026
645b659
Light hovered realms up in place, and keep them lit under a city
ryanbarlow97 Oct 3, 2026
d40db46
Let terrain show through realm highlights
ryanbarlow97 Oct 3, 2026
17e5bd0
Keep the ruler row when their character is not known yet
ryanbarlow97 Oct 3, 2026
109ed3e
Let each site choose which map /map shows
ryanbarlow97 Oct 3, 2026
4a507ae
Give guilds their own cards on the map
ryanbarlow97 Oct 3, 2026
fdbe07f
Size settlement pins by what they are, and stop names overlapping
ryanbarlow97 Oct 3, 2026
dcd3303
Say which guild a settlement is the capital of
ryanbarlow97 Oct 3, 2026
b80acb7
Let the map be dragged until a corner reaches the middle of the screen
ryanbarlow97 Oct 3, 2026
e130215
Make the trade map the Guilds map
ryanbarlow97 Oct 3, 2026
1a7ae5d
Name the realm on a guild card opened from the Guilds map
ryanbarlow97 Oct 3, 2026
d587be6
Draw borders on the Guilds (trade) map
ryanbarlow97 Oct 3, 2026
830ef7b
Call the trade map Trade again
ryanbarlow97 Oct 3, 2026
769d8bf
Make region regeneration fast enough to outline every mode
ryanbarlow97 Oct 3, 2026
6788e03
Focus a clicked realm: dim the world, open its subjects in place
ryanbarlow97 Oct 3, 2026
845cf82
Fade the other realms' colours instead of masking the focus
ryanbarlow97 Oct 3, 2026
24542a4
Keep an opened realm lit, and open it after a mode switch
ryanbarlow97 Oct 3, 2026
7a932c9
Lay out a mode's names faster, and once per page view
ryanbarlow97 Oct 3, 2026
b4db43b
Light a clicked guild on the trade map, and make names point at their…
ryanbarlow97 Oct 3, 2026
1cf8bc4
Order the map modes Realms | Empires to Counties | the rest
ryanbarlow97 Oct 3, 2026
dd5cb63
Keep the prosperity map tiled across regenerations
ryanbarlow97 Oct 3, 2026
ab55d88
Stop the map running iPhones out of memory
ryanbarlow97 Oct 3, 2026
7569239
Draw map highlights without filters on Safari
ryanbarlow97 Oct 3, 2026
4ea5cf5
Draw the title tiers as one family of crowns, as CK3 does
ryanbarlow97 Oct 3, 2026
332afc5
Decode the pick map off the main thread
ryanbarlow97 Oct 3, 2026
7279a46
Copy the pick map into its canvas a band at a time
ryanbarlow97 Oct 3, 2026
bd838ce
Lay out a mode's names in slices, not one long task
ryanbarlow97 Oct 3, 2026
37632e7
Clear the pick canvas a band at a time too
ryanbarlow97 Oct 3, 2026
faccebc
Hold the map's resting scale as CSS zoom, not a scale transform
ryanbarlow97 Oct 3, 2026
31927bf
Redraw the world-mode icons as things, not charts
ryanbarlow97 Oct 3, 2026
b70ad4b
Hide iOS's scroll indicator under the mobile mode chips
ryanbarlow97 Oct 3, 2026
a7012e8
Put Layers bottom left on phones, as on desktop
ryanbarlow97 Oct 3, 2026
c434d6b
Do not show hover tooltips after a tap
ryanbarlow97 Oct 3, 2026
f3edf5c
Tap a province in the raster modes to read it
ryanbarlow97 Oct 3, 2026
feb1ca0
Show a tapped province from the tap's click
ryanbarlow97 Oct 3, 2026
ea39803
Load province figures on entering a raster mode, show them on arrival
ryanbarlow97 Oct 3, 2026
e4ecd62
Stop the page itself scrolling under the full-screen map
ryanbarlow97 Oct 3, 2026
87c1cd6
Turn off page zoom on the map
ryanbarlow97 Oct 3, 2026
2c5bbab
Give phones a slimmer map layout
ryanbarlow97 Oct 3, 2026
31e23fb
Merge main: bring the infrastructure mode into the new map
ryanbarlow97 Oct 3, 2026
298eef0
Address CodeRabbit's review of the map overhaul
ryanbarlow97 Oct 3, 2026
c681299
Make the phone's Layers button an icon, as Google Maps does
ryanbarlow97 Oct 3, 2026
f6330c0
Stop text and image selection on the map page
ryanbarlow97 Oct 3, 2026
e46aef2
Meet map tiles on whole pixels instead of overlapping them
ryanbarlow97 Oct 3, 2026
8ebec45
Do not take a failed map list for "main is live"
ryanbarlow97 Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
55 changes: 44 additions & 11 deletions backend/src/api/file_routes.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)


Expand Down
38 changes: 37 additions & 1 deletion backend/src/api/map_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@
from __future__ import annotations

import os
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any

import yaml

_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

Expand All @@ -23,13 +30,16 @@ 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 {
"id": self.id,
"display_name": self.display_name,
"public": self.public,
"archived": self.archived,
"live": self.live,
}


Expand All @@ -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


Expand Down Expand Up @@ -88,13 +100,20 @@ 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,
display_name=display_name,
realm_id=realm_id,
staff_permission=staff_permission,
archived=archived,
live=live,
)


Expand Down Expand Up @@ -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:
Expand Down
Loading