Commit Graph
20 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 c19711c463 Map: don't build a street name the frame is about to throw away
_compose will not place a label under its own min_zoom, and street names
carry one of 15. _draw_tile built them anyway, at every zoom: a named
road's label is the expensive half of it — the line clipped to the canvas
segment by segment, each surviving piece measured, the pieces sorted, and
feature_to_dot called once per vertex, which is the per-vertex function
call the geometry path beside it deliberately inlined away. A central
Montréal tile carries 270 of them.

Measured on the desktop against the committed renderer, one tile at
PicoCalc geometry: 16.8 ms of a 46 ms frame at z13, all of it discarded a
moment later. Below the gate a frame is now 27-52% cheaper across z9-z14,
which on the device is a third of a second off the two zooms the map
most often sits at — it opens on the region fit, and z13/z14 is where a
mesh-sized frame lands.

The zoom is the display zoom, not the tile's: an overzoomed view reads
its ground off a lower tile but is still zoomed in, and still wants its
names. The 15 is a constant now, read by the queue-time gate and the
place-time test both, so the two cannot drift.

Nothing in the picture moves. Rendered against the pre-change renderer at
z9-z18 on three geometries, coarse pass included: every frame
byte-identical, every Ghost raster identical (a label was never in the
raster — that is the overlay). The skipped candidates never reached the
max_labels budget either, since the placement loop continues past an
under-zoom label rather than counting it, and sorted() is stable, so what
does get placed is unchanged. Crossing the gate changes the viewport and
so the ground key, which means a zoom across it always redraws rather
than serving a frame short of its names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:58:58 -04:00
Jean-Pierre MartineauandClaude Opus 5 c79ade3fab Map: the rough first pass is off, behind a switch rather than a deletion
_COARSE_PREVIEW turns off the two-pass draw: every view now waits for the
whole picture, standing on the last frame's ground reprojected until it
lands, or on nothing where the view has run past that too.

None of the machinery goes. Both passes still draw, render_ground still
takes coarse=, and the abandonment point between them is still where a
held pan key gets off — what the switch decides is only whether a moved
view is offered the first pass, which is why it is read where the raster
is asked for and not inside the renderer. Turning it back on is one line.

The abandonment test needed to say what it means to survive the change:
it asserted no finished pass appeared in a list it read after the loop
closed, which quietly depended on the *next* view's draw not getting a
scheduler step in time. It reads the list where the abandoned key's draw
returns now, and asks only about that key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:09:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 b4db55961f Map: three optimisations that were undoing each other's work
The rough first pass and the prefetcher each state a rule about when they
must stand down, and each was reading that rule off the wrong thing.

The prefetcher's rule is that speculation is never the thing in the way:
no guessing while a tile is in flight or a raster is running or queued.
It was asked from inside _ensure_tiles, which runs before _ground — so
what it read was the *previous* paint's answer. On a settled map a find
keystroke moves nothing, so the view is still the one it is entitled to
guess from, and it started a tile decode in the very paint that queued
the frame the reader was waiting for: the ~110 ms in front of the next
frame the pacing was written to avoid. It is asked at the end of the
paint now, once _ground has said what this frame owes.

The rough pass's rule is that it is skipped where the ground is already
drawn, because a coarse picture of streets the reader can see takes
detail away. It decided that on _aligned, which also compares the find
query and the marker count — the *overlay*. Change one and the ground
underneath is the same ground, still drawn, still correct; but the
answer came back "not aligned", so every letter of a find query flattened
the streets to the rough pass and drew them back, once per keystroke.
That question is now its own — _ground_drawn, which asks about the view
alone — and _aligned goes back to being only about the picture.

And a guess must not push past a silence: a tile the source has just gone
quiet about is on a cooldown so an offline map isn't retrying it on a
loop, and the prefetcher was walking straight past that to re-ask it off
its own initiative. It leaves cooling tiles alone; when the cooldown
expires the tile is in the plan again, and if the reader has panned onto
it by then the fetch is theirs, made on their behalf, which is the one
the cooldown was always sized for.

