perf+fix(ui): F8-12 — max-zoom clamp + planet-names toggle responsiveness (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m16s

* 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>
This commit is contained in:
Ilia Denisov
2026-05-28 12:58:43 +02:00
parent 4d729c1f50
commit f4670c1831
+90 -13
View File
@@ -584,6 +584,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
let currentLabels: ReadonlyArray<PlanetLabelData> = []; let currentLabels: ReadonlyArray<PlanetLabelData> = [];
let currentLabelsFingerprint: string | null = null; let currentLabelsFingerprint: string | null = null;
let currentLabelsSelectedId: number | null = null; let currentLabelsSelectedId: number | null = null;
let currentPlanetSetFingerprint: string | null = null;
const fingerprintPlanetLabels = ( const fingerprintPlanetLabels = (
labels: ReadonlyArray<PlanetLabelData>, labels: ReadonlyArray<PlanetLabelData>,
): string => { ): string => {
@@ -593,6 +594,14 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
} }
return parts.join("|"); return parts.join("|");
}; };
const fingerprintPlanetSet = (
labels: ReadonlyArray<PlanetLabelData>,
): string => {
const ids: number[] = [];
for (const l of labels) ids.push(l.planetNumber);
ids.sort((a, b) => a - b);
return ids.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;
@@ -638,31 +647,34 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
planetLabelInstances.clear(); planetLabelInstances.clear();
currentLabelsFingerprint = null; currentLabelsFingerprint = null;
currentLabelsSelectedId = null; currentLabelsSelectedId = null;
currentPlanetSetFingerprint = null;
}; };
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => { const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
// Text colours flip on selection so the legend reads on the // Text colours flip on selection so the legend reads on the
// inverse-fill frame. // inverse-fill frame.
const nameVisible =
entry.nameText !== null && entry.nameText.visible;
const nameFill = isSelected ? theme.labelInverseText : theme.labelText; const nameFill = isSelected ? theme.labelInverseText : theme.labelText;
const numberFill = isSelected const numberFill = isSelected
? theme.labelInverseText ? theme.labelInverseText
: entry.nameText !== null : nameVisible
? theme.labelMuted ? theme.labelMuted
: theme.labelText; : theme.labelText;
if (entry.nameText !== null) { if (entry.nameText !== null) {
entry.nameText.style.fill = nameFill; entry.nameText.style.fill = nameFill;
} }
entry.numberText.style.fill = numberFill; entry.numberText.style.fill = numberFill;
const nameHeight = entry.nameText?.height ?? 0; const nameHeight = nameVisible ? entry.nameText!.height : 0;
const numberHeight = entry.numberText.height; const numberHeight = entry.numberText.height;
const totalTextHeight = const totalTextHeight =
nameHeight + (entry.nameText !== null ? LABEL_LINE_GAP_PX : 0) + numberHeight; nameHeight + (nameVisible ? LABEL_LINE_GAP_PX : 0) + numberHeight;
entry.numberText.y = entry.nameText !== null ? nameHeight + LABEL_LINE_GAP_PX : 0; entry.numberText.y = nameVisible ? nameHeight + LABEL_LINE_GAP_PX : 0;
// Refresh the click hit area to match the label's bounding box // Refresh the click hit area to match the label's bounding box
// plus the padding from the selection frame (so the player can // plus the padding from the selection frame (so the player can
// click anywhere inside the visible legend, not just the glyphs). // click anywhere inside the visible legend, not just the glyphs).
const widestText = Math.max( const widestText = Math.max(
entry.nameText?.width ?? 0, nameVisible ? entry.nameText!.width : 0,
entry.numberText.width, entry.numberText.width,
); );
const hitWidth = widestText + LABEL_FRAME_PADDING_PX * 2; const hitWidth = widestText + LABEL_FRAME_PADDING_PX * 2;
@@ -846,10 +858,33 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
requestRender(); requestRender();
return; return;
} }
const planetSetFp = fingerprintPlanetSet(labels);
if (planetSetFp === currentPlanetSetFingerprint) {
// Same planet set, only the per-row text changed (almost
// always the F8-12 / #29 `planetNames` toggle flipping name
// strings on / off). Rebuilding 9-copies × N labels here
// dwarfed everything else on a 500-planet map — instead, walk
// the live entries and just update text content / visibility.
currentLabels = labels.slice();
for (const data of labels) {
const list = planetLabelInstances.get(data.planetNumber);
if (list === undefined) continue;
for (const entry of list) {
applyLabelContent(entry, data);
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
}
}
currentLabelsFingerprint = fp;
currentLabelsSelectedId = selectedPlanetId;
updateLabelTransforms();
requestRender();
return;
}
clearAllLabels(); clearAllLabels();
currentLabels = labels.slice(); currentLabels = labels.slice();
currentLabelsFingerprint = fp; currentLabelsFingerprint = fp;
currentLabelsSelectedId = selectedPlanetId; currentLabelsSelectedId = selectedPlanetId;
currentPlanetSetFingerprint = planetSetFp;
for (const data of labels) { for (const data of labels) {
const targetPlanet = data.planetNumber; const targetPlanet = data.planetNumber;
const list: LabelGfx[] = []; const list: LabelGfx[] = [];
@@ -858,11 +893,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const frame = new Graphics(); const frame = new Graphics();
frame.visible = false; frame.visible = false;
container.addChild(frame); container.addChild(frame);
const nameText = // We always build both lines and toggle visibility for
data.name === null // the name line, so the `planetNames` flip can swap
? null // content without destroying Pixi.Text instances.
: buildLabelText(data.name, theme.labelText); const nameText = buildLabelText(data.name ?? "", theme.labelText);
if (nameText !== null) container.addChild(nameText); nameText.visible = data.name !== null;
container.addChild(nameText);
const numberText = buildLabelText(data.numberLabel, theme.labelMuted); const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
container.addChild(numberText); container.addChild(numberText);
layer.addChild(container); layer.addChild(container);
@@ -890,6 +926,19 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
requestRender(); requestRender();
}; };
const applyLabelContent = (entry: LabelGfx, data: PlanetLabelData): void => {
if (entry.nameText !== null) {
const desired = data.name ?? "";
if (entry.nameText.text !== desired) {
entry.nameText.text = desired;
}
entry.nameText.visible = data.name !== null;
}
if (entry.numberText.text !== data.numberLabel) {
entry.numberText.text = data.numberLabel;
}
};
const simulatePlanetClick = (planetNumber: number): void => { const simulatePlanetClick = (planetNumber: number): void => {
const prim = pointPrimitivesById.get(planetNumber); const prim = pointPrimitivesById.get(planetNumber);
if (prim === undefined) return; if (prim === undefined) return;
@@ -959,15 +1008,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight }, { widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
opts.world, opts.world,
); );
const maxScale = maxScaleForViewport(
viewport.screenWidth,
viewport.screenHeight,
);
currentScaleRef = minScale; currentScaleRef = minScale;
// Both modes enforce minScale on zoom-out: the world (origin // Both modes enforce minScale on zoom-out: the world (origin
// copy) always fills at least the viewport. Without this, in // copy) always fills at least the viewport. Without this, in
// torus mode the user would zoom out far enough to see the // torus mode the user would zoom out far enough to see the
// 3×3 grid of wrap copies at once; the copies are there to // 3×3 grid of wrap copies at once; the copies are there to
// fill the partial slack near a panned edge, not to be // fill the partial slack near a panned edge, not to be
// visible simultaneously. // visible simultaneously. The matching maxScale cap (F8-12
viewport.clampZoom({ minScale }); // follow-up) keeps the longest viewport axis showing at least
// `MIN_VISIBLE_WORLD_AT_MAX_ZOOM` world units of map, so the
// player can never zoom past the point where individual
// glyphs become uselessly large.
viewport.clampZoom({ minScale, maxScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (viewport.scaled > maxScale) viewport.setZoom(maxScale, true);
if (newMode === "no-wrap") { if (newMode === "no-wrap") {
viewport.clamp({ direction: "all" }); viewport.clamp({ direction: "all" });
viewport.on("moved", enforceCentreWhenLarger); viewport.on("moved", enforceCentreWhenLarger);
@@ -1451,10 +1509,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
app.renderer.resize(w, h); app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height); viewport.resize(w, h, opts.world.width, opts.world.height);
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world); const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
const maxScale = maxScaleForViewport(w, h);
currentScaleRef = minScale; currentScaleRef = minScale;
viewport.plugins.remove("clamp-zoom"); viewport.plugins.remove("clamp-zoom");
viewport.clampZoom({ minScale }); viewport.clampZoom({ minScale, maxScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (viewport.scaled > maxScale) viewport.setZoom(maxScale, true);
if (mode === "no-wrap") { if (mode === "no-wrap") {
enforceCentreWhenLarger(); enforceCentreWhenLarger();
} }
@@ -1534,6 +1594,23 @@ function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" {
*/ */
const SMOOTH_CIRCLE_SEGMENTS = 32; const SMOOTH_CIRCLE_SEGMENTS = 32;
/**
* MIN_VISIBLE_WORLD_AT_MAX_ZOOM caps zoom-in so the longest viewport
* axis never covers fewer than this many world units. Tuned on the
* F8-12 owner-feedback round: 5 keeps individual planet glyphs from
* filling half the screen on either mobile (longest ≈ 412 px → max
* scale ≈ 82) or desktop (longest ≈ 1200 px → max scale ≈ 240) — the
* "useful detail" budget at full zoom is still a 5×5-world tile in
* the centre of the viewport.
*/
const MIN_VISIBLE_WORLD_AT_MAX_ZOOM = 5;
function maxScaleForViewport(widthPx: number, heightPx: number): number {
const longest = Math.max(widthPx, heightPx);
if (longest <= 0) return Number.POSITIVE_INFINITY;
return longest / MIN_VISIBLE_WORLD_AT_MAX_ZOOM;
}
function traceSmoothCircle( function traceSmoothCircle(
g: Graphics, g: Graphics,
x: number, x: number,