perf+fix(ui): F8-12 — max-zoom clamp + planet-names toggle responsiveness (#55)
* 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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user