Two constants went with them, both describing mechanisms that aren't
there: _PREFETCH_LEAD, which measured the lead in pan steps the plan
stopped working in, and _COARSE_ROADS, whose class list left out the two
classes (rail, and the unrecognised default) the priority test it was
supposed to mirror actually drops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 00:39:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 8b5c27909b Map: draw a rough frame first, so a moved view is never black for long
A coarse pan step is 30% of the view, so four keypresses leave nothing of
the last frame under the screen — there is no ground to reproject, and the
map went black. It then stayed black for a whole raster, which is most of
a second on the PicoCalc, and often for two: the raster in flight was
being drawn for a view the user had already left, and the one they were
actually looking at could not start until it finished.

So the raster is two passes now. The first draws the ground fills, the
watercourses and the through-roads (tertiary and up, derived from the road
styles so the two sets cannot drift) — no buildings, no back streets, no
text at all. It is a quarter of the work: 249 ms against 962 at zoom 13 on
the device, 120 against 529 at zoom 16. The second draws the finished
frame over it.

The gap between them is where a moving view gets off. Checking _wanted
there cuts the abandonable unit of work from a whole raster to the rough
one, so a pan being held is answered with real ground about four times as
often and the finished frame is drawn for where the user actually stopped.

Measured on the device, four quick pan steps from a settled map to the
first ground back on screen:

  zoom 13:  1984 ms -> 617 ms
  zoom 15:   970 ms -> 348 ms

The rough pass is skipped where a frame for this view is already on screen
and merely waiting on a tile: there a coarser picture would take away
detail the reader can already see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:19:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 d12873694e Map: magnify the stand-in ground by the dot, not by the glyph
Zooming in reprojected the last frame's ground a cell at a time, which
means a source cell that now covers a 2x2 block of cells was stamped into
all four of them whole. That is double vision: the same 2x4 dot pattern
side by side and one above the other, four times over at two steps. It
reads as a rendering fault rather than as the coarse preview it is, and
it puts the ground up to a cell away from where it really is.

A braille cell is 2x4 dots, so the honest mapping is fixed arithmetic:
each cell of the block shows its own quarter (or sixteenth) of the source,
enlarged. That is 256 patterns by n*n sub-positions and it never varies,
so _magnified() builds it once per factor and the paste reads it with a
single index — the same one lookup per cell the naive version cost, and
none of it on the paint path. _ghost_axis now carries the remainder of the
cell division alongside the quotient, which is the sub-position.

Measured against the real frame that lands a moment later, the stand-in's
dots now sit on the ground's dots 85% of the time at one step (was 83%)
and 64% at two (was 61%) — and at two steps its best fit moved to a zero
offset, from a dot column out.

Reduction is deliberately left alone: a view that zoomed out keeps the
whole source glyph in the one cell it shrank to, because dropping three
quarters of its dots would break every thin line into dashes exactly as
it gets harder to see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:54:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 c9aa9a410d Map: the tile palette resolved once per feature, not once per tile
mark_rgb was reached ~30 000 times a frame and Feature.get ~32 000, both
answering the same handful of questions: a tile's colours are a handful
of constants and a handful of feature classes, shared by tens of
thousands of features. The memo behind mark_rgb makes each call a dict
hit, but at that count a dict hit behind a function call is real time on
a Cortex-A7.

Resolve the fixed colours once per tile and memoize the per-class styles
(roads, waterways) into small local dicts; read tags directly rather than
through the accessor. Only the ground raster's catch-up, not the
keystroke — that moved off the paint path already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 05:42:33 -04:00
Jean-Pierre MartineauandClaude Opus 5 6e0c9ec50b 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>
2026-08-09 04:32:51 -04:00
Jean-Pierre MartineauandClaude Fable 5 e53e35523e Map: draw the ground off the keystroke, and stop reprojecting the world
A pan cost 0.9-2.4 s. It was never the tiles - those already load in a
thread - it was render_map, which rasterizes the vector ground on the
paint path: 900-2342 ms for a view holding ~16k features.

