feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55) #70
+141
-31
@@ -515,6 +515,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
// `setVisibilityFog` runs. We track the dispatched ops only
|
// `setVisibilityFog` runs. We track the dispatched ops only
|
||||||
// implicitly via the layer's children; on every flip we drop
|
// implicitly via the layer's children; on every flip we drop
|
||||||
// the previous children and rebuild from the new op list.
|
// 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 applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
|
||||||
const visible = !hiddenIds.has(id);
|
const visible = !hiddenIds.has(id);
|
||||||
for (const g of list) g.visible = visible;
|
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 => {
|
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
||||||
for (const c of copies) {
|
for (const c of copies) {
|
||||||
const g = new Graphics();
|
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);
|
drawPrimitiveInto(prim, g);
|
||||||
c.addChild(g);
|
c.addChild(g);
|
||||||
let list = primitiveGraphics.get(prim.id);
|
let list = primitiveGraphics.get(prim.id);
|
||||||
@@ -650,29 +672,22 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
currentPlanetSetFingerprint = null;
|
currentPlanetSetFingerprint = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
|
// `paintLabelLayout` keeps the label container's hit area + line
|
||||||
// Text colours flip on selection so the legend reads on the
|
// positions in sync with the current text visibility / content. It
|
||||||
// inverse-fill frame.
|
// 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 =
|
const nameVisible =
|
||||||
entry.nameText !== null && entry.nameText.visible;
|
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 nameHeight = nameVisible ? entry.nameText!.height : 0;
|
||||||
const numberHeight = entry.numberText.height;
|
const numberHeight = entry.numberText.height;
|
||||||
const totalTextHeight =
|
const totalTextHeight =
|
||||||
nameHeight + (nameVisible ? LABEL_LINE_GAP_PX : 0) + numberHeight;
|
nameHeight + (nameVisible ? LABEL_LINE_GAP_PX : 0) + numberHeight;
|
||||||
entry.numberText.y = nameVisible ? 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
|
|
||||||
// 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(
|
const widestText = Math.max(
|
||||||
nameVisible ? entry.nameText!.width : 0,
|
nameVisible ? entry.nameText!.width : 0,
|
||||||
entry.numberText.width,
|
entry.numberText.width,
|
||||||
@@ -685,24 +700,85 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
hitWidth,
|
hitWidth,
|
||||||
hitHeight,
|
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) {
|
if (!isSelected) {
|
||||||
entry.frame.visible = false;
|
if (entry.frame.visible) {
|
||||||
|
entry.frame.clear();
|
||||||
|
entry.frame.visible = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const frameWidth = hitWidth;
|
// Selected — make sure the frame matches the current layout
|
||||||
const frameHeight = hitHeight;
|
// 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(
|
entry.frame.roundRect(
|
||||||
-frameWidth / 2,
|
-frameW / 2,
|
||||||
-LABEL_FRAME_PADDING_PX,
|
-LABEL_FRAME_PADDING_PX,
|
||||||
frameWidth,
|
frameW,
|
||||||
frameHeight,
|
frameH,
|
||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
entry.frame.fill({ color: theme.labelInverseBackground, alpha: 0.95 });
|
entry.frame.fill({
|
||||||
|
color: theme.labelInverseBackground,
|
||||||
|
alpha: 0.95,
|
||||||
|
});
|
||||||
entry.frame.visible = true;
|
entry.frame.visible = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
|
||||||
|
paintLabelLayout(entry);
|
||||||
|
paintLabelSelection(entry, isSelected);
|
||||||
|
};
|
||||||
|
|
||||||
const updateLabelTransforms = (): void => {
|
const updateLabelTransforms = (): void => {
|
||||||
const cameraScale = viewport.scaled;
|
const cameraScale = viewport.scaled;
|
||||||
if (cameraScale <= 0) return;
|
if (cameraScale <= 0) return;
|
||||||
@@ -818,6 +894,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
const list: Graphics[] = [];
|
const list: Graphics[] = [];
|
||||||
for (const layer of outlineLayers) {
|
for (const layer of outlineLayers) {
|
||||||
const g = new Graphics();
|
const g = new Graphics();
|
||||||
|
g.cullable = true;
|
||||||
layer.addChild(g);
|
layer.addChild(g);
|
||||||
list.push(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
|
// Same planet set, only the per-row text changed (almost
|
||||||
// always the F8-12 / #29 `planetNames` toggle flipping name
|
// always the F8-12 / #29 `planetNames` toggle flipping name
|
||||||
// strings on / off). Rebuilding 9-copies × N labels here
|
// strings on / off). Rebuilding 9-copies × N labels here
|
||||||
// dwarfed everything else on a 500-planet map — instead, walk
|
// dwarfed everything else on a 700-planet map — instead, walk
|
||||||
// the live entries and just update text content / visibility.
|
// 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();
|
currentLabels = labels.slice();
|
||||||
|
const selectionChanged = !sameSelection;
|
||||||
for (const data of labels) {
|
for (const data of labels) {
|
||||||
const list = planetLabelInstances.get(data.planetNumber);
|
const list = planetLabelInstances.get(data.planetNumber);
|
||||||
if (list === undefined) continue;
|
if (list === undefined) continue;
|
||||||
|
const isSelected = data.planetNumber === selectedPlanetId;
|
||||||
for (const entry of list) {
|
for (const entry of list) {
|
||||||
applyLabelContent(entry, data);
|
applyLabelContent(entry, data);
|
||||||
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
paintLabelLayout(entry);
|
||||||
|
if (selectionChanged) {
|
||||||
|
paintLabelSelection(entry, isSelected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentLabelsFingerprint = fp;
|
currentLabelsFingerprint = fp;
|
||||||
@@ -890,6 +976,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
const list: LabelGfx[] = [];
|
const list: LabelGfx[] = [];
|
||||||
for (const layer of labelLayers) {
|
for (const layer of labelLayers) {
|
||||||
const container = new Container();
|
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();
|
const frame = new Graphics();
|
||||||
frame.visible = false;
|
frame.visible = false;
|
||||||
container.addChild(frame);
|
container.addChild(frame);
|
||||||
@@ -928,11 +1021,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
|
|
||||||
const applyLabelContent = (entry: LabelGfx, data: PlanetLabelData): void => {
|
const applyLabelContent = (entry: LabelGfx, data: PlanetLabelData): void => {
|
||||||
if (entry.nameText !== null) {
|
if (entry.nameText !== null) {
|
||||||
const desired = data.name ?? "";
|
if (data.name !== null) {
|
||||||
if (entry.nameText.text !== desired) {
|
if (entry.nameText.text !== data.name) {
|
||||||
entry.nameText.text = desired;
|
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) {
|
if (entry.numberText.text !== data.numberLabel) {
|
||||||
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),
|
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
||||||
setVisibilityFog: (circles) => {
|
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
|
// Detach the old mask before destroying its Graphics, then
|
||||||
// drop the previous fog rectangles. Every flip rebuilds from
|
// drop the previous fog rectangles. Every flip rebuilds from
|
||||||
// scratch instead of mutating in place.
|
// scratch instead of mutating in place.
|
||||||
|
|||||||
Reference in New Issue
Block a user