feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55) #70

Merged
developer merged 9 commits from feature/issue-55-map-polish into development 2026-05-28 12:21:17 +00:00
Showing only changes of commit a37b784452 - Show all commits
+141 -31
View File
@@ -515,6 +515,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// `setVisibilityFog` runs. We track the dispatched ops only
// implicitly via the layer's children; on every flip we drop
// the previous children and rebuild from the new op list.
//
// `currentFogFingerprint` keeps the renderer from rebuilding the
// fog mask on a no-data-change `setVisibilityFog` call. The
// `applyVisibilityState` path in `map.svelte` runs on every
// toggle flip; for a 700-planet legacy report the destroy +
// recreate of 29 × 9 mask circles is the dominant cost of a
// `planet-names` flip (the toggle does not touch fog at all).
let currentFogFingerprint: string | null = null;
const fingerprintFogCircles = (
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
wrap: WrapMode,
): string => {
const parts: string[] = [wrap];
for (const c of circles) {
parts.push(`${c.x};${c.y};${c.radius}`);
}
return parts.join("|");
};
const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
const visible = !hiddenIds.has(id);
for (const g of list) g.visible = visible;
@@ -532,6 +550,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
for (const c of copies) {
const g = new Graphics();
// F8-12 perf round 4: cull off-screen primitives so a
// 9 × N scene graph (700 planets → 6300 disc Graphics on a
// legacy report) doesn't drag the per-frame paint cost.
g.cullable = true;
drawPrimitiveInto(prim, g);
c.addChild(g);
let list = primitiveGraphics.get(prim.id);
@@ -650,29 +672,22 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
currentPlanetSetFingerprint = null;
};
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
// Text colours flip on selection so the legend reads on the
// inverse-fill frame.
// `paintLabelLayout` keeps the label container's hit area + line
// positions in sync with the current text visibility / content. It
// never mutates `style.fill`, so Pixi does not invalidate the
// underlying Text textures — this is the path the F8-12 / #29
// `planetNames` toggle walks for all 700+ planets and absolutely
// has to stay cheap. The selection-frame update is split into
// `paintLabelSelection` below because that one *does* need to
// touch fills.
const paintLabelLayout = (entry: LabelGfx): void => {
const nameVisible =
entry.nameText !== null && entry.nameText.visible;
const nameFill = isSelected ? theme.labelInverseText : theme.labelText;
const numberFill = isSelected
? theme.labelInverseText
: nameVisible
? theme.labelMuted
: theme.labelText;
if (entry.nameText !== null) {
entry.nameText.style.fill = nameFill;
}
entry.numberText.style.fill = numberFill;
const nameHeight = nameVisible ? entry.nameText!.height : 0;
const numberHeight = entry.numberText.height;
const totalTextHeight =
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(
nameVisible ? entry.nameText!.width : 0,
entry.numberText.width,
@@ -685,24 +700,85 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
hitWidth,
hitHeight,
);
entry.frame.clear();
// Repaint the frame at the new bounds *only* when it is
// currently visible — for non-selected entries the frame stays
// hidden and we leave its empty geometry untouched.
if (entry.frame.visible) {
entry.frame.clear();
entry.frame.roundRect(
-hitWidth / 2,
-LABEL_FRAME_PADDING_PX,
hitWidth,
hitHeight,
3,
);
entry.frame.fill({
color: theme.labelInverseBackground,
alpha: 0.95,
});
}
};
// `paintLabelSelection` flips the inverse-frame on / off and
// updates the text fills. Only runs when the selection identity
// actually changes (renderer-internal "fast" path) or as part of
// the initial label mount.
const paintLabelSelection = (
entry: LabelGfx,
isSelected: boolean,
): void => {
const nameVisible =
entry.nameText !== null && entry.nameText.visible;
const nameFill = isSelected ? theme.labelInverseText : theme.labelText;
const numberFill = isSelected
? theme.labelInverseText
: nameVisible
? theme.labelMuted
: theme.labelText;
if (entry.nameText !== null && entry.nameText.style.fill !== nameFill) {
entry.nameText.style.fill = nameFill;
}
if (entry.numberText.style.fill !== numberFill) {
entry.numberText.style.fill = numberFill;
}
if (!isSelected) {
entry.frame.visible = false;
if (entry.frame.visible) {
entry.frame.clear();
entry.frame.visible = false;
}
return;
}
const frameWidth = hitWidth;
const frameHeight = hitHeight;
// Selected — make sure the frame matches the current layout
// dimensions.
const nameH = nameVisible ? entry.nameText!.height : 0;
const numberH = entry.numberText.height;
const totalH = nameH + (nameVisible ? LABEL_LINE_GAP_PX : 0) + numberH;
const widest = Math.max(
nameVisible ? entry.nameText!.width : 0,
entry.numberText.width,
);
const frameW = widest + LABEL_FRAME_PADDING_PX * 2;
const frameH = totalH + LABEL_FRAME_PADDING_PX * 2;
entry.frame.clear();
entry.frame.roundRect(
-frameWidth / 2,
-frameW / 2,
-LABEL_FRAME_PADDING_PX,
frameWidth,
frameHeight,
frameW,
frameH,
3,
);
entry.frame.fill({ color: theme.labelInverseBackground, alpha: 0.95 });
entry.frame.fill({
color: theme.labelInverseBackground,
alpha: 0.95,
});
entry.frame.visible = true;
};
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
paintLabelLayout(entry);
paintLabelSelection(entry, isSelected);
};
const updateLabelTransforms = (): void => {
const cameraScale = viewport.scaled;
if (cameraScale <= 0) return;
@@ -818,6 +894,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const list: Graphics[] = [];
for (const layer of outlineLayers) {
const g = new Graphics();
g.cullable = true;
layer.addChild(g);
list.push(g);
}
@@ -863,15 +940,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// 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.
// dwarfed everything else on a 700-planet map — instead, walk
// the live entries and just update text content / visibility
// + the cheap layout pass. Selection styling only runs when
// the selection identity actually changed (i.e. another
// branch above missed the early-out), so Pixi does not
// invalidate Text textures on every toggle flip.
currentLabels = labels.slice();
const selectionChanged = !sameSelection;
for (const data of labels) {
const list = planetLabelInstances.get(data.planetNumber);
if (list === undefined) continue;
const isSelected = data.planetNumber === selectedPlanetId;
for (const entry of list) {
applyLabelContent(entry, data);
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
paintLabelLayout(entry);
if (selectionChanged) {
paintLabelSelection(entry, isSelected);
}
}
}
currentLabelsFingerprint = fp;
@@ -890,6 +976,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const list: LabelGfx[] = [];
for (const layer of labelLayers) {
const container = new Container();
// F8-12 perf round 4: ask Pixi to skip the label when
// its bounds fall outside the renderer's visible area.
// On a 700-planet legacy report the scene graph holds
// 9 × 700 = 6300 label containers — culling shrinks
// that to whichever ones the camera is actually looking
// at, which slashes per-frame paint cost dramatically.
container.cullable = true;
const frame = new Graphics();
frame.visible = false;
container.addChild(frame);
@@ -928,11 +1021,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const applyLabelContent = (entry: LabelGfx, data: PlanetLabelData): void => {
if (entry.nameText !== null) {
const desired = data.name ?? "";
if (entry.nameText.text !== desired) {
entry.nameText.text = desired;
if (data.name !== null) {
if (entry.nameText.text !== data.name) {
entry.nameText.text = data.name;
}
entry.nameText.visible = true;
} else {
// `planetNames` toggle off: keep the original text and
// just flip visibility. Blanking the string would
// invalidate Pixi's cached text texture and force a
// re-render of all 700 + planets on the next paint,
// which was the dominant cost of the toggle flip on a
// big legacy report.
entry.nameText.visible = false;
}
entry.nameText.visible = data.name !== null;
}
if (entry.numberText.text !== data.numberLabel) {
entry.numberText.text = data.numberLabel;
@@ -1440,6 +1542,14 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
},
isPrimitiveHidden: (id) => hiddenIds.has(id),
setVisibilityFog: (circles) => {
// Same input → keep the previously-built fog around. On a
// 700-planet report the destroy + recreate of 29 × 9 mask
// circles costs north of half a second per call, and the
// effect path runs it on every toggle flip even when the
// fog itself is untouched.
const fp = fingerprintFogCircles(circles, mode);
if (fp === currentFogFingerprint) return;
currentFogFingerprint = fp;
// Detach the old mask before destroying its Graphics, then
// drop the previous fog rectangles. Every flip rebuilds from
// scratch instead of mutating in place.