From f4670c1831ba12b393565d9a3c79709c1582451c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 28 May 2026 12:58:43 +0200 Subject: [PATCH] =?UTF-8?q?perf+fix(ui):=20F8-12=20=E2=80=94=20max-zoom=20?= =?UTF-8?q?clamp=20+=20planet-names=20toggle=20responsiveness=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ui/frontend/src/map/render.ts | 103 +++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index b94138d..7fb721d 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -584,6 +584,7 @@ export async function createRenderer(opts: RendererOptions): Promise = []; let currentLabelsFingerprint: string | null = null; let currentLabelsSelectedId: number | null = null; + let currentPlanetSetFingerprint: string | null = null; const fingerprintPlanetLabels = ( labels: ReadonlyArray, ): string => { @@ -593,6 +594,14 @@ export async function createRenderer(opts: RendererOptions): Promise, + ): 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_LINE_GAP_PX = 0; const LABEL_FRAME_PADDING_PX = 3; @@ -638,31 +647,34 @@ export async function createRenderer(opts: RendererOptions): Promise { // Text colours flip on selection so the legend reads on the // inverse-fill frame. + const nameVisible = + entry.nameText !== null && entry.nameText.visible; const nameFill = isSelected ? theme.labelInverseText : theme.labelText; const numberFill = isSelected ? theme.labelInverseText - : entry.nameText !== null + : nameVisible ? theme.labelMuted : theme.labelText; if (entry.nameText !== null) { entry.nameText.style.fill = nameFill; } entry.numberText.style.fill = numberFill; - const nameHeight = entry.nameText?.height ?? 0; + const nameHeight = nameVisible ? entry.nameText!.height : 0; const numberHeight = entry.numberText.height; const totalTextHeight = - nameHeight + (entry.nameText !== null ? LABEL_LINE_GAP_PX : 0) + numberHeight; - entry.numberText.y = entry.nameText !== null ? nameHeight + LABEL_LINE_GAP_PX : 0; + nameHeight + (nameVisible ? LABEL_LINE_GAP_PX : 0) + numberHeight; + entry.numberText.y = nameVisible ? nameHeight + LABEL_LINE_GAP_PX : 0; // Refresh the click hit area to match the label's bounding box // plus the padding from the selection frame (so the player can // click anywhere inside the visible legend, not just the glyphs). const widestText = Math.max( - entry.nameText?.width ?? 0, + nameVisible ? entry.nameText!.width : 0, entry.numberText.width, ); const hitWidth = widestText + LABEL_FRAME_PADDING_PX * 2; @@ -846,10 +858,33 @@ export async function createRenderer(opts: RendererOptions): Promise { + 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 prim = pointPrimitivesById.get(planetNumber); if (prim === undefined) return; @@ -959,15 +1008,24 @@ export async function createRenderer(opts: RendererOptions): Promise maxScale) viewport.setZoom(maxScale, true); if (newMode === "no-wrap") { viewport.clamp({ direction: "all" }); viewport.on("moved", enforceCentreWhenLarger); @@ -1451,10 +1509,12 @@ export async function createRenderer(opts: RendererOptions): Promise maxScale) viewport.setZoom(maxScale, true); if (mode === "no-wrap") { enforceCentreWhenLarger(); } @@ -1534,6 +1594,23 @@ function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" { */ 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( g: Graphics, x: number,