Two changes, in that order.

The projection was doing the same trigonometry once per vertex. Every
feature_to_dot call recomputed Viewport.origin_world - a sin/log pair -
and a 2** for values constant across the whole frame, 107k times a frame.
The mapping is affine in tile-local coordinates, so tile_transform folds
it into three numbers and _draw_tile spells the arithmetic out inline: two
multiplies and two adds per vertex, no call at all. That alone is 2.4x
(z12 2342 -> 991 ms).

The rest is genuinely per-feature Python and will not shrink to keystroke
size, so the raster moved off the paint path. A paint now returns the best
picture available immediately and a background task draws the real one:
unchanged view serves the finished frame; a view that only gained a tile
keeps the old raster, which is still correctly aligned; a view that *moved*
draws the nodes alone on the new viewport, which is a few ms, so panning
tracks the keys and the streets catch up. The title says "drawing..." while
it does.

Exactly one raster runs at a time and it is always the newest requested -
a held arrow hands us a new viewport every repaint, and starting a second
of thread-bound work per keypress would queue frames that are already
stale on arrival and slow the keystrokes this exists to protect.

On the device: pan 1205 -> 89 ms, 1156 -> 130 ms, 2449 -> 91 ms; zoom
1161 -> 142 ms; a settled view 37 ms.

Also flips MESHTERM_FASTRENDER on by default (=0 still opts out), and asks
whether a loop is running *before* building a coroutine, so a static render
no longer leaves an un-awaited one behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 01:58:08 -04:00
Jean-Pierre MartineauandClaude Opus 5 5268892780 Map: buildings as a texture, because a filled dot is an eraser
Draw the `building` layer at the top two zooms, stippled to every other dot.
On a braille canvas a dot is one bit, so a solid fill does not shade a region
— it lights every dot in every cell it covers and takes the street grid down
with it. Downtown becomes a wall of ⣿ with the roads gone. The stipple reads
as tone instead, and leaves room in every cell for a road to stay a legible
line through it. Outlines fail differently: at one bit a building's edge and a
road are the same mark.

The lattice is aligned to the canvas rather than to each polygon, so
neighbouring footprints share it and the texture doesn't shimmer at their
seams.

Below the gate a typical footprint is about two dots across — under half a
cell — and the layer collapses into a haze, so it stays off.

Each footprint is rejected on its own tile-local bounds before a single point
of it is projected. Without that, a downtown tile's 2,704 footprints cost the
frame 2-4x (87 -> 258 ms at z15, 82 -> 294 ms at z16, measured on the fixture);
with it, +7% at z15 and +14% at z16, and flat in zoom. Widening DRAWN_LAYERS
re-stamps the decoded sidecar, so this costs one re-decode per tile and no
re-download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 02:44:30 -04:00
Jean-Pierre MartineauandClaude Opus 5 fd312db8b9 Map: name the street where you can see it, not where it begins
Street names got rarer the further you zoomed in, which is backwards.
A line label anchored to the midpoint of the feature as the *tile* drew
it -- a fixed geographic point, not a point in your view -- and the
tighter the view, the less likely that point was still on screen. Of the
265 named streets in the Montréal fixture, 37 anchors landed on a 53x26
canvas at zoom 14 and 6 at zoom 16, of which 2 survived collision.

The line is now clipped to the canvas first and the label goes on the
longest piece that survives, so the anchor is always somewhere you are
actually looking. Clipping rather than testing the endpoints matters:
at high zoom a street often crosses the whole view with both of its ends
outside it, and that is exactly the street you most want named. The
next-longest pieces are kept as alternates and tried in turn when the
first spot is already taken, so a street whose middle sits under a node
marker still gets named further along. A feature with nothing on screen
queues nothing at all.

That made a second bug visible: OpenStreetMap splits a long street into
several named features, and once each one anchored to its own visible
stretch, René-Lévesque was drawn twice on one screen. A name is now drawn
once per frame -- on a terminal's worth of columns the second copy only
ever takes space from a street that has none.

