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