From 6e0c9ec50b81cea3a3c6f4a4ea6abd5ad5dd6743 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 9 Aug 2026 04:32:51 -0400 Subject: [PATCH] Map: the pan was blanking the ground it already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- meshterm/ui/map_render.py | 149 +++++++++++++++++++++++++++++++++++++- meshterm/ui/map_screen.py | 33 ++++++--- meshterm/ui/mapcanvas.py | 81 +++++++++++++++++++++ tests/test_map.py | 149 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 398 insertions(+), 14 deletions(-) diff --git a/meshterm/ui/map_render.py b/meshterm/ui/map_render.py index 4909ad5..475d4d6 100644 --- a/meshterm/ui/map_render.py +++ b/meshterm/ui/map_render.py @@ -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: diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index 6266009..cec3164 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -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() diff --git a/meshterm/ui/mapcanvas.py b/meshterm/ui/mapcanvas.py index 040244a..eda9eb2 100644 --- a/meshterm/ui/mapcanvas.py +++ b/meshterm/ui/mapcanvas.py @@ -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: diff --git a/tests/test_map.py b/tests/test_map.py index 4d889b5..aba0206 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -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"