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:
1 parent
641286828d
commit
6e0c9ec50b
4 files changed
+398
-14
No files matched your search
+147
-2
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user