Profiling on the Lyra found vector-tile decode dominating everything else in the app by about two orders of magnitude: a median cached tile took 1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a whole viewport. Decoded tiles are memoised for the session, so this is a first-pan cost -- but it runs on a GIL-bound worker thread, so it starves the render loop for those seconds rather than running beside it. Roughly 40% of that work was thrown away. A planet tile ships building, housenumber, poi and mountain_peak; _draw_tile looks up ten layer names and none of them is those. So decode_tile now takes the set of layers the caller actually wants, reads each layer's identity with a cheap header walk, and decodes the features only for the ones asked for. Unwanted layers are still returned, named and empty -- which matters, because the tile source decides whether a cache entry is real by whether the bytes parsed into layers, and a tile whose every layer we skip must not read as blank and get its perfectly good cache entry pruned and re-downloaded every session. That test moves off "has features" and onto "parsed into layers", which is sound because the other two ways a tile isn't real -- empty and corrupt -- decode to nothing and raise respectively. Only the decode narrows; whole tiles are still cached, so drawing more later costs a re-decode and never a re-download. Measured on the device across the 46 real cached tiles: 183 s -> 51 s, 3.6x, with the drawn layers byte-identical on every one. The median tile is 1259 -> 353 ms, the largest 3545 -> 759 ms. _Reader.skip stopped slicing out the bytes it steps over while it was here, which is most of what makes the header walk cheap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
438 lines
17 KiB
Python
438 lines
17 KiB
Python
"""Compose a rendered map frame: basemap geometry, labels, and mesh nodes.
|
|
|
|
Given a :class:`~meshterm.core.geo.Viewport` and the decoded vector tiles covering it, this
|
|
projects every street, river, water body and place label onto a :class:`MapCanvas`, then
|
|
overlays the mesh nodes with their names on top. It is pure and synchronous — tile *fetching*
|
|
happens elsewhere (:mod:`meshterm.services.basemap`) — so both the interactive screen and
|
|
the one-shot CLI render call the same code.
|
|
|
|
Colours target a dark terminal (the app theme): warm roads, grey minor streets, blue water
|
|
and rivers, faint green parks. Because a cell shows one colour, draw priorities keep the
|
|
important feature visible where things overlap (rivers over water, major roads over minor).
|
|
The features whose hue *is* the information — water, its watercourses, parks, highways —
|
|
name a ``map.*`` theme style instead of a hex so the 16-slot console chooses its own shade
|
|
(see :data:`~meshterm.ui.theme.MESH_THEME_16`); everything else is grey either way.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from ..core.geo import Viewport
|
|
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
|
|
from .mapcanvas import MapCanvas
|
|
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK, parse_hex
|
|
from .theme import mark_rgb
|
|
|
|
# -- node markers -------------------------------------------------------------
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class MapMarker:
|
|
"""One mesh node to overlay on the map.
|
|
|
|
Attributes:
|
|
label: Node name shown beside the marker.
|
|
lat: Latitude in decimal degrees.
|
|
lon: Longitude in decimal degrees.
|
|
is_repeater: Whether the node is a repeater (prioritised marker).
|
|
is_self: Whether this is our own node (highlighted).
|
|
detail: Extra text for the CLI legend (e.g. ``"18 pkts · +6.0 dB"``).
|
|
key: The node's key hex (as full as the caller holds), seeding the label's
|
|
key-derived hue; ``None`` leaves the label the muted no-key grey.
|
|
"""
|
|
|
|
label: str
|
|
lat: float
|
|
lon: float
|
|
is_repeater: bool = False
|
|
is_self: bool = False
|
|
detail: str = ""
|
|
key: Optional[str] = None
|
|
|
|
def _rank(self) -> int:
|
|
"""Draw order: self on top of repeaters on top of leaf nodes."""
|
|
return 2 if self.is_self else (1 if self.is_repeater else 0)
|
|
|
|
|
|
# Marker palette, shared across the whole app — canonical tuples in ui.marks; the old
|
|
# private names stay bound here for this module and its existing importers.
|
|
_SELF = SELF_MARK
|
|
_REPEATER = REPEATER_MARK
|
|
_NODE = NODE_MARK
|
|
_UNKNOWN = UNKNOWN_MARK
|
|
|
|
|
|
# -- basemap styling ----------------------------------------------------------
|
|
|
|
# Every colour here is whatever :func:`~meshterm.ui.theme.mark_rgb` takes: a literal
|
|
# ``#rrggbb``, or a theme style name where the hue carries meaning and the platform must
|
|
# pick its own shade (the ``map.*`` entries — see the theme, which explains why water,
|
|
# parks and highways cannot survive a naive downsample to 16 slots).
|
|
|
|
# Road class -> (colour, priority). Higher priority wins a shared cell.
|
|
_ROAD_STYLE: dict[str, tuple[str, int]] = {
|
|
"motorway": ("map.highway", 27),
|
|
"trunk": ("map.highway", 26),
|
|
"primary": ("#e8b24a", 25),
|
|
"secondary": ("#d7c257", 24),
|
|
"tertiary": ("#a7adb8", 23),
|
|
"minor": ("#828894", 21),
|
|
"unclassified": ("#828894", 21),
|
|
"residential": ("#828894", 21),
|
|
"living_street": ("#767c88", 20),
|
|
"service": ("#5f646e", 19),
|
|
"pedestrian": ("#5a5f69", 15),
|
|
"path": ("#4b5560", 12),
|
|
"track": ("#4b5560", 12),
|
|
"footway": ("#454e58", 11),
|
|
"cycleway": ("#455864", 12),
|
|
}
|
|
_ROAD_DEFAULT = ("#5f646e", 18)
|
|
_RAIL = ("#8a8f98", 22)
|
|
|
|
# Waterway class -> (colour, priority).
|
|
_WATERWAY_STYLE: dict[str, tuple[str, int]] = {
|
|
"river": ("map.river", 30),
|
|
"canal": ("map.river", 30),
|
|
"stream": ("map.stream", 29),
|
|
"ditch": ("map.ditch", 28),
|
|
"drain": ("map.ditch", 28),
|
|
}
|
|
_WATER_FILL = ("map.water", 6)
|
|
_GREEN_FILL = ("map.park", 4)
|
|
_GREEN_CLASSES = {"wood", "forest", "grass", "park", "meadow", "scrub", "wetland", "cemetery"}
|
|
|
|
# Label styling per place class: (colour, bold, rank, min_display_zoom). Lower rank places
|
|
# claim screen space first; min zoom hides the fine detail until you zoom in.
|
|
_PLACE_STYLE: dict[str, tuple[str, bool, int, int]] = {
|
|
"city": ("#f8fafc", True, 0, 0),
|
|
"town": ("#e6ebf2", True, 1, 9),
|
|
"village": ("#cdd6e2", False, 2, 12),
|
|
"suburb": ("#9fb0c4", False, 3, 12),
|
|
"quarter": ("#94a5ba", False, 4, 13),
|
|
"neighbourhood": ("#8496ab", False, 5, 13),
|
|
"hamlet": ("#8496ab", False, 6, 14),
|
|
}
|
|
_WATER_LABEL = ("#7dd3fc", False, 2)
|
|
_STREET_LABEL = ("#9aa0aa", False, 7)
|
|
|
|
#: Polygon layers drawn as fills, in painting order (later wins the shared cell).
|
|
_FILL_LAYERS = ("water", "landcover", "landuse", "park")
|
|
|
|
#: Every basemap layer :func:`_draw_tile` looks at — and so the only geometry the map
|
|
#: has any use for. A planet tile also ships ``building``, ``housenumber``, ``poi`` and
|
|
#: ``mountain_peak``, which together are a large share of its features and none of its
|
|
#: pixels: decoding them cost ~40% of every tile until this set was handed to
|
|
#: :func:`~meshterm.core.mvt.decode_tile` (measured on the Lyra — a 153 KB tile went
|
|
#: 1236 ms → 281 ms). Passed to the tile source at construction
|
|
#: (:attr:`meshterm.context.AppContext.basemap_source`); the on-disk cache still holds
|
|
#: whole tiles, so widening this set costs a re-decode, never a re-download.
|
|
DRAWN_LAYERS: frozenset[str] = frozenset(
|
|
_FILL_LAYERS
|
|
+ ("waterway", "transportation", "boundary", "transportation_name", "place", "water_name")
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _Label:
|
|
"""A pending label placement candidate."""
|
|
|
|
rank: int
|
|
x: float
|
|
y: float
|
|
text: str
|
|
color: RGB
|
|
bold: bool
|
|
min_zoom: int = 0
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _Frame:
|
|
"""Working state while composing one frame."""
|
|
|
|
canvas: MapCanvas
|
|
viewport: Viewport
|
|
labels: list[_Label] = field(default_factory=list)
|
|
|
|
|
|
def render_map(
|
|
viewport: Viewport,
|
|
tiles: dict[tuple[int, int, int], Optional[list[Layer]]],
|
|
markers: list[MapMarker],
|
|
*,
|
|
max_labels: int = 80,
|
|
find: str = "",
|
|
) -> list[str]:
|
|
"""Render a full map frame to truecolour ANSI lines.
|
|
|
|
Args:
|
|
viewport: The view to render (its dot size sets the canvas size).
|
|
tiles: Decoded layers keyed by ``(z, x, y)``; ``None`` values are pending/absent.
|
|
markers: Mesh nodes to overlay.
|
|
max_labels: Cap on basemap labels placed, to keep the map readable.
|
|
find: A live node-name filter: markers whose label contains it
|
|
(case-insensitively) draw with bright white labels while the rest dim to
|
|
unlabelled context. Empty draws every node normally.
|
|
|
|
Returns:
|
|
One ANSI string per row, ready for the TUI frame or the console.
|
|
"""
|
|
canvas = MapCanvas(viewport.dot_w // 2, viewport.dot_h // 4)
|
|
frame = _Frame(canvas=canvas, viewport=viewport)
|
|
|
|
for (z, x, y), layers in tiles.items():
|
|
if layers:
|
|
_draw_tile(frame, layers, z, x, y)
|
|
|
|
# Nodes reserve their cells first so basemap labels route around them.
|
|
_draw_nodes(canvas, viewport, markers, find=find)
|
|
|
|
# Then place basemap labels by importance, honouring the zoom gate and collisions.
|
|
placed = 0
|
|
for label in sorted(frame.labels, key=lambda l: l.rank):
|
|
if placed >= max_labels:
|
|
break
|
|
if viewport.zoom < label.min_zoom:
|
|
continue
|
|
if canvas.place_label(label.x, label.y, label.text, label.color, bold=label.bold):
|
|
placed += 1
|
|
|
|
return canvas.to_ansi_lines()
|
|
|
|
|
|
def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> None:
|
|
"""Draw one decoded tile's geometry and collect its label candidates."""
|
|
vp = frame.canvas
|
|
by_name = {layer.name: layer for layer in layers}
|
|
|
|
def project(ring: list[tuple[int, int]], extent: int) -> list[tuple[float, float]]:
|
|
return [
|
|
frame.viewport.feature_to_dot(x, y, z, extent, lx, ly) for lx, ly in ring
|
|
]
|
|
|
|
# Fills first (water, green space) so lines and labels sit on top.
|
|
for name in _FILL_LAYERS:
|
|
layer = by_name.get(name)
|
|
if layer is None:
|
|
continue
|
|
for feat in layer.features:
|
|
if feat.geom_type != GEOM_POLYGON:
|
|
continue
|
|
if name == "water":
|
|
color, prio = _WATER_FILL
|
|
elif str(feat.get("class") or feat.get("subclass")) in _GREEN_CLASSES:
|
|
color, prio = _GREEN_FILL
|
|
else:
|
|
continue
|
|
vp.fill_polygon(
|
|
[project(r, layer.extent) for r in feat.rings], mark_rgb(color), prio
|
|
)
|
|
|
|
# Waterways (rivers/streams) as lines.
|
|
waterway = by_name.get("waterway")
|
|
if waterway is not None:
|
|
for feat in waterway.features:
|
|
style = _WATERWAY_STYLE.get(str(feat.get("class")))
|
|
if style is None or feat.geom_type != GEOM_LINE:
|
|
continue
|
|
color, prio = style
|
|
for ring in feat.rings:
|
|
if len(ring) >= 2:
|
|
vp.draw_line(project(ring, waterway.extent), mark_rgb(color), prio)
|
|
if feat.name:
|
|
_add_line_label(frame, feat.rings, waterway.extent, z, x, y, feat.name, _WATER_LABEL)
|
|
|
|
# Roads / rail.
|
|
transportation = by_name.get("transportation")
|
|
if transportation is not None:
|
|
for feat in transportation.features:
|
|
if feat.geom_type != GEOM_LINE:
|
|
continue
|
|
cls = str(feat.get("class") or "")
|
|
if cls in ("rail", "transit"):
|
|
color, prio = _RAIL
|
|
else:
|
|
color, prio = _ROAD_STYLE.get(cls, _ROAD_DEFAULT)
|
|
rgb = mark_rgb(color)
|
|
for ring in feat.rings:
|
|
if len(ring) >= 2:
|
|
vp.draw_line(project(ring, transportation.extent), rgb, prio)
|
|
|
|
# Boundaries (admin) as a faint hint.
|
|
boundary = by_name.get("boundary")
|
|
if boundary is not None:
|
|
for feat in boundary.features:
|
|
if feat.geom_type != GEOM_LINE:
|
|
continue
|
|
try:
|
|
if int(feat.get("admin_level", 99)) > 6:
|
|
continue
|
|
except (TypeError, ValueError):
|
|
continue
|
|
for ring in feat.rings:
|
|
if len(ring) >= 2:
|
|
vp.draw_line(project(ring, boundary.extent), mark_rgb("#6d5f88"), 14)
|
|
|
|
# Street-name labels (only kick in at high zoom via the label's min_zoom gate).
|
|
tname = by_name.get("transportation_name")
|
|
if tname is not None:
|
|
color, bold, rank = _STREET_LABEL
|
|
for feat in tname.features:
|
|
if feat.name and feat.rings:
|
|
_add_line_label(
|
|
frame, feat.rings, tname.extent, z, x, y, feat.name,
|
|
(color, bold, rank), min_zoom=15,
|
|
)
|
|
|
|
# Place labels.
|
|
place = by_name.get("place")
|
|
if place is not None:
|
|
for feat in place.features:
|
|
if not feat.name or not feat.rings or not feat.rings[0]:
|
|
continue
|
|
style = _PLACE_STYLE.get(str(feat.get("class")))
|
|
if style is None:
|
|
continue
|
|
color, bold, rank, min_zoom = style
|
|
lx, ly = feat.rings[0][0]
|
|
dx, dy = frame.viewport.feature_to_dot(x, y, z, place.extent, lx, ly)
|
|
frame.labels.append(
|
|
_Label(rank, dx, dy, feat.name, mark_rgb(color), bold, min_zoom)
|
|
)
|
|
|
|
# Water-body names.
|
|
water_name = by_name.get("water_name")
|
|
if water_name is not None:
|
|
color, bold, rank = _WATER_LABEL
|
|
for feat in water_name.features:
|
|
if feat.name and feat.rings and feat.rings[0]:
|
|
lx, ly = feat.rings[0][0]
|
|
dx, dy = frame.viewport.feature_to_dot(x, y, z, water_name.extent, lx, ly)
|
|
frame.labels.append(_Label(rank, dx, dy, feat.name, mark_rgb(color), bold, 8))
|
|
|
|
|
|
def _add_line_label(
|
|
frame: _Frame,
|
|
rings: list[list[tuple[int, int]]],
|
|
extent: int,
|
|
z: int,
|
|
x: int,
|
|
y: int,
|
|
text: str,
|
|
style: tuple[str, bool, int],
|
|
*,
|
|
min_zoom: int = 0,
|
|
) -> None:
|
|
"""Queue a label at the midpoint of a line feature's longest part."""
|
|
longest = max(rings, key=len)
|
|
lx, ly = longest[len(longest) // 2]
|
|
dx, dy = frame.viewport.feature_to_dot(x, y, z, extent, lx, ly)
|
|
color, bold, rank = style
|
|
frame.labels.append(_Label(rank, dx, dy, text, mark_rgb(color), bold, min_zoom))
|
|
|
|
|
|
def _marker_style(marker: MapMarker) -> tuple[str, str]:
|
|
"""The glyph and colour for a marker: self, repeater, or leaf node."""
|
|
if marker.is_self:
|
|
return _SELF
|
|
if marker.is_repeater:
|
|
return _REPEATER
|
|
return _NODE
|
|
|
|
|
|
#: How many piled nodes it takes for a marker's glyph to reach full brightness.
|
|
_PILE_FULL = 8
|
|
|
|
|
|
def _pile_color(color: RGB, count: int, *, cap: int = _PILE_FULL) -> RGB:
|
|
"""Brighten a marker's colour by how many nodes share its cell.
|
|
|
|
One cell can only show a single glyph, so where many nodes fall on the same spot the
|
|
map would otherwise hide the crowd behind one marker. Instead we keep the top node's
|
|
glyph and wash its colour toward white as the pile grows, so a bright marker reads as
|
|
a busy cluster. The ramp is logarithmic (2 nodes → a clear lift, saturating around
|
|
``cap``) so it stays informative without a lone extra node looking crowded.
|
|
"""
|
|
if count <= 1:
|
|
return color
|
|
t = min(1.0, math.log2(count) / math.log2(cap))
|
|
r, g, b = color
|
|
return (
|
|
round(r + (255 - r) * t),
|
|
round(g + (255 - g) * t),
|
|
round(b + (255 - b) * t),
|
|
)
|
|
|
|
|
|
#: How far a node outside an active find filter dims (glyph colour multiplier).
|
|
_FIND_DIM = 0.35
|
|
|
|
#: Label colour for find-filter matches: full white, the brightest thing on the map.
|
|
_FIND_MATCH_LABEL: RGB = (255, 255, 255)
|
|
|
|
|
|
def _dimmed(color: RGB, factor: float) -> RGB:
|
|
"""``color`` scaled toward black by ``factor``, clamped to byte range."""
|
|
return tuple(max(0, min(255, round(c * factor))) for c in color) # type: ignore[return-value]
|
|
|
|
|
|
def _draw_nodes(
|
|
canvas: MapCanvas, viewport: Viewport, markers: list[MapMarker], *, find: str = ""
|
|
) -> None:
|
|
"""Overlay mesh nodes: every glyph, then labels by importance until they collide.
|
|
|
|
Glyphs are drawn lowest-priority first so self/repeaters land on top of leaf nodes.
|
|
Where several nodes share a cell the surviving glyph is brightened by the pile size
|
|
(:func:`_pile_color`) so crowded spots glow rather than silently hiding the crowd.
|
|
Labels are then placed highest-priority first — self, then repeaters, then leaf
|
|
nodes — each only if it fits without overlapping. So on a crowded map the important
|
|
labels win the available space and the rest show as a bare marker (no overlap).
|
|
|
|
With a ``find`` filter active only the matching nodes keep labels — drawn in bright
|
|
white so they pop — while everything else dims to near-background context and match
|
|
labels never lose the collision contest to non-matches.
|
|
"""
|
|
needle = find.casefold()
|
|
placed: list[tuple[MapMarker, int, int, bool]] = []
|
|
for marker in markers:
|
|
x, y = viewport.lonlat_to_dot(marker.lat, marker.lon)
|
|
ix, iy = int(round(x)), int(round(y))
|
|
if not (0 <= ix < viewport.dot_w and 0 <= iy < viewport.dot_h):
|
|
continue
|
|
matched = not needle or needle in marker.label.casefold()
|
|
placed.append((marker, ix, iy, matched))
|
|
|
|
# A cell is (dot_x >> 1, dot_y >> 2); count how many nodes land on each.
|
|
pile = Counter((ix >> 1, iy >> 2) for _, ix, iy, _m in placed)
|
|
|
|
# Matches draw after (over) dimmed non-matches whatever their rank, so the node
|
|
# being searched for is never buried under a brighter neighbour.
|
|
for marker, ix, iy, matched in sorted(placed, key=lambda p: (p[3], p[0]._rank())):
|
|
glyph, color = _marker_style(marker)
|
|
rgb = parse_hex(color)
|
|
if not matched:
|
|
rgb = _dimmed(rgb, _FIND_DIM)
|
|
else:
|
|
rgb = _pile_color(rgb, pile[(ix >> 1, iy >> 2)])
|
|
canvas.marker(ix, iy, glyph, rgb)
|
|
|
|
# A filtered-out node is context: bare dim glyph, no label. Labels take the
|
|
# node's key-derived name hue — the app-wide colour rule — with our own label the
|
|
# pure ``you`` white (the ★ glyph keeps its yellow); an active find filter still
|
|
# forces every match's label full white so the sought node pops.
|
|
from .widgets import name_rgb # widgets imports this module; late-bind to dodge the cycle
|
|
|
|
labelled = (p for p in placed if p[3])
|
|
for marker, ix, iy, _matched in sorted(labelled, key=lambda p: -p[0]._rank()):
|
|
if needle:
|
|
label_color = _FIND_MATCH_LABEL
|
|
elif marker.is_self:
|
|
label_color = (255, 255, 255) # the `you` white
|
|
else:
|
|
label_color = name_rgb(marker.label, marker.key)
|
|
canvas.marker_label(ix, iy, marker.label, label_color)
|