Zoom 16 goes 2 -> 6 street names at 53x26 and 2 -> 8 at 72x24; zoom 15
goes 10 -> 13. Waterway labels take the same path and improve with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 02:10:38 -04:00
Jean-Pierre MartineauandClaude Opus 5 4e7bcacc37 Tile decode: don't pay for the layers the map never draws
Profiling on the Lyra found vector-tile decode dominating everything else
in the app by about two orders of magnitude: a median cached tile took
1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a
whole viewport. Decoded tiles are memoised for the session, so this is a
first-pan cost -- but it runs on a GIL-bound worker thread, so it starves
the render loop for those seconds rather than running beside it.

Roughly 40% of that work was thrown away. A planet tile ships building,
housenumber, poi and mountain_peak; _draw_tile looks up ten layer names
and none of them is those. So decode_tile now takes the set of layers the
caller actually wants, reads each layer's identity with a cheap header
walk, and decodes the features only for the ones asked for. Unwanted
layers are still returned, named and empty -- which matters, because the
tile source decides whether a cache entry is real by whether the bytes
parsed into layers, and a tile whose every layer we skip must not read as
blank and get its perfectly good cache entry pruned and re-downloaded
every session. That test moves off "has features" and onto "parsed into
layers", which is sound because the other two ways a tile isn't real --
empty and corrupt -- decode to nothing and raise respectively.

Only the decode narrows; whole tiles are still cached, so drawing more
later costs a re-decode and never a re-download.

Measured on the device across the 46 real cached tiles: 183 s -> 51 s,
3.6x, with the drawn layers byte-identical on every one. The median tile
is 1259 -> 353 ms, the largest 3545 -> 759 ms.

_Reader.skip stopped slicing out the bytes it steps over while it was
here, which is most of what makes the header walk cheap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 00:04:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 6f5c61b133 Map: name the four hues the console cannot guess
Water, its watercourses, parks and highways were literal hex, so the
PicoCalc fold picked their slots by nearest neighbour and lost the point
of each one: the dark blue landed on grey, the dark green on black, the
motorway amber on bright red. Same failure the node-type marks already
dodge by naming a style, so these do too — a map.* block in both themes,
resolved through mark_rgb like every other mark.

On the console water is now blue, every watercourse the bright blue one
rung up, parks green and highways brown. Truecolour is unchanged: the
named styles carry the same hexes they replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:36:25 -04:00
Jean-Pierre MartineauandClaude Fable 5 84caae53f8 P3 rework: the PicoCalc visual language, done properly
The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:

- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
  families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
  rewritten against color(0..15) with the VT's bold-as-brightness and
  dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
  install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
  so theme, rasterizer and widgets swap implementations once per switch
  and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
  (NFKD accent folds - the Terminus base has no accented Latin - plus
  symbol stand-ins and the width-padded emoji map), an embedded-SGR
  quantizer for canvas truecolor, and a ?-at-own-width catch-all that
  makes the no-wide-glyph short-circuit true by construction. Applied at
  the only two places every visible byte passes: render_to_ansi and
  MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
  the gallery now asserts rendered picocalc ANSI carries no truecolor/
  256-colour SGR and no character outside it - the parity gate the
  first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
  gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
  donor misses now fatal and a keeper canary (donating pi once erased
  Cyrillic pe - shared slot). Table re-dumped on-device: identical to
  fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
  no longer import ui), fed from monitor observations, heard_nodes and
  merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
  drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
  funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
  marks, not the banned emoji pair.

Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:48:07 -04:00
Jean-Pierre MartineauandClaude Fable 5 3fe4e5b4e1 Map: labels take the name hue, ours the you-white
Node labels were coloured by marker type (self yellow, repeater
violet, leaf pink); they now carry the app-wide key-derived name hue —
MapMarker grows a key field threaded from contacts, stored heard
nodes, and self-info — with our own label the pure you-white while the
★ glyph keeps its yellow. Find-filter matches still force full white,
non-matches still dim bare. The CLI legend follows suit (and its SEEN
column header becomes HEARD, per the lexicon).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:17:32 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f1b06bf2e2 Trophy case: draw each record's walk on the shared route graph
A record's dialog showed its route only as a text line; add the fan-lane braille
graph above it so a walk's shape reads at a glance — us starred at both ends,
each relay marked and labelled by its first hash byte, the same presentation the
Message paths dialog draws a delivery with.

