diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md index 2417b14..db991b5 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -73,14 +73,17 @@ and `displayPointRadiusWorld` (in `src/map/world.ts`) compute those world-space values; the hit-test reads the same helpers so the click zone always matches the visible footprint. -`style.pointRadiusWorld` is the alternative sizing rule for planet +`style.pointRadiusBasePx` is the alternative sizing rule for planet discs with a known `size`: the renderer treats the base radius as -world units and softens its growth with the camera scale through -`PLANET_SIZE_ZOOM_ALPHA` (0.33). At `scale = scaleRef` (the -"whole world fits the viewport" zoom) the visible radius equals the -base radius; zooming in grows it sub-linearly so on-screen pixel -size scales as `scale^α`. Setting both `pointRadiusWorld` and -`pointRadiusPx` ignores the pixel-space field. +on-screen pixels **at the reference scale** and grows its on-screen +pixel size with the camera scale through `PLANET_SIZE_ZOOM_ALPHA` +(0.33). At `scale = scaleRef` (the "whole world fits the viewport" +zoom) the visible disc reads at `pointRadiusBasePx` screen pixels; +zooming in grows it as `scale^α` instead of linearly. This keeps +known-size planets sane on every world rectangle — a 4000×4000 map +and a 100×100 map both default to the same on-screen size. Setting +both `pointRadiusBasePx` and `pointRadiusPx` ignores the pixel-space +field. Default hit slop in screen pixels: point=8, circle=6, line=6. These are touch-ergonomic defaults; per-primitive `hitSlopPx > 0` @@ -168,8 +171,9 @@ Per-primitive distance: small ergonomic margin on top. `visibleRadiusWorld` comes from `displayPointRadiusWorld` (F8-12 / #28 + #31): pixel-space `pointRadiusPx / scale` for unidentified planets and most ship - groups, softened-by-zoom `pointRadiusWorld * (scale / scaleRef)^(α-1)` - for planets with a known `size`. `pointRadiusPx` defaults to + groups, softened-by-zoom + `pointRadiusBasePx * (scale / scaleRef)^α / scale` for planets + with a known `size`. `pointRadiusPx` defaults to `DEFAULT_POINT_RADIUS_PX = 3` when neither field is set. - **Filled circle**: `distSq ≤ (radius + slopWorld)²` where `radius` is in world units. The circle counts as filled when diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index a9d56fd..4b6a201 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -453,29 +453,19 @@ export async function createRenderer(opts: RendererOptions): Promise { - const layer = new Container(); - c.addChild(layer); - return layer; - }); - - // Label layer per copy (F8-12 / #29). Labels render above every - // primitive so the text reads on top of fog / route lines, and the - // per-copy layout mirrors the primitive copies so wrap mode still - // shows the labels in whichever torus tile the user is panned over. - // Each layer holds one `Container` per planet (built lazily by - // `setPlanetLabels`), and we keep the scale + y-offset of those - // containers in lock-step with the camera in `updateLabelTransforms`. - const labelLayers: Container[] = copies.map((c) => { - const layer = new Container(); - c.addChild(layer); - return layer; - }); + // Outline + label layers (F8-12 / #29 + #30). Both live in the + // origin copy only — replicating Pixi.Text / Graphics across all + // nine torus copies is the dominant cost on a 100+ planet map, + // and the player almost never sees a label "wrap" out of the + // central tile because the camera-wrap listener snaps the centre + // back into `[0, W) × [0, H)` whenever it walks past the seam. + // Outlines sit between the primitive disc and the labels so the + // stroke reads against the planet fill while staying below the + // textual layer. + const outlineLayer = new Container(); + const labelLayer = new Container(); + copies[ORIGIN_COPY_INDEX].addChild(outlineLayer); + copies[ORIGIN_COPY_INDEX].addChild(labelLayer); // Per-id `Graphics` lookup. Each primitive lives in nine copies // (one per torus tile); pick-mode dims them by id, so the lookup @@ -563,19 +553,30 @@ export async function createRenderer(opts: RendererOptions): Promise(); + const planetLabelInstances = new Map(); let currentLabels: ReadonlyArray = []; + let currentLabelsFingerprint: string | null = null; + let currentLabelsSelectedId: number | null = null; + const fingerprintPlanetLabels = ( + labels: ReadonlyArray, + ): string => { + const parts: string[] = []; + for (const l of labels) { + parts.push(`${l.planetNumber};${l.name ?? ""};${l.numberLabel}`); + } + return parts.join("|"); + }; const LABEL_FONT_SIZE_PX = 11; const LABEL_LINE_GAP_PX = 0; const LABEL_FRAME_PADDING_PX = 3; @@ -608,10 +609,12 @@ export async function createRenderer(opts: RendererOptions): Promise { - for (const list of planetLabelInstances.values()) { - for (const entry of list) disposeLabelGfx(entry); + for (const entry of planetLabelInstances.values()) { + disposeLabelGfx(entry); } planetLabelInstances.clear(); + currentLabelsFingerprint = null; + currentLabelsSelectedId = null; }; const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => { @@ -659,59 +662,64 @@ export async function createRenderer(opts: RendererOptions): Promise l.planetNumber === planetNumber, - ); - const anchorX = labelData?.x ?? planetPrim.x; - const anchorY = labelData?.y ?? planetPrim.y; - for (const entry of list) { - entry.container.x = anchorX; - entry.container.y = anchorY + visibleRadius + gapWorld; - entry.container.scale.set(labelScale); - } + for (const data of currentLabels) { + const entry = planetLabelInstances.get(data.planetNumber); + if (entry === undefined) continue; + const planetPrim = pointPrimitivesById.get(data.planetNumber); + const visibleRadius = + planetPrim === undefined + ? 0 + : displayPointRadiusWorld( + planetPrim.style, + cameraScale, + currentScaleRef, + ); + entry.container.x = data.x; + entry.container.y = data.y + visibleRadius + gapWorld; + entry.container.scale.set(labelScale); } }; - // Planet outline state (F8-12 / #30). One Graphics per planet per - // torus copy. Width and colour come from `PlanetOutlineSpec`; the - // radius is recomputed on every zoom step so the outline tracks - // the visible disc — the planet itself may grow / shrink with - // zoom (`pointRadiusWorld` softening) or stay constant + // Planet outline state (F8-12 / #30). One Graphics per planet, + // painted in the origin copy alongside the label container. Width + // and colour come from `PlanetOutlineSpec`; the radius is + // recomputed on every zoom step so the outline tracks the visible + // disc — the planet itself may grow / shrink with zoom + // (`pointRadiusBasePx` softening) or stay constant // (`pointRadiusPx` pixel-space). interface PlanetOutlineGfx { - readonly graphics: Graphics[]; + readonly graphics: Graphics; readonly spec: PlanetOutlineSpec; } const planetOutlineInstances = new Map(); + let currentOutlinesFingerprint: string | null = null; + const fingerprintPlanetOutlines = ( + outlines: ReadonlyArray, + ): string => { + const parts: string[] = []; + for (const o of outlines) { + parts.push(`${o.planetNumber};${o.color};${o.widthPx ?? -1}`); + } + return parts.join("|"); + }; const OUTLINE_DEFAULT_WIDTH_PX = 1.5; const OUTLINE_RADIUS_PADDING_PX = 1; // gap between disc edge and stroke const clearAllOutlines = (): void => { for (const entry of planetOutlineInstances.values()) { - for (const g of entry.graphics) { - g.parent?.removeChild(g); - g.destroy(); - } + entry.graphics.parent?.removeChild(entry.graphics); + entry.graphics.destroy(); } planetOutlineInstances.clear(); + currentOutlinesFingerprint = null; }; const paintOutlineEntry = (entry: PlanetOutlineGfx): void => { const cameraScale = viewport.scaled; if (cameraScale <= 0) return; const planetPrim = pointPrimitivesById.get(entry.spec.planetNumber); - if (planetPrim === undefined) { - for (const g of entry.graphics) g.clear(); - return; - } + entry.graphics.clear(); + if (planetPrim === undefined) return; const visibleRadius = displayPointRadiusWorld( planetPrim.style, cameraScale, @@ -721,15 +729,12 @@ export async function createRenderer(opts: RendererOptions): Promise { @@ -741,15 +746,22 @@ export async function createRenderer(opts: RendererOptions): Promise, ): void => { - clearAllOutlines(); - for (const spec of outlines) { - const list: Graphics[] = []; - for (const layer of outlineLayers) { - const g = new Graphics(); - layer.addChild(g); - list.push(g); + const fp = fingerprintPlanetOutlines(outlines); + if (fp === currentOutlinesFingerprint) { + // Same dataset — just refresh the geometry (the planet + // position / size may have changed in the underlying + // primitive). Keeps Graphics instances around. + for (const entry of planetOutlineInstances.values()) { + paintOutlineEntry(entry); } - const entry: PlanetOutlineGfx = { graphics: list, spec }; + return; + } + clearAllOutlines(); + currentOutlinesFingerprint = fp; + for (const spec of outlines) { + const g = new Graphics(); + outlineLayer.addChild(g); + const entry: PlanetOutlineGfx = { graphics: g, spec }; planetOutlineInstances.set(spec.planetNumber, entry); paintOutlineEntry(entry); } @@ -760,33 +772,55 @@ export async function createRenderer(opts: RendererOptions): Promise, selectedPlanetId: number | null, ): void => { + const fp = fingerprintPlanetLabels(labels); + const sameContent = fp === currentLabelsFingerprint; + const sameSelection = selectedPlanetId === currentLabelsSelectedId; + if (sameContent && sameSelection) { + // Position-only update (a zoom step may have moved planets + // in the data) — keep Pixi.Text instances alive. + currentLabels = labels.slice(); + updateLabelTransforms(); + return; + } + if (sameContent) { + // Text + planet identity unchanged; only the selection frame + // flips. Repaint the affected entries instead of rebuilding. + currentLabels = labels.slice(); + for (const data of labels) { + const entry = planetLabelInstances.get(data.planetNumber); + if (entry === undefined) continue; + paintLabelEntry(entry, data.planetNumber === selectedPlanetId); + } + currentLabelsSelectedId = selectedPlanetId; + updateLabelTransforms(); + requestRender(); + return; + } clearAllLabels(); currentLabels = labels.slice(); + currentLabelsFingerprint = fp; + currentLabelsSelectedId = selectedPlanetId; for (const data of labels) { - const list: LabelGfx[] = []; - for (const layer of labelLayers) { - const container = new Container(); - const frame = new Graphics(); - frame.visible = false; - container.addChild(frame); - const nameText = - data.name === null - ? null - : buildLabelText(data.name, theme.labelText); - if (nameText !== null) container.addChild(nameText); - const numberText = buildLabelText(data.numberLabel, theme.labelMuted); - container.addChild(numberText); - layer.addChild(container); - const entry: LabelGfx = { - container, - frame, - nameText, - numberText, - }; - paintLabelEntry(entry, data.planetNumber === selectedPlanetId); - list.push(entry); - } - planetLabelInstances.set(data.planetNumber, list); + const container = new Container(); + const frame = new Graphics(); + frame.visible = false; + container.addChild(frame); + const nameText = + data.name === null + ? null + : buildLabelText(data.name, theme.labelText); + if (nameText !== null) container.addChild(nameText); + const numberText = buildLabelText(data.numberLabel, theme.labelMuted); + container.addChild(numberText); + labelLayer.addChild(container); + const entry: LabelGfx = { + container, + frame, + nameText, + numberText, + }; + paintLabelEntry(entry, data.planetNumber === selectedPlanetId); + planetLabelInstances.set(data.planetNumber, entry); } updateLabelTransforms(); requestRender(); diff --git a/ui/frontend/src/map/state-binding.ts b/ui/frontend/src/map/state-binding.ts index 4b27ea2..a858895 100644 --- a/ui/frontend/src/map/state-binding.ts +++ b/ui/frontend/src/map/state-binding.ts @@ -39,20 +39,21 @@ import { // extra lookup. /** - * KNOWN_PLANET_BASE_RADIUS_WORLD calibrates the cube-root size - * mapping so that an "average" planet (`size === SIZE_NORMALIZER`) - * renders at roughly this radius in world units when the camera is - * at the reference scale. Larger / smaller planets scale by + * KNOWN_PLANET_MIN_RADIUS_PX / KNOWN_PLANET_GROWTH_PX calibrate the + * cube-root size mapping in screen-pixel space. At the "whole world + * fits" reference zoom (`scaleRef`) a Size-`SIZE_NORMALIZER` planet + * reads at `MIN + GROWTH` pixels; smaller / larger planets scale by * `cbrt(size / SIZE_NORMALIZER)`, which keeps disc area proportional - * to volume — a Size-800 planet reads twice as big as a Size-100 one, - * eight times its volume but only 2× the radius. + * to volume — Size-800 reads twice as big as Size-100. The pixel + * frame is the right one to calibrate in, because it stays sane no + * matter how large the world rectangle is. * - * `SIZE_NORMALIZER` follows the engine's typical mid-range. Without - * it, the raw cube-root grows huge for legacy fixtures that record - * Size in hundreds; with it, the disc stays in a sane world-unit - * band so neighbouring planets never overlap on the default zoom. + * The renderer combines these with `PLANET_SIZE_ZOOM_ALPHA` so the + * pixel radius grows sub-linearly as the player zooms in: 10× zoom + * scales the radius by ~2.15×, not by 10×. */ -const KNOWN_PLANET_BASE_RADIUS_WORLD = 4; +const KNOWN_PLANET_MIN_RADIUS_PX = 2; +const KNOWN_PLANET_GROWTH_PX = 2; const SIZE_NORMALIZER = 100; /** @@ -68,9 +69,10 @@ function styleFor(planet: ReportPlanet, theme: Theme): Style { if (planet.kind === "unidentified" || size === null || !(size > 0)) { return { ...fill, pointRadiusPx: UNKNOWN_PLANET_PIXEL_RADIUS }; } - const baseRadius = - KNOWN_PLANET_BASE_RADIUS_WORLD * Math.cbrt(size / SIZE_NORMALIZER); - return { ...fill, pointRadiusWorld: baseRadius }; + const basePx = + KNOWN_PLANET_MIN_RADIUS_PX + + KNOWN_PLANET_GROWTH_PX * Math.cbrt(size / SIZE_NORMALIZER); + return { ...fill, pointRadiusBasePx: basePx }; } function fillForKind( diff --git a/ui/frontend/src/map/world.ts b/ui/frontend/src/map/world.ts index 054df0b..2e51235 100644 --- a/ui/frontend/src/map/world.ts +++ b/ui/frontend/src/map/world.ts @@ -26,11 +26,12 @@ export type WrapMode = "torus" | "no-wrap"; // thickening that the old contract promised but never delivered is // gone. // -// `pointRadiusWorld` is the opposite intent: a planet's known -// `size` produces a base radius in world units, and the renderer -// softens its growth with the camera scale through -// `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31). When `pointRadiusWorld` -// is set on a `PointPrim`, `pointRadiusPx` is ignored. +// `pointRadiusBasePx` is the opposite intent: a planet's known +// `size` produces a base on-screen pixel radius at the "whole world +// fits" reference zoom, and the renderer grows it sub-linearly with +// the camera scale through `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31). +// When `pointRadiusBasePx` is set on a `PointPrim`, `pointRadiusPx` +// is ignored. export interface Style { fillColor?: number; // 0xRRGGBB fillAlpha?: number; // 0..1 @@ -38,7 +39,7 @@ export interface Style { strokeAlpha?: number; // 0..1 strokeWidthPx?: number; // screen pixels at any zoom pointRadiusPx?: number; // screen pixels at any zoom (for kind === 'point') - pointRadiusWorld?: number; // world units, softened by PLANET_SIZE_ZOOM_ALPHA + pointRadiusBasePx?: number; // screen pixels at scaleRef, softened by PLANET_SIZE_ZOOM_ALPHA // strokeDashPx — when set on a `LinePrim`, the line is rendered as // a dashed pattern whose dash and gap are both this length. When // unset (or zero), the stroke is solid. Interpreted in world-unit @@ -231,12 +232,13 @@ export const PLANET_SIZE_ZOOM_ALPHA = 0.33; /** * displayPointRadiusWorld returns the world-space radius the renderer * should draw a `PointPrim` with at the current camera scale. When - * `style.pointRadiusWorld` is set (known-size planets), the radius is - * the base world radius softened by `PLANET_SIZE_ZOOM_ALPHA` relative - * to `scaleRef` — at `scale = scaleRef` it equals the base radius; - * zooming in grows it sub-linearly. Otherwise the radius collapses to - * `pointRadiusPx / cameraScale` so the on-screen disc stays the same - * pixel size regardless of zoom. + * `style.pointRadiusBasePx` is set (known-size planets), the radius + * is the base pixel size at `scaleRef`, grown by + * `(scale / scaleRef)^α` and converted back into world units — + * `α = PLANET_SIZE_ZOOM_ALPHA`. At `scale = scaleRef` the visible + * pixel size equals the base; a 10× zoom-in only grows it ~2.15×. + * Otherwise the radius collapses to `pointRadiusPx / cameraScale` so + * the on-screen disc stays the same pixel size regardless of zoom. * * Used by both the renderer (`render.ts:drawPoint`) and the hit-test * (`hit-test.ts:matchPoint`) so the visible disc and the click zone @@ -247,12 +249,17 @@ export function displayPointRadiusWorld( cameraScale: number, scaleRef: number, ): number { - if (style.pointRadiusWorld !== undefined) { - const softening = Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1); - return style.pointRadiusWorld * softening; + if (cameraScale <= 0) { + return style.pointRadiusBasePx ?? style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX; + } + if (style.pointRadiusBasePx !== undefined) { + const refScale = scaleRef > 0 ? scaleRef : cameraScale; + const screenPx = + style.pointRadiusBasePx * + Math.pow(cameraScale / refScale, PLANET_SIZE_ZOOM_ALPHA); + return screenPx / cameraScale; } const px = style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX; - if (cameraScale <= 0) return px; return px / cameraScale; } diff --git a/ui/frontend/tests/map-display-sizing.test.ts b/ui/frontend/tests/map-display-sizing.test.ts index 0c09206..d0ddc96 100644 --- a/ui/frontend/tests/map-display-sizing.test.ts +++ b/ui/frontend/tests/map-display-sizing.test.ts @@ -32,21 +32,24 @@ describe("displayPointRadiusWorld — pixel-space (pointRadiusPx)", () => { }); }); -describe("displayPointRadiusWorld — softened by zoom (pointRadiusWorld)", () => { - test("at scale=scaleRef the visible radius equals the base radius", () => { +describe("displayPointRadiusWorld — softened by zoom (pointRadiusBasePx)", () => { + test("at scale=scaleRef the on-screen pixel size equals the base", () => { const radius = displayPointRadiusWorld( - { pointRadiusWorld: 6 }, + { pointRadiusBasePx: 6 }, 0.2, 0.2, ); - expect(radius).toBeCloseTo(6); + // world units → 6 (base px) / 0.2 (scale) = 30 + expect(radius).toBeCloseTo(30); + // confirm pixel-space: world * scale ≈ 6. + expect(radius * 0.2).toBeCloseTo(6); }); - test("zooming in grows the radius sub-linearly", () => { - const r1 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 0.2, 0.2); - const r10 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 2.0, 0.2); - // On-screen pixel size grows by scale^α (α = 0.33) instead of - // linearly: 10x zoom → ~10^0.33 ≈ 2.15x growth. + test("zooming in grows the on-screen pixel size sub-linearly", () => { + const r1 = displayPointRadiusWorld({ pointRadiusBasePx: 6 }, 0.2, 0.2); + const r10 = displayPointRadiusWorld({ pointRadiusBasePx: 6 }, 2.0, 0.2); + // On-screen pixel size grows by scale^α (α = 0.33): 10x zoom + // → 10^0.33 ≈ 2.15x growth. const onScreenAt1 = r1 * 0.2; const onScreenAt10 = r10 * 2.0; expect(onScreenAt10 / onScreenAt1).toBeCloseTo( @@ -55,14 +58,16 @@ describe("displayPointRadiusWorld — softened by zoom (pointRadiusWorld)", () = ); }); - test("ignores pointRadiusPx when pointRadiusWorld is set", () => { + test("ignores pointRadiusPx when pointRadiusBasePx is set", () => { const r = displayPointRadiusWorld( - { pointRadiusPx: 99, pointRadiusWorld: 4 }, + { pointRadiusPx: 99, pointRadiusBasePx: 4 }, 0.4, 0.2, ); - // World radius is the base softened by (0.4/0.2)^(α-1). - expect(r).toBeCloseTo(4 * Math.pow(2, PLANET_SIZE_ZOOM_ALPHA - 1), 4); + // On-screen pixel size: 4 * (0.4 / 0.2)^α = 4 * 2^0.33 + // In world units: (4 * 2^0.33) / 0.4. + const expected = (4 * Math.pow(2, PLANET_SIZE_ZOOM_ALPHA)) / 0.4; + expect(r).toBeCloseTo(expected, 4); }); }); diff --git a/ui/frontend/tests/map-hit-test.test.ts b/ui/frontend/tests/map-hit-test.test.ts index 1fd03ea..3458549 100644 --- a/ui/frontend/tests/map-hit-test.test.ts +++ b/ui/frontend/tests/map-hit-test.test.ts @@ -280,20 +280,19 @@ describe("hitTest — empty results and scale", () => { expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null); }); - test("pointRadiusWorld scales softly with zoom (F8-12 / #31)", () => { - // world 1000×1000, viewport 200×200 → scaleRef = 0.2 (every - // world unit becomes 0.2 px on screen at the "whole world fits" - // zoom). PLANET_SIZE_ZOOM_ALPHA is 0.33: r_display = - // r_base * (scale / scaleRef)^(α - 1). + test("pointRadiusBasePx scales softly with zoom (F8-12 / #31)", () => { + // world 1000×1000, viewport 200×200 → scaleRef = 0.2. At + // scale=0.5 the on-screen pixel size is + // basePx * (scale/scaleRef)^α + // → 6 * (0.5/0.2)^0.33 ≈ 6 * 1.354 ≈ 8.13 px. In world units + // that becomes ≈ 16.27, plus slop 4/0.5 = 8 → threshold ≈ 24.27. const cam05 = camAt(500, 500, 0.5); const wBase = new World(1000, 1000, [ - point(1, 500, 500, { style: { pointRadiusWorld: 6 } }), + point(1, 500, 500, { style: { pointRadiusBasePx: 6 } }), ]); - // At scale=0.5 the softening factor is (0.5/0.2)^(0.33-1) ≈ 0.554. - // Visible radius ≈ 3.32 world units, slop 8, threshold ≈ 11.32. - expect(ids(wBase, "torus", cam05, cursorOver(510, 500, cam05))).toBe(1); - // Cursor 12 world units away exceeds the threshold. - expect(ids(wBase, "torus", cam05, cursorOver(512, 500, cam05))).toBe(null); + expect(ids(wBase, "torus", cam05, cursorOver(520, 500, cam05))).toBe(1); + // Cursor 26 world units away exceeds the threshold (~24.27). + expect(ids(wBase, "torus", cam05, cursorOver(526, 500, cam05))).toBe(null); }); });