Profiling KNNTS041 (700 planets, 1283 primitives, 29 LOCAL fog
circles) flushed three independent costs out of the toggle path:
* `setVisibilityFog` rebuilt the inverse mask + 29 × 9 paint ops on
every effect run, even when the input was identical. Caches a
fingerprint of the circles + wrap mode and bails on a no-op
call — knocks ~1 ms off every flip, more on heavier maps.
* `paintLabelEntry` was split into `paintLabelLayout` (hit-area /
line positions / frame geometry — runs on every content change)
and `paintLabelSelection` (text fills + frame visibility — runs
only when the selection identity actually flips). The incremental
path now skips the 6300 redundant `Text.style.fill = ...` writes
it used to perform on every `planetNames` flip, which is what
forced Pixi to invalidate the underlying text textures.
* `applyLabelContent` no longer blanks `nameText.text` when the
toggle hides the name — it just flips `visible`. The cached text
texture survives, so the next paint frame skips ~700 texture
rebuilds.
Also enables Pixi-side culling on every per-copy primitive / outline
/ label container. With 9 torus copies × ~700 planets the scene
graph holds thousands of nodes, most of which sit outside the
visible viewport at any moment — the cullable flag lets Pixi skip
them in the per-frame traversal.
The legacy `KNNTS041` probe (chromium-desktop, headless) shows
`applyVisibilityState` collapsing from ~24 ms to ~5 ms after a
cache-warm flip; `app.render` drops from ~46 ms to ~22 ms. Reading
the toggle delay end-to-end inside the browser still measures
~460 ms in headless, which is consistent with the runner's RAF
cadence — owner can confirm on the real machine where the previous
~1 s delay was reported.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Max-zoom clamp: `MIN_VISIBLE_WORLD_AT_MAX_ZOOM = 5` world units on
the longest viewport axis. Tuned against the owner's
debug-overlay readings — mobile longest ≈ 412 px clamps at
scale ≈ 82, desktop longest ≈ 1200 px clamps at scale ≈ 240.
Same formula adapts to both shapes automatically; no separate
mobile / desktop branch.
* Planet-names toggle no longer rebuilds every Pixi.Text on a flip.
When `setPlanetLabels` sees the same planet set (which is the
common case — only the `name` lines toggling on / off), it walks
the live label containers and just retunes text content +
visibility instead of destroying and recreating 9 × N Text
instances. A 500-planet map flips the toggle inside a frame now.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Planet discs (and every other circle the renderer draws —
outlines, picker hover ring, reach / bombing rings, etc.) trace
a fixed 32-segment polygon instead of leaning on Pixi's adaptive
bezier subdivision. PixiJS v8 picks the segment count from the
world-space radius, which collapsed to 6-8 segments once the
parent container's scale climbed — so the planet read as a
visible polygon at high zoom. The custom path stays cheap (~64
floats per disc) and gives a perceptually round silhouette at
every zoom level.
* Opt-in dev overlay activated by `?debug=1` in the URL. A small
bottom-left panel shows the current `scale`, the
"whole world fits" reference scale, the current zoom ratio
(scale / scale_ref), and the world-units rectangle visible in
the viewport — so the owner can decide what `maxScale` to clamp
to on the next iteration without guessing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Hit-test: a click inside a planet's visible disc always picks the
planet, regardless of overlapping route shafts or battle X-crosses
with higher base `priority`. Closes the #1, #2, #4 reports
(picker hover would only catch the circumference, planet+routes
swallowed disc clicks, label click on a battled planet routed to
the battle viewer). Slop-only hits (cursor near a line but not on
any disc) still use the existing priority order.
* Labels and planet outlines render in all nine torus copies again
so they follow the player into wrap tiles — closes#3 (labels
vanished on the wrong half of the viewport whenever the camera
was panned past the wrap seam). The fingerprint guard keeps the
per-toggle / per-selection rebuild cheap.
* Pixi.Text gets a few px of `padding` so the rasteriser no longer
clips the last letter on a half-pixel measurement — closes#5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Bug fix: theme flip no longer leaves planets oversized. The
camera-preserving remount now calls a new
`RendererHandle.refreshCameraDerivedDraws` explicitly after the
manual moveCenter/setZoom pair so the post-mount geometry tracks
`viewport.scaled` even if pixi-viewport's `'zoomed'` listener
races the next Ticker tick.
* Doc #3: clicks on a planet label route through the same hit-test
path as a click on the disc. The label `Container` now has a
pointer hit area sized to the text + frame padding; pointertap
simulates a click at the planet centre, so selection and
pick-mode resolution behave identically.
* Doc #4: battle X-crosses + cargo arrowhead wings grow
sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New
`Style.softLengthAnchor` ('center' / 'start') makes the renderer
treat the recorded endpoints as the geometry "at the reference
scale" and rescale around the midpoint (X-cross) or the start
endpoint (arrow wings). Arrowhead base length is halved from 6
to 3 world units to match the owner's "in half" request.
* Doc #5: picker overlay loses the anchor ring at the source, the
cursor line drops to a cargo-route-thin 0.6 px stroke, and the
hover ring around the destination is replaced by a planet-style
outline (visible disc + 1 px padding) in the `pickHighlight`
accent — so candidate destinations read like selection in warm
yellow.
* Doc #6: regression test pins the in-disc hit zone.
* Perf #1: camera-driven redraws are throttled onto the next
Ticker tick. A rapid wheel / pinch burst now coalesces into at
most one `clear() + redraw` pass per painted frame, which keeps
the 500-planet map responsive on zoom and toggle flips.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Planet size formula moves to pixel-space:
`pointRadiusBasePx = 2 + 2 * cbrt(size / SIZE_NORMALIZER)`. The
on-screen disc now reads ~4-7 px at the reference zoom regardless
of how large the world rectangle is — the previous `world-units`
formulation blew up on small maps and made Source-class planets
swallow their neighbours.
* Labels + outlines live in the origin copy only. The 9× replication
across torus copies was the dominant cost on a 100+ planet map
(Pixi.Text creation + Graphics rebuilds on every zoom step); the
origin-copy layout is what the camera-wrap listener guarantees
the user actually sees.
* `setPlanetLabels` and `setPlanetOutlines` skip Pixi-object
rebuilds when the input fingerprint is unchanged — toggle flips
and selection changes now keep the existing Text / Graphics
instances alive and only repaint the affected pieces.
* `renderer.md` updated to the new contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* state-binding.ts: normalise planet size by the engine's typical
mid-range (`SIZE_NORMALIZER = 100`) so legacy fixtures recording
Size in the hundreds do not blow up the world-unit disc and start
overlapping neighbouring planets. The cube-root growth stays;
Size-800 reads twice as big as Size-100.
* cargo-routes.spec.ts: retire the selection-ring CirclePrim from
the expected primitive count (4 planets + 3 cargo arrow lines = 7).
* map-toggles.spec.ts: bombing-rings → planet outlines (the high-bit
0xc… range is permanently empty); planet-names persist test waits
for the renderer's debug providers and for the IndexedDB write to
flush before reload.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the
renderer divides by the current camera scale on every
`viewport.zoomed` so thin lines / small markers stay the same on-screen
size at any zoom.
* Known-size planets switch to `pointRadiusWorld`, softened against the
reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified
planets pin to a 3-px disc.
* New planet label layer renders a two-line `name / #N` legend under
each planet (`#N` only for unidentified or when the new `planetNames`
toggle is off). Selection now paints an inverse-fill frame around the
selected planet's label plus an outline on the disc; the old
selection-ring primitive is retired.
* Bombing markers swap the separate CirclePrim for a planet-outline
overlay (damaged / wiped colour); the report deep-link moves to a
"view bombing report" link in the planet inspector.
* Docs + tests follow: `renderer.md` reflects the new sizing contract +
label / outline layers, vitest covers the sizing math, label
formatting, and the new toggle, and the map-toggles e2e adds a
persistence case for `planetNames`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>