Extract that presentation into widgets.route_graph_style() — the glyph/label/
colour callbacks both screens hand render_path_graph — so the two graphs cannot
drift; pass source==self_name to draw both ends as us for a walk. A route that
revisits a node (a boomerang's mirrored return leg) collapses to distinct-in-
order before drawing, since the depth graph can only seat a node once; the route
line below still carries every hop. Move the shared _UNKNOWN marker down to
map_render alongside _SELF/_NODE/_REPEATER so widgets can reach it without a
cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:18:42 -04:00
Jean-Pierre MartineauandClaude Fable 5 36e8173b5a Map: arrows-only movement, find-as-you-type node filter
Panning is the arrow keys (Shift for fine), zoom is PgUp/PgDn alone, and
reset moves to Home — freeing every letter and +/- so typing can drive a
live name filter instead: matches keep bright white labels and always win
the label contest, everything else dims to bare context glyphs, Enter
frames the matches, Backspace edits, and Esc peels the filter before the
map. The footer swaps to the query while a find is active, the title
counts matches, and the location picker follows the same movement keys
with find disabled (typing there has no job). Hints fit 72 columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:17:33 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6cfe435d55 Redesign the nodes page with heat-map recency and live sorting
Sort contacts alphabetically with our own node pinned first, and add
Heard (relative age) and Pkts (overheard-packet) columns alongside a
per-type glyph and the full path-hash-highlighted key. Drop the boxed
table for a clean header rule and a glyph legend.

Colour each name by a continuous log-scale heat map of how recently it
was heard (white -> yellow -> orange -> red -> grey), and give the
node-type icons a shared, map-safe palette across the whole app: a
yellow star for us, a loud pink node and a calmer violet repeater
(both in hues no basemap uses, so nodes pop more than repeaters),
a white room and an orange sensor.

In the menu the list becomes an interactive screen: left/right pick the
sort column (cyan header + direction triangle), up/down set ascending/
descending, with column widths reserved so toggling never shifts the
row. The scripted CLI keeps a one-shot render via --sort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:38:28 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b02e3b9f4b Always render the map in braille, dropping block mode
The block-element fallback halved the vertical resolution and looked
poor, so remove the braille/block toggle entirely. Drops the `t` key,
the persisted map_block preference, and the quadrant-glyph rendering
path; the map now always draws 2x4 braille dots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:18:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6b84281d0b Render the map so any terminal font can show it
The braille base map and its labels lean on Unicode a terminal font may not
have: 8-dot braille (U+2840-28FF) that many fonts omit, and place/node names
in scripts or emoji outside a Latin monospace font. A missing glyph renders as
double-width tofu, which both looks wrong and shoves the panel right border.

- Prefer a romanized name (name:latin/name:en) over the local script, and strip
  labels to single-cell characters, so foreign names and emoji node names can no
  longer tofu or knock the grid out of alignment.
- Add a block-element base-map mode, toggled with t: 2x2 quadrant blocks instead
  of 2x4 braille - half the vertical resolution, but glyphs essentially every
  monospace font has. Braille stays the default.
- Zoom with PageUp/PageDown as well as +/-.
- While braille mode is active, force a full repaint each paint so the
  double-width-fallback corruption on the right border is scrubbed on every move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:09:18 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2ec18138a7 Rename project from MeshTools to MeshTerm
Rename the package directory, imports, brand strings, env-var prefixes,
console-script entry point, and the config/data dir (~/.config/meshterm).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:40:49 -04:00