Map: the pan was blanking the ground it already had

Rasterizing a view is most of a second, so the paint that answers a pan
drew the nodes on an empty canvas and waited — the map going black on
every keypress and flashing back when the frame landed. Each finished
frame now keeps its braille layer, and the next moved view stands on it:
the two viewports differ by an affine scale-and-shift in Web Mercator,
which collapses to two cell-index tables the canvas pastes by. So the
streets slide with the view — dimmed, and short of the edge you are
panning onto — until their own raster replaces them.

Cell granularity, not dot-exact: the stand-in lands within half a cell
of true and is corrected a moment later, for +0.5 ms on the paint path
instead of eight times the work. Only the ground travels; labels and
markers are redrawn where they really are. The fade is off on the
16-slot console, where dimming a colour is a recolouring, and the whole
ghost is dropped once it says nothing — panned clear off, or more than
two zoom steps away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-09 04:32:51 -04:00
1 parent 641286828d
commit 6e0c9ec50b
4 files changed
+398 -14

No files matched your search

+147 -2
View File
@@ -6,6 +6,11 @@ overlays the mesh nodes with their names on top. It is pure and synchronous —
happens elsewhere (:mod:`meshterm.services.basemap`) — so both the interactive screen and
the one-shot CLI render call the same code.
Composing a frame is most of a second of pure Python, far more than a pan keystroke can
wait for, so a frame can also be drawn on the ground of the *last* one, reprojected onto
the view that has since moved (:class:`Ghost`) — which is what the interactive map paints
while the real raster is being drawn behind it.
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).
@@ -23,7 +28,8 @@ from typing import Optional
from ..core.geo import Viewport
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
from .mapcanvas import MapCanvas
from ..platforms import Platform, on_platform
from .mapcanvas import MapCanvas, Raster
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK, parse_hex
from .theme import mark_rgb
@@ -178,6 +184,104 @@ class _Frame:
labels: list[_Label] = field(default_factory=list)
# -- the stale ground ---------------------------------------------------------
@dataclass(frozen=True, slots=True)
class Ghost:
"""A finished frame's ground, and the view it was drawn for.
Rasterizing a view is most of a second of pure Python, so a pan moves the view long
before its picture can exist. Rather than answer the keystroke with an empty canvas —
the map blanking to black on every step and flashing back when the frame lands — the
paint reprojects *this*: the streets as they were, shifted to where they now belong,
under fresh markers. It is the ground alone (see :class:`~meshterm.ui.mapcanvas.
Raster`); labels and node markers are redrawn at their real places on top.
Attributes:
raster: The braille layer of the frame it came from.
viewport: The view that frame was drawn for, which is what says where its dots
have since moved to.
"""
raster: Raster
viewport: Viewport
#: How far the reused ground dims while its replacement is being drawn — enough to read as
#: provisional (and to keep the not-yet-drawn edge the view is panning onto from looking
#: like real empty ground) without losing the shape of the streets.
#:
#: Bound at platform-switch time, and *off* where there is no truecolour: on the 16-slot
#: console a colour does not dim, it lands in a different slot, so a fade there is a
#: recolouring — the faint road classes drop to black and the rest muddle together. The
#: title's ``drawing…`` carries the same news on both platforms.
_GHOST_FADE = 0.6
#: Zoom steps of difference beyond which the ghost is dropped rather than scaled. One step
#: reads as the coarse preview it is; past two it is a smear of blocks that says nothing
#: true about the ground.
_GHOST_MAX_STEPS = 2
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the ghost's fade to what the platform's palette can actually express."""
global _GHOST_FADE
_GHOST_FADE = 0.6 if platform.truecolor else 1.0
def _ghost_axis(
cells: int, dots: int, origin: float, src_origin: float, scale: float, src_cells: int
) -> list[int]:
"""Source cell index per canvas cell along one axis (``-1`` where there is none).
Both viewports are windows in Web Mercator, so one maps onto the other by an affine
scale-and-shift on each axis independently — a dot at world position ``d + origin``
sits at ``(d + origin) * scale - src_origin`` in the older view — and the whole
reprojection collapses to two little index tables the paste then reads off.
Args:
cells: Canvas size along this axis, in cells.
dots: Dots per cell on this axis (2 across, 4 down).
origin: The canvas viewport's world-pixel origin on this axis.
src_origin: The ghost viewport's world-pixel origin, at *its* zoom.
scale: World pixels of the ghost's zoom per world pixel of ours.
src_cells: The ghost raster's size along this axis, for the bounds test.
"""
out: list[int] = []
for c in range(cells):
centre = c * dots + dots / 2 # sample each cell at its middle dot
src = math.floor((centre + origin) * scale - src_origin) // dots
out.append(src if 0 <= src < src_cells else -1)
return out
def _paste_ghost(canvas: MapCanvas, viewport: Viewport, ghost: Ghost) -> None:
"""Lay ``ghost``'s ground onto ``canvas``, reprojected to ``viewport``.
A no-op when the two views have drifted too far apart in zoom, or when the pan has
carried the view clear off the old frame's ground — in both cases there is nothing
left to stand in, and the markers alone are the honest picture.
"""
src = ghost.viewport
if abs(src.zoom - viewport.zoom) > _GHOST_MAX_STEPS:
return
scale = 2.0 ** (src.zoom - viewport.zoom)
ox, oy = viewport.origin_world
sx, sy = src.origin_world
cols = _ghost_axis(canvas.cell_w, 2, ox, sx, scale, ghost.raster.cell_w)
if not any(c >= 0 for c in cols):
return
rows = _ghost_axis(canvas.cell_h, 4, oy, sy, scale, ghost.raster.cell_h)
if not any(r >= 0 for r in rows):
return
canvas.paste_raster(ghost.raster, cols, rows, fade=_GHOST_FADE)
# -- composing a frame --------------------------------------------------------
def render_map(
viewport: Viewport,
tiles: dict[tuple[int, int, int], Optional[list[Layer]]],
@@ -185,6 +289,7 @@ def render_map(
*,
max_labels: int = 80,
find: str = "",
ghost: Optional[Ghost] = None,
) -> list[str]:
"""Render a full map frame to truecolour ANSI lines.
@@ -196,13 +301,53 @@ def render_map(
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.
ghost: Ground from an earlier frame to lay down first, reprojected onto this view
(see :class:`Ghost`). It is a stand-in for ground that isn't drawn yet, so it
goes with the frames that have no ``tiles`` of their own; a frame drawing the
real thing has no use for one.
Returns:
One ANSI string per row, ready for the TUI frame or the console.
"""
return _compose(
viewport, tiles, markers, max_labels=max_labels, find=find, ghost=ghost
).to_ansi_lines()
def render_ground(
viewport: Viewport,
tiles: dict[tuple[int, int, int], Optional[list[Layer]]],
markers: list[MapMarker],
*,
max_labels: int = 80,
find: str = "",
) -> tuple[list[str], Ghost]:
"""Render a frame and keep its ground, for the next moved view to stand on.
The same work as :func:`render_map`, plus the :class:`Ghost` the frame leaves behind —
which is why the interactive map calls this one for the real (background) raster and
:func:`render_map` for the immediate paints in between.
"""
canvas = _compose(viewport, tiles, markers, max_labels=max_labels, find=find)
return canvas.to_ansi_lines(), Ghost(canvas.raster(), viewport)
def _compose(
viewport: Viewport,
tiles: dict[tuple[int, int, int], Optional[list[Layer]]],
markers: list[MapMarker],
*,
max_labels: int = 80,
find: str = "",
ghost: Optional[Ghost] = None,
) -> MapCanvas:
"""Draw one frame onto a fresh canvas — see :func:`render_map` for the arguments."""
canvas = MapCanvas(viewport.dot_w // 2, viewport.dot_h // 4)
frame = _Frame(canvas=canvas, viewport=viewport)
if ghost is not None:
_paste_ghost(canvas, viewport, ghost)
for (z, x, y), layers in tiles.items():
if layers:
_draw_tile(frame, layers, z, x, y)
@@ -227,7 +372,7 @@ def render_map(
named.add(label.text)
break
return canvas.to_ansi_lines()
return canvas
def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> None:
+21 -12
View File
@@ -38,7 +38,7 @@ from ..core.mvt import Layer
from ..platforms import get_platform
from ..services import modifier_watch
from ..services.basemap import BasemapSource
from .map_render import MapMarker, render_map
from .map_render import Ghost, MapMarker, render_ground, render_map
from .tui.render import query_line
from .tui.screen import Screen
@@ -208,6 +208,9 @@ class MapScreen(Screen):
# path and the paint serves whatever is ready.
self._frame: Optional[list[str]] = None
self._frame_key: Optional[tuple] = None
# That frame's ground, kept so a view that has moved on can stand on it until its
# own is drawn (see :meth:`_ground`).
self._ghost: Optional[Ghost] = None
self._drawing: Optional[tuple] = None # the key currently being rasterized
self._wanted: Optional[tuple[tuple, Viewport]] = None # the next one to draw
@@ -310,10 +313,13 @@ class MapScreen(Screen):
* **Only the tiles changed** (a fetch landed, the view did not move) — the previous
raster is still correctly aligned, just missing some streets. Keep showing it
rather than blanking a good picture to redraw the same ground.
* **The view moved** — the old raster is now in the wrong place, and showing it
would be a lie about where you are looking. Draw the nodes alone on the *new*
viewport, which costs a few milliseconds, so panning tracks the keys exactly and
the streets catch up.
* **The view moved** — the old raster is in the wrong place *as a picture*, but the
ground it drew is still the only ground anyone has: reproject it onto the new
viewport (:class:`~meshterm.ui.map_render.Ghost`) and draw the nodes over it at
their real positions. That costs a few milliseconds, so panning still tracks the
keys exactly, and the streets slide with the view — dimmed, and short of the
edge you are panning onto — instead of the map blanking to black between every
keypress and flashing back when the frame lands (JP, 2026-08-09).
"""
key = self._ground_key(vp)
if self._frame_key == key and self._frame is not None:
@@ -323,8 +329,8 @@ class MapScreen(Screen):
if self._frame is not None and self._frame_key is not None:
if self._frame_key[0] == vp and self._frame_key[2] == key[2]:
return list(self._frame) # same view, only tiles differ — still aligned
# The view moved (or nothing has ever been drawn): markers only, no tiles.
return render_map(vp, {}, self._markers, find=self._filter)
# The view moved (or nothing has ever been drawn): markers over the last ground.
return render_map(vp, {}, self._markers, find=self._filter, ghost=self._ghost)
def _schedule_ground(self, key: tuple, vp: Viewport) -> None:
"""Note that ``key`` wants drawing, and start on it if nothing else is in flight.
@@ -353,7 +359,9 @@ class MapScreen(Screen):
# A static render (the CLI's map export, a test): there is nothing to be
# responsive *to*, so draw it here and now rather than never.
self._drawing = None
self._frame = render_map(vp, tiles, self._markers, find=self._filter)
self._frame, self._ghost = render_ground(
vp, tiles, self._markers, find=self._filter
)
self._frame_key = key
return
self._drawing = key
@@ -368,13 +376,14 @@ class MapScreen(Screen):
frame (measured worst-case delay ~50 ms, against the ~1 s of a blocking draw).
"""
try:
lines = await asyncio.to_thread(
render_map, vp, tiles, self._markers, find=self._filter
drawn = await asyncio.to_thread(
render_ground, vp, tiles, self._markers, find=self._filter
)
except Exception: # noqa: BLE001 - a frame we couldn't draw is one we draw again
lines = None
drawn = None
self._drawing = None
if lines is not None:
if drawn is not None:
lines, self._ghost = drawn
self._frame, self._frame_key = lines, key
self._needs_scrub = True
self._session.invalidate()
+81
View File
@@ -15,6 +15,7 @@ truecolour ANSI lines, which is exactly what the TUI frame consumes.
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from typing import Optional
from ..platforms import Platform, on_platform
@@ -69,6 +70,21 @@ _DOT_BITS = (
)
@dataclass(frozen=True, slots=True)
class Raster:
"""A finished canvas's braille layer alone — its dots and their cell colours.
The text overlay is deliberately *not* here: the labels and markers a frame carries are
tied to where things were when it was drawn, so a caller reusing an old raster over a
moved view (:meth:`MapCanvas.paste_raster`) wants the ground and draws its own overlay
on top. Taken with :meth:`MapCanvas.raster`, kept with :meth:`MapCanvas.paste_raster`.
"""
cell_w: int
cell_h: int
bits: list[list[int]]
color: list[list[Optional[RGB]]]
class MapCanvas:
"""A colored braille raster with a text/marker overlay and label collision tracking."""
@@ -193,6 +209,71 @@ class MapCanvas:
for x in range(x_from, x_to + 1, stipple):
self.plot(x, y, color, priority)
# -- reuse ------------------------------------------------------------------
def raster(self) -> Raster:
"""Take a copy of the braille layer, for a later frame to paste back in.
Copied rather than shared: the caller keeps this for as long as it is the newest
ground it has, and a canvas that is still being drawn on must not be able to
change it underneath them.
"""
return Raster(
self.cell_w,
self.cell_h,
[row[:] for row in self._bits],
[row[:] for row in self._color],
)
def paste_raster(
self, src: Raster, cols: list[int], rows: list[int], *, fade: float = 1.0
) -> None:
"""Fill this canvas's braille layer from ``src``, one cell at a time.
Cell ``(cx, cy)`` here takes cell ``(cols[cx], rows[cy])`` of ``src``; a ``-1`` in
either list is a cell with no source (the ground the view has moved onto, which
nothing has ever drawn) and is left blank. The caller owns the projection — it is
the one that knows what the two rasters *mean* geographically — and this end is a
copy loop, deliberately: it runs on the paint path, between a pan keystroke and the
frame that answers it.
Cell granularity is the whole point of the shape: a dot-exact reprojection would be
eight times the work for a picture that is about to be replaced anyway, so a paste
lands within half a cell of true and the real raster corrects it a moment later.
Args:
src: The raster to sample.
cols: Source cell x per canvas column (``-1`` = none), length ``cell_w``.
rows: Source cell y per canvas row (``-1`` = none), length ``cell_h``.
fade: Multiplier on every pasted colour, for a caller marking the ground as
provisional. ``1.0`` pastes the colours untouched.
"""
faded: dict[RGB, RGB] = {}
for cy, sy in enumerate(rows):
if sy < 0 or cy >= self.cell_h:
continue
src_bits, src_color = src.bits[sy], src.color[sy]
bits, color, prio = self._bits[cy], self._color[cy], self._prio[cy]
for cx, sx in enumerate(cols):
if sx < 0 or cx >= self.cell_w:
continue
dots = src_bits[sx]
if not dots:
continue
bits[cx] = dots
rgb = src_color[sx]
if rgb is not None and fade != 1.0:
dim = faded.get(rgb)
if dim is None:
dim = faded[rgb] = (
round(rgb[0] * fade), round(rgb[1] * fade), round(rgb[2] * fade)
)
rgb = dim
color[cx] = rgb
# Left at the empty-cell priority so anything drawn afterwards wins the
# cell outright: pasted ground is a stand-in, never evidence.
prio[cx] = -1
# -- overlay (markers + labels) --------------------------------------------
def marker(self, x: int, y: int, glyph: str, color: RGB) -> None:
+149
View File
@@ -313,6 +313,38 @@ def test_canvas_labels_keep_a_vertical_gap() -> None:
assert canvas.place_label(2, 4, "Far", (200, 200, 200))
def _dot_colors(lines: list[str]) -> set[tuple[int, int, int]]:
"""Every truecolour a braille run was drawn in (the ground's colours, not the text's)."""
return {
(int(r), int(g), int(b))
for r, g, b in re.findall(
r"38;2;(\d+);(\d+);(\d+)m(?:\x1b\[1m)?[⠀-⣿]", "".join(lines)
)
}
def test_canvas_paste_raster_offsets_fades_and_yields_the_cell() -> None:
"""A pasted raster lands where the caller's index tables put it, dimmed, underneath.
``-1`` is a cell with no source — the ground a pan has just moved onto — and the
pasted cell keeps the empty-cell priority so anything drawn afterwards wins it.
"""
from meshterm.ui.mapcanvas import MapCanvas
src = MapCanvas(3, 1)
src.plot(0, 0, (200, 100, 50), 5) # cell 0
src.plot(4, 0, (60, 120, 240), 5) # cell 2
canvas = MapCanvas(3, 1)
canvas.paste_raster(src.raster(), [-1, 0, 2], [0], fade=0.5)
out = "".join(canvas.to_ansi_lines())
assert _plain([out]) == " ⠁⠁" # shifted one cell right; the gap has no source
assert _dot_colors([out]) == {(100, 50, 25), (30, 60, 120)}
canvas.plot(2, 0, (10, 20, 30), 0) # a real feature, drawn after, takes the cell
assert _dot_colors(["".join(canvas.to_ansi_lines())]) >= {(10, 20, 30)}
def test_parse_hex() -> None:
"""Hex colours parse to RGB triples, with or without the leading hash."""
assert parse_hex("#38bdf8") == (0x38, 0xBD, 0xF8)
@@ -514,6 +546,96 @@ def test_render_map_works_without_basemap() -> None:
assert "★" in out and "▲" in out
# -- the ghost ground ---------------------------------------------------------
def _dot_rows(lines: list[str]) -> list[str]:
"""Each rendered row reduced to its braille dots (labels and markers blanked out)."""
return [
"".join(ch if 0x2800 <= ord(ch) <= 0x28FF else " " for ch in row)
for row in _plain(lines).split("\n")
]
def _ghosted(zoom: int = 14, *, dlon: float = 0.0, dlat: float = 0.0, dz: int = 0):
"""Draw the fixture, then paint a moved view standing on that frame's ground."""
from meshterm.ui.map_render import MapMarker, render_ground, render_map
vp = Viewport(45.5019, -73.5674, zoom, 106, 104)
tiles = {(14, 4843, 5861): decode_tile(_FIXTURE.read_bytes())}
markers = [MapMarker("Yagi", 45.5019, -73.5674, is_repeater=True)]
lines, ghost = render_ground(vp, tiles, markers)
moved = Viewport(vp.center_lat + dlat, vp.center_lon + dlon, zoom + dz, 106, 104)
return lines, render_map(moved, {}, markers, ghost=ghost)
def test_a_panned_view_stands_on_the_ground_it_just_had() -> None:
"""Panning slides the last frame's streets across rather than blanking to black.
Rasterizing takes far too long to sit on a keypress, so the paint that answers a pan
has no ground of its own. Without the ghost it drew the markers on an empty canvas —
the map going black between every step and flashing back when the frame landed.
"""
from meshterm.ui.map_render import MapMarker, render_map
vp = Viewport(45.5019, -73.5674, 14, 106, 104)
markers = [MapMarker("Yagi", 45.5019, -73.5674, is_repeater=True)]
bare = sum(1 for ch in _plain(render_map(vp, {}, markers)) if ch.strip())
drawn, moved = _ghosted(dlon=0.004)
lit = sum(1 for ch in _plain(moved) if ch.strip())
assert lit > bare * 10, "the panned paint is as empty as one with no ghost at all"
assert lit < sum(1 for ch in _plain(drawn) if ch.strip()), "nothing was left behind"
def test_the_ghost_ground_lands_where_the_pan_put_it() -> None:
"""The reused dots move by exactly the cells the view moved, to within one cell."""
drawn, moved = _ghosted(dlon=0.004)
# 0.004° of longitude at zoom 14: dots per degree = 256 * 2^14 / 360.
shift = round(0.004 * 256 * (2**14) / 360 / 2) # → cells (2 dots wide)
before, after = _dot_rows(drawn), _dot_rows(moved)
assert len(before) == len(after)
matched = sum(
1
for old, new in zip(before, after)
if old[shift:].rstrip() and new.rstrip() == old[shift:].rstrip()
)
assert matched > len(before) // 2, "the ground did not slide with the view"
def test_the_ghost_ground_carries_no_stale_text() -> None:
"""Only the dots come along: a label pinned to old ground would name the wrong place."""
drawn, moved = _ghosted(dlon=0.004)
assert "Montréal" in _plain(drawn)
assert "Montréal" not in _plain(moved)
assert "Yagi" in _plain(moved) # the markers are redrawn where they really are
def test_the_ghost_ground_dims_while_its_replacement_is_drawn() -> None:
"""Reused ground reads as provisional — it is stale, and short of the leading edge."""
from meshterm.ui.map_render import _GHOST_FADE
assert 0 < _GHOST_FADE < 1 # the desktop default; the 16-slot console binds it to 1.0
drawn, moved = _ghosted(dlon=0.004)
faded = {
tuple(round(c * _GHOST_FADE) for c in rgb) for rgb in _dot_colors(drawn)
}
ghost = _dot_colors(moved)
assert ghost and ghost <= faded, "the reused ground is not the ground we drew"
assert not ghost & _dot_colors(drawn), "it came through at full strength"
def test_the_ghost_ground_is_dropped_once_it_says_nothing() -> None:
"""A view that has left the old frame behind gets the honest empty canvas."""
_, far = _ghosted(dlon=4.0) # panned clean off the ground we had
assert not any(row.strip() for row in _dot_rows(far))
_, deep = _ghosted(dz=3) # zoomed past what a cell-coarse stand-in can say
assert not any(row.strip() for row in _dot_rows(deep))
_, near = _ghosted(dz=1) # one step is still a readable preview
assert any(row.strip() for row in _dot_rows(near))
# -- basemap source (offline behaviour) ---------------------------------------
@@ -1593,3 +1715,30 @@ def test_map_drops_a_stale_frame_once_the_view_moves(monkeypatch) -> None: # no
screen.handle("right")
assert screen.render_body(80) != ["ground"] * 20, "kept a misaligned frame"
async def test_map_pans_on_the_ground_its_last_raster_left(monkeypatch) -> None: # noqa: ANN001
"""A misaligned frame is dropped, but the ground it drew is kept and reprojected.
It is the only ground anyone has until the next raster lands, and a pan that answers
with an empty canvas blanks the map to black between every keypress.
"""
from meshterm.ui import map_screen as ms
started: list = []
screen = _async_map(monkeypatch, started)
screen.render_body(80)
await screen._draw_ground(screen._ground_key(screen._viewport), screen._viewport, {})
assert screen._ghost is not None, "the finished raster left no ground behind"
assert screen._ghost.viewport == screen._viewport
handed: list = []
real = ms.render_map
monkeypatch.setattr(
ms, "render_map",
lambda vp, tiles, m, **k: handed.append(k.get("ghost")) or real(vp, tiles, m, **k),
)
screen.handle("right")
screen.render_body(80)
assert handed and handed[-1] is screen._ghost, "the pan painted on an empty canvas"