Files
Jean-Pierre MartineauandClaude Opus 5.5 eeafbb18f6 Node marks are white, and the border is darker
Every node-type mark is white now: ● ▲ ■ ◉ were pink, violet, white,
and orange, and the shape already says the type. Each keeps its own
type.* style, so one can take a colour back in one place; the unknown ○
keeps its grey. On the map a crowd in one cell used to glow toward
white, which a white marker cannot do, so that signal is gone.

The border grey goes down to #7c7c7c, which JP picked on a ladder. That
sits almost on faint, so the title bar's live clip arrow is now lit like
the title instead of taking the border's grey. On the PicoCalc the
border is the dark grey slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:30:12 -04:00

827 lines
36 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Compose a rendered map raster: basemap geometry, labels, and mesh nodes.
This module gets a :class:`~meshterm.core.geo.Viewport` and the decoded vector tiles that
cover it. It projects each street, river, water body, and place label onto a
:class:`MapCanvas`. Then it puts the mesh nodes, with their names, on top. The module is
pure and synchronous: it does not get the tiles, because :mod:`meshterm.services.basemap`
does that. Thus the interactive screen and the one-shot CLI render call the same code.
To compose a raster takes most of a second of pure Python. That is much longer than a pan
key press can wait. Thus a raster can also be drawn on the ground of the last raster,
reprojected onto the viewport that moved after it (:class:`Ghost`). The interactive map
shows this while the real raster is drawn behind it.
The colours are for a dark terminal (the app theme): warm roads, grey minor streets, blue
water and rivers, and faint green parks. A cell shows one colour. Thus, where features
overlap, the draw priorities keep the important feature visible (rivers over water, major
roads over minor roads). Some features have a colour that is the information: water, its
watercourses, parks, and highways. These features name a ``map.*`` theme style instead of
a hex value, so that the 16-slot console can choose its own shade (refer to
:data:`~meshterm.ui.theme.MESH_THEME_16`). All the other features are grey on each
platform.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from operator import itemgetter
from ..core.geo import Viewport
from ..core.models import MapMarker
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
from ..platforms import Platform, on_platform
from .mapcanvas import MapCanvas, Raster
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK
from .theme import mark_rgb
# The marker palette, shared across all the app. The canonical tuples are in ui.marks.
# These names are local short names for the hot paths of the renderer. All other code
# imports from ui.marks.
_SELF = SELF_MARK
_REPEATER = REPEATER_MARK
_NODE = NODE_MARK
_UNKNOWN = UNKNOWN_MARK
# -- basemap styling ----------------------------------------------------------
# Each colour here is a value that :func:`~meshterm.ui.theme.mark_rgb` accepts: a literal
# ``#rrggbb``, or a theme style name where the colour has a meaning and the platform must
# choose its own shade (the ``map.*`` entries). Refer to the theme, which explains why
# water, parks, and highways cannot survive a naive downsample to 16 slots.
# Road class -> (colour, priority). The 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": ("#adadad", 23),
"minor": ("#888888", 21),
"unclassified": ("#888888", 21),
"residential": ("#888888", 21),
"living_street": ("#7c7c7c", 20),
"service": ("#646464", 19),
"pedestrian": ("#5f5f5f", 15),
"path": ("#545454", 12),
"track": ("#545454", 12),
"footway": ("#4d4d4d", 11),
"cycleway": ("#555555", 12),
}
_ROAD_DEFAULT = ("#646464", 18)
_RAIL = ("#8f8f8f", 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"}
# The label style for each place class: (colour, bold, rank, min_display_zoom). Places
# with a lower rank get screen space first. The minimum zoom hides the fine detail until
# you zoom in.
_PLACE_STYLE: dict[str, tuple[str, bool, int, int]] = {
"city": ("#fafafa", True, 0, 0),
"town": ("#eaeaea", True, 1, 9),
"village": ("#d5d5d5", False, 2, 12),
"suburb": ("#aeaeae", False, 3, 12),
"quarter": ("#a3a3a3", False, 4, 13),
"neighbourhood": ("#949494", False, 5, 13),
"hamlet": ("#949494", False, 6, 14),
}
_WATER_LABEL = ("#7dd3fc", False, 2)
_STREET_LABEL = ("#9f9f9f", False, 7)
#: The zoom at which street names appear. Below it, a canvas 53 cells wide cannot hold one
#: name for each street. The few names that fit label a grid that is too coarse to show
#: which line each name belongs to. At that scale, the place names are what tell the user
#: where they are.
#:
#: Two places read this value, and that is necessary: :func:`_compose` does not place a
#: label below its own ``min_zoom``, and :func:`_draw_tile` does not build a label that it
#: knows cannot be placed. The second place is the important one. A street label in the
#: queue is not cheap: the line is clipped to the canvas segment by segment, each piece
#: that stays is measured, and the pieces are sorted (refer to :func:`_add_line_label`). A
#: central tile has 270 of them. They took 16.8 ms of a 46 ms raster on the desktop, and
#: thus more than a third of a second on the PicoCalc, at the two zooms that the map uses
#: most. And the placement removed this text each time.
_STREET_LABEL_MIN_ZOOM = 15
#: The polygon layers drawn as fills, in draw order (a later layer wins the shared cell).
_FILL_LAYERS = ("water", "landcover", "landuse", "park")
#: Buildings: the colour, the cell priority, the stipple density, and the zoom at which
#: they appear. They are above the ground fills and below all the roads. They are
#: stippled, not solid, because a braille dot is one bit. A solid fill does not shade an
#: area: it fills each cell that it touches, and the street grid disappears below it.
#: Below the zoom gate, a typical footprint is approximately two dots wide (less than half
#: a cell). Thus the full layer becomes a haze that makes the streets hard to read, and it
#: shows nothing that the street pattern does not already show.
_BUILDING_FILL = ("#4f4f4f", 8)
_BUILDING_STIPPLE = 2
_BUILDING_MIN_ZOOM = 15
#: All the basemap layers that :func:`_draw_tile` reads. Thus this is the only geometry
#: that the map uses. A planet tile also has ``housenumber``, ``poi``, ``mountain_peak``,
#: and the aero layers. Together, they are a large part of its features, but none of its
#: pixels. Their decode cost approximately 40% of each tile, until MeshTerm gave this set
#: to :func:`~meshterm.core.mvt.decode_tile` (measured on the Lyra: a 153 KB tile went
#: 1236 ms → 281 ms). The tile source gets this set when it is built
#: (:attr:`meshterm.context.AppContext.basemap_source`). The cache on disk still holds full
#: tiles. Thus, if this set becomes larger, the cost is a new decode, never a new download.
DRAWN_LAYERS: frozenset[str] = frozenset(
_FILL_LAYERS
+ (
"building",
"waterway",
"transportation",
"boundary",
"transportation_name",
"place",
"water_name",
)
)
#: The mark for "this feature class has no style yet" in the style caches of
#: :func:`_draw_tile` (one set of caches for each tile). In those caches, ``None`` is a real
#: answer: "a class that we do not draw, on purpose".
_UNRESOLVED = object()
#: The lowest draw priority that a **coarse** pass keeps among the road and rail lines
#: (refer to :func:`render_ground`). Tertiary roads and higher: the through-roads, whose
#: pattern tells where you are, and not the residential streets, which tell only that this
#: is a town. This priority is also above rail (:data:`_RAIL`) and above the default for an
#: unrecognized class. It is stated as the priority that the styles already have, not as a
#: second list of class names. Thus a road that changes class cannot be in one pass and
#: not in the other. At zoom 13 on the PicoCalc, it keeps 2 522 lines of 5 416: less than
#: half of them, for almost all of the legibility.
_COARSE_ROAD_PRIORITY = 23
@dataclass(slots=True)
class _Label:
"""A candidate for a label placement, not placed yet.
Attributes:
alts: More anchors to try, in order, when the preferred anchor is already taken.
A line feature offers these along the part of itself that is on the screen.
Thus a street whose middle is below a node marker still gets a name at a
different point.
"""
rank: int
x: float
y: float
text: str
color: RGB
bold: bool
min_zoom: int = 0
alts: tuple[tuple[float, float], ...] = ()
@dataclass(slots=True)
class _Frame:
"""The work state while MeshTerm composes one raster.
Attributes:
coarse: Whether this is the quick first pass: ground and through-roads only, with
no buildings, no back streets, and no text (refer to :func:`render_ground`).
"""
canvas: MapCanvas
viewport: Viewport
labels: list[_Label] = field(default_factory=list)
coarse: bool = False
# -- the stale ground ---------------------------------------------------------
@dataclass(frozen=True, slots=True)
class Ghost:
"""The ground of a finished raster, and the viewport that it was drawn for.
To rasterize a viewport takes most of a second of pure Python. Thus a pan moves the
viewport long before its picture can exist. MeshTerm does not answer the key press with
an empty canvas, because then the map goes black at each step and comes back when the
raster is ready. Instead, the paint reprojects this ghost: the streets as they were,
moved to where they now are, below new markers. It is only the ground (refer to
:class:`~meshterm.ui.mapcanvas.Raster`). The labels and the node markers are drawn
again at their real positions on top.
Attributes:
raster: The braille layer of the earlier raster.
viewport: The viewport that the earlier raster was drawn for. It tells where the
dots of that raster moved to after that.
"""
raster: Raster
viewport: Viewport
#: How much the reused ground dims while MeshTerm draws its replacement. It is enough to
#: show that the ground is temporary, and to make the edge that the viewport pans onto
#: (not drawn yet) look different from real empty ground. It is not so much that the
#: shape of the streets is lost.
#:
#: The value is bound when the platform changes, and it is off where there is no
#: truecolour. On the 16-slot console, a colour does not dim: it goes to a different slot.
#: Thus a fade there changes the colours: the faint road classes become black, and the
#: other classes mix together. The ``drawing…`` in the title gives the same information on
#: the two platforms.
_GHOST_FADE = 0.6
#: The difference in zoom steps above which MeshTerm does not use the ghost, instead of a
#: scaled ghost. What the ghost shows is true at any distance, because the dots are
#: reprojected, not the glyphs (refer to :func:`~meshterm.ui.mapcanvas._magnified`). But a
#: dot enlarged by more than two steps is eight cells wide. A mosaic that coarse does not
#: help the user to find a position: nothing of the street pattern is left to recognize,
#: and the markers alone are the better picture.
_GHOST_MAX_STEPS = 2
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the fade of the ghost to what the palette of the platform can 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,
magnify: int,
) -> list[tuple[int, int]]:
"""Source cell and sub-cell for each canvas cell on one axis (``(-1, 0)`` for none).
The two viewports are rectangles in Web Mercator. Thus one maps onto the other by an
affine scale and shift, on each axis independently: a dot at world position
``d + origin`` is at ``(d + origin) * scale - src_origin`` in the older viewport. The
full reprojection becomes two small index tables, which the paste then reads.
Where the viewport zoomed in, one source cell spans ``magnify`` cells here. Thus the
integer part of that division is only half of the answer. The remainder tells which of
those cells this cell is, and thus which part of the dots of the source it must show
enlarged (refer to :meth:`~meshterm.ui.mapcanvas.MapCanvas.paste_raster`). At a
``magnify`` of 1, the remainder is always zero, and this is the plain table of cell
indexes that it was before.
Args:
cells: The size of the canvas along this axis, in cells.
dots: The dots for each cell on this axis (2 across, 4 down).
origin: The world-pixel origin of the canvas viewport on this axis.
src_origin: The world-pixel origin of the ghost viewport, at the zoom of the ghost.
scale: The world pixels at the zoom of the ghost for each world pixel at our zoom.
src_cells: The size of the ghost raster along this axis, for the bounds test.
magnify: How many cells here one source cell covers.
"""
out: list[tuple[int, int]] = []
for c in range(cells):
centre = c * dots + dots / 2 # sample each cell at its middle dot
exact = ((centre + origin) * scale - src_origin) / dots
src = math.floor(exact)
if not 0 <= src < src_cells:
out.append((-1, 0))
continue
# The magnified sub-cell that contains the middle of this cell. Floating point can
# make the fraction very slightly less than 1.0. Thus the last sub-cell is clamped,
# and the division is not trusted.
out.append((src, min(int((exact - src) * magnify), magnify - 1)))
return out
def _paste_ghost(canvas: MapCanvas, viewport: Viewport, ghost: Ghost) -> None:
"""Put the ground of ``ghost`` onto ``canvas``, reprojected to ``viewport``.
The function does nothing when the zooms of the two viewports are too far apart, or
when the pan moved the viewport fully off the ground of the old raster. In the two
cases, nothing is left that can stand in, and the markers alone are the honest picture.
"""
src = ghost.viewport
steps = viewport.zoom - src.zoom
if abs(steps) > _GHOST_MAX_STEPS:
return
scale = 2.0**-steps
magnify = 1 << steps if steps > 0 else 1 # a viewport zoomed out keeps whole glyphs
ox, oy = viewport.origin_world
sx, sy = src.origin_world
cols = _ghost_axis(canvas.cell_w, 2, ox, sx, scale, ghost.raster.cell_w, magnify)
if not any(c >= 0 for c, _ in cols):
return
rows = _ghost_axis(canvas.cell_h, 4, oy, sy, scale, ghost.raster.cell_h, magnify)
if not any(r >= 0 for r, _ in rows):
return
canvas.paste_raster(ghost.raster, cols, rows, magnify=magnify, fade=_GHOST_FADE)
# -- composing a raster -------------------------------------------------------
def render_map(
viewport: Viewport,
tiles: dict[tuple[int, int, int], list[Layer] | None],
markers: list[MapMarker],
*,
max_labels: int = 80,
find: str = "",
ghost: Ghost | None = None,
) -> list[str]:
"""Render a full map raster to truecolour ANSI lines.
Args:
viewport: The viewport to render (its dot size sets the canvas size).
tiles: Decoded layers, indexed by ``(z, x, y)``. A ``None`` value is pending or
absent.
markers: The mesh nodes to put on top.
max_labels: The maximum number of basemap labels to place, to keep the map
readable.
find: A live node-name filter. Markers whose label contains it (case-insensitive)
draw with bright white labels, and the other markers dim to context with no
label. When it is empty, each node draws normally.
ghost: Ground from an earlier raster to put down first, reprojected onto this
viewport (refer to :class:`Ghost`). It stands in for ground that is not drawn
yet. Thus it goes with the rasters that have no ``tiles`` of their own. A
raster that draws the real ground does not use one.
Returns:
One ANSI string for each line, 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], list[Layer] | None],
markers: list[MapMarker],
*,
max_labels: int = 80,
find: str = "",
coarse: bool = False,
) -> tuple[list[str], Ghost]:
"""Render a raster and keep its ground, so that the next moved viewport can use it.
It does the same work as :func:`render_map`, and also returns the :class:`Ghost` that
the raster leaves. That is why the interactive map calls this function for the real
(background) raster, and :func:`render_map` for the immediate paints between them.
``coarse`` draws the **first pass**: the ground fills, the watercourses, the admin
boundaries, and the through-roads (:data:`_COARSE_ROAD_PRIORITY`). It draws no
buildings, no back streets, and no text. It is the same picture with less detail, and
it is a quarter of the work: on the PicoCalc, 249 ms instead of 962 ms at zoom 13, and
120 ms instead of 529 ms at zoom 16. When it is drawn before the full raster, it gives
a viewport that moved past its last ground something true almost immediately. It also
gives a held pan a unit of work that is small enough to cancel. The map asks for this
pass when :data:`~meshterm.ui.map_screen._COARSE_PREVIEW` is on (refer to
:meth:`meshterm.ui.map_screen.MapScreen._draw_ground`).
Args:
viewport: The viewport to render.
tiles: Decoded layers, indexed by ``(z, x, y)``.
markers: The mesh nodes to put on top.
max_labels: The maximum number of basemap labels to place.
find: A live node-name filter (refer to :func:`render_map`).
coarse: Draw the quick first pass instead of the finished raster.
Returns:
The ANSI lines, and the ground that they leave.
"""
canvas = _compose(viewport, tiles, markers, max_labels=max_labels, find=find, coarse=coarse)
return canvas.to_ansi_lines(), Ghost(canvas.raster(), viewport)
def _compose(
viewport: Viewport,
tiles: dict[tuple[int, int, int], list[Layer] | None],
markers: list[MapMarker],
*,
max_labels: int = 80,
find: str = "",
ghost: Ghost | None = None,
coarse: bool = False,
) -> MapCanvas:
"""Draw one raster onto a new canvas. Refer to :func:`render_map` for the arguments."""
canvas = MapCanvas(viewport.dot_w // 2, viewport.dot_h // 4)
frame = _Frame(canvas=canvas, viewport=viewport, coarse=coarse)
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)
# The nodes reserve their cells first, so that basemap labels go around them.
_draw_nodes(canvas, viewport, markers, find=find)
# Then place the basemap labels by importance, and obey the zoom gate and the
# collisions. OpenStreetMap divides a long street into several named features, so one
# name can arrive many times. In the width of a terminal, the second copy only takes
# space from a street that has no name. Thus a name is drawn one time in each raster.
placed = 0
named: set[str] = set()
for label in sorted(frame.labels, key=lambda lab: lab.rank):
if placed >= max_labels:
break
if viewport.zoom < label.min_zoom or label.text in named:
continue
for ax, ay in ((label.x, label.y), *label.alts):
if canvas.place_label(ax, ay, label.text, label.color, bold=label.bold):
placed += 1
named.add(label.text)
break
return canvas
#: The y of a point, for ``min``/``max`` to read in C (refer to ``on_view`` in
#: :func:`_draw_tile`).
_Y = itemgetter(1)
def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> None:
"""Draw the geometry of one decoded tile, and collect its label candidates."""
vp = frame.canvas
by_name = {layer.name: layer for layer in layers}
# The projection from tile-local units to dots is affine, and in practice all the
# layers of a tile have the same extent. Thus its three coefficients are resolved one
# time for each extent, and the work for each vertex is written inline below (refer to
# Viewport.tile_transform). A downtown raster projects approximately 50k vertices. At
# that count, it is worth the removal of even the one function call for each vertex.
transforms: dict[int, tuple[float, float, float]] = {}
def transform(extent: int) -> tuple[float, float, float]:
t = transforms.get(extent)
if t is None:
t = transforms[extent] = frame.viewport.tile_transform(x, y, z, extent)
return t
def project(ring: list[tuple[int, int]], extent: int) -> list[tuple[float, float]]:
bx, by, step = transform(extent)
return [(bx + lx * step, by + ly * step) for lx, ly in ring]
# The canvas as a rectangle in the units of this tile, one dot wider on each side. Thus
# a ring can be tested before one point of it is projected. A zoomed-out viewport shows
# a thin part of tiles whose coastlines and land cover have tens of thousands of
# points. The projection of all of them was half of a z7 raster. To skip a ring that is
# fully outside is exact, never an approximation: a line there sets no dot, and the
# crossings of a closed ring on each scanline come in pairs off the canvas. Thus the
# even-odd fill of the other rings in the same polygon does not change (refer to
# MapCanvas.fill_polygon).
windows: dict[int, tuple[float, float, float, float]] = {}
def on_view(ring: list[tuple[int, int]], extent: int) -> bool:
w = windows.get(extent)
if w is None:
bx, by, step = transform(extent)
w = windows[extent] = (
(-1 - bx) / step,
(vp.dot_w + 1 - bx) / step,
(-1 - by) / step,
(vp.dot_h + 1 - by) / step,
)
lo_x, hi_x, lo_y, hi_y = w
# min and max over the tuples run in C. The first element decides, so these are x.
return not (
max(ring)[0] < lo_x
or min(ring)[0] > hi_x
or max(ring, key=_Y)[1] < lo_y
or min(ring, key=_Y)[1] > hi_y
)
# The palette of a tile is a few colours that tens of thousands of features share. Thus
# each colour is resolved one time here, not for each feature. mark_rgb caches its
# results, but at approximately 30 000 calls for each raster, even a dict hit behind a
# function call takes real time on the handheld. Also, the styles below are constants,
# so the answer never changes in a tile. The styles for each class (roads, waterways,
# places) are cached in the dicts next to them.
water_rgb, water_prio = mark_rgb(_WATER_FILL[0]), _WATER_FILL[1]
green_rgb, green_prio = mark_rgb(_GREEN_FILL[0]), _GREEN_FILL[1]
boundary_rgb = mark_rgb("#6d5f88")
road_styles: dict[str | None, tuple[tuple[int, int, int], int]] = {}
water_styles: dict[str | None, tuple[tuple[int, int, int], int] | None] = {}
# Fills first (water, green space), so that lines and labels are on top.
for name in _FILL_LAYERS:
layer = by_name.get(name)
if layer is None:
continue
is_water = name == "water"
for feat in layer.features:
if feat.geom_type != GEOM_POLYGON:
continue
if is_water:
rgb, prio = water_rgb, water_prio
elif str(feat.tags.get("class") or feat.tags.get("subclass")) in _GREEN_CLASSES:
rgb, prio = green_rgb, green_prio
else:
continue
ext = layer.extent
vp.fill_polygon([project(r, ext) for r in feat.rings if on_view(r, ext)], rgb, prio)
# Buildings, as a stippled texture below the streets.
building = by_name.get("building")
if building is not None and frame.viewport.zoom >= _BUILDING_MIN_ZOOM and not frame.coarse:
rgb = mark_rgb(_BUILDING_FILL[0])
# A downtown tile holds thousands of footprints, and a zoomed-in viewport shows a
# few dozen of them. Thus the code tests the bounds of each footprint before it
# projects the footprint, and rejects each footprint that is outside.
ext = building.extent
for feat in building.features:
if feat.geom_type != GEOM_POLYGON:
continue
keep = [ring for ring in feat.rings if len(ring) >= 4 and on_view(ring, ext)]
if keep:
vp.fill_polygon(
[project(r, ext) for r in keep],
rgb,
_BUILDING_FILL[1],
stipple=_BUILDING_STIPPLE,
)
# Waterways (rivers and streams) as lines.
waterway = by_name.get("waterway")
if waterway is not None:
for feat in waterway.features:
cls = feat.tags.get("class")
style = water_styles.get(cls, _UNRESOLVED)
if style is _UNRESOLVED:
named = _WATERWAY_STYLE.get(str(cls))
style = None if named is None else (mark_rgb(named[0]), named[1])
water_styles[cls] = style
if style is None or feat.geom_type != GEOM_LINE:
continue
rgb, prio = style
for ring in feat.rings:
if len(ring) >= 2 and on_view(ring, waterway.extent):
vp.draw_line(project(ring, waterway.extent), rgb, prio)
if feat.name and not frame.coarse:
_add_line_label(
frame, feat.rings, waterway.extent, z, x, y, feat.name, _WATER_LABEL
)
# Roads and rail.
transportation = by_name.get("transportation")
if transportation is not None:
for feat in transportation.features:
if feat.geom_type != GEOM_LINE:
continue
cls = feat.tags.get("class")
style = road_styles.get(cls)
if style is None:
name = str(cls or "")
named = (
_RAIL if name in ("rail", "transit") else _ROAD_STYLE.get(name, _ROAD_DEFAULT)
)
style = road_styles[cls] = (mark_rgb(named[0]), named[1])
rgb, prio = style
if frame.coarse and prio < _COARSE_ROAD_PRIORITY:
continue # the back streets are most of the lines, and the least important
for ring in feat.rings:
if len(ring) >= 2 and on_view(ring, transportation.extent):
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.tags.get("admin_level", 99)) > 6:
continue
except (TypeError, ValueError):
continue
for ring in feat.rings:
if len(ring) >= 2 and on_view(ring, boundary.extent):
vp.draw_line(project(ring, boundary.extent), boundary_rgb, 14)
if frame.coarse:
return # all after this point is text, and text is for the second pass
# Street-name labels, when the streets are far enough apart to have a name each. The
# zoom gate is here and also at placement (:data:`_STREET_LABEL_MIN_ZOOM`). The label
# is the expensive half of a named road, and below the gate, each label was built and
# then removed. The zoom of the viewport decides, not the zoom of the tile. An
# overzoomed viewport gets its ground from a lower tile, but it is still zoomed in, and
# it still needs names.
tname = by_name.get("transportation_name")
if tname is not None and frame.viewport.zoom >= _STREET_LABEL_MIN_ZOOM:
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=_STREET_LABEL_MIN_ZOOM,
)
# 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.tags.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
label_rgb = mark_rgb(color)
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, label_rgb, bold, 8))
def _clip_to_canvas(
x0: float, y0: float, x1: float, y1: float, w: float, h: float
) -> tuple[float, float, float, float] | None:
"""Clip a segment to the ``0..w`` by ``0..h`` canvas (Liang-Barsky).
Returns:
The part of the segment in the canvas, or ``None`` if no part is in it. A street
that crosses the viewport with its two endpoints outside the edges still gives the
part that you can see. That is the purpose of a clip, instead of a test of the
endpoints.
"""
dx, dy = x1 - x0, y1 - y0
t0, t1 = 0.0, 1.0
for p, q in ((-dx, x0), (dx, w - x0), (-dy, y0), (dy, h - y0)):
if p == 0:
if q < 0:
return None # parallel to this edge, and fully outside it
continue
t = q / p
if p < 0:
if t > t1:
return None
t0 = max(t0, t)
else:
if t < t0:
return None
t1 = min(t1, t)
return (x0 + t0 * dx, y0 + t0 * dy, x0 + t1 * dx, y0 + t1 * dy)
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:
"""Put a label in the queue, on the longest part of a line feature that is on the screen.
An anchor at the midpoint of the feature (the middle of the street as the tile drew it)
pins the label to a fixed geographic point, not to your viewport. The more you zoom in,
the less likely it is that this point is still on the screen. Because of this, street
names became rarer the more you zoomed in: of 265 named streets in a central Montréal
tile, 37 anchors were on a 53x26 canvas at zoom 14, and only 6 at zoom 16.
Thus the line is clipped to the canvas first, and the label goes on the longest piece
that stays. The next-longest pieces are kept as alternatives, for when that position is
already taken. A feature that is fully off the screen puts nothing in the queue.
"""
vp = frame.viewport
w, h = float(vp.dot_w), float(vp.dot_h)
pieces: list[tuple[float, float, float]] = [] # (length, mid x, mid y)
for ring in rings:
if len(ring) < 2:
continue
prev = vp.feature_to_dot(x, y, z, extent, *ring[0])
for point in ring[1:]:
cur = vp.feature_to_dot(x, y, z, extent, *point)
visible = _clip_to_canvas(prev[0], prev[1], cur[0], cur[1], w, h)
prev = cur
if visible is None:
continue
ax, ay, bx, by = visible
pieces.append((math.hypot(bx - ax, by - ay), (ax + bx) / 2, (ay + by) / 2))
if not pieces:
return
pieces.sort(key=lambda p: -p[0])
color, bold, rank = style
frame.labels.append(
_Label(
rank,
pieces[0][1],
pieces[0][2],
text,
mark_rgb(color),
bold,
min_zoom,
alts=tuple((px, py) for _, px, py in pieces[1:4]),
)
)
def _marker_style(marker: MapMarker) -> tuple[str, str]:
"""The glyph and colour of a marker: our node, a repeater, or a leaf node."""
if marker.is_self:
return _SELF
if marker.is_repeater:
return _REPEATER
return _NODE
#: How much a node outside an active find filter dims (a multiplier for the glyph colour).
_FIND_DIM = 0.35
#: The 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``, and clamped to the 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:
"""Draw the mesh nodes on top: each glyph, then labels by importance until they collide.
The glyphs are drawn lowest priority first, so that our node and the repeaters are on
top of the leaf nodes. One cell shows one glyph, so where several nodes share a cell,
the glyph of the top node stays. (A crowd once made that glyph brighter. Each marker
is white now, and white cannot get brighter, so JP removed that signal on
2026-10-14.) Then the labels are placed highest priority first (our
node, then repeaters, then leaf nodes), each only if it fits with no overlap. Thus, on
a crowded map, the important labels get the available space, and the other nodes show
as a bare marker (with no overlap).
When a ``find`` filter is active, only the matching nodes keep their labels. These
labels are bright white, so that they are easy to see. All the other nodes dim to
context near the background colour, and a match label never loses a collision to a
node that does not match.
"""
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))
# Matches draw after (over) the dimmed nodes that do not match, at any rank. Thus the
# node that the user looks for is never hidden below 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 = mark_rgb(color)
if not matched:
rgb = _dimmed(rgb, _FIND_DIM)
canvas.marker(ix, iy, glyph, rgb)
# A node that the filter removes is context: a bare dim glyph with no label. Labels get
# the name hue of the node, which comes from its key (the colour rule for all the app).
# The label of our node is the pure ``you`` white, the same as the ★ glyph. An
# active find filter still makes the label of each match full white, so that the node
# that the user looks for is easy to see.
from .widgets import name_rgb # widgets imports this module, so bind late to avoid 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)