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
2 changed files with 126 additions and 69 deletions
Showing only changes of commit 24d75564bb - Show all commits
+24 -1
View File
@@ -32,6 +32,16 @@ import {
export interface Hit {
primitive: Primitive;
distSq: number; // in world units squared
/**
* insideDisc is `true` when the cursor sits *inside* a planet's
* visible disc (point primitive, distance ≤ `visibleRadius`, no
* slop required). F8-12 / #4 follow-up uses it to break tie
* against overlapping route shafts or battle X-crosses: a click
* inside the disc always picks the planet, even though those
* other primitives carry a higher base `priority` (so they still
* win when the cursor is "near the line, outside any planet").
*/
insideDisc: boolean;
}
// hitTest returns the best-matching primitive under the cursor, or
@@ -62,6 +72,7 @@ export function hitTest(
const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind];
const slopWorld = slopPx / camera.scale;
let result: number | null;
let insideDisc = false;
if (p.kind === "point") {
const visibleRadius = displayPointRadiusWorld(
p.style,
@@ -75,6 +86,9 @@ export function hitTest(
slopWorld,
mode === "torus" ? world : null,
);
if (result !== null) {
insideDisc = result <= visibleRadius * visibleRadius;
}
} else if (p.kind === "circle") {
result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
} else {
@@ -88,7 +102,7 @@ export function hitTest(
);
}
if (result !== null) {
candidates.push({ primitive: p, distSq: result });
candidates.push({ primitive: p, distSq: result, insideDisc });
}
}
@@ -98,6 +112,15 @@ export function hitTest(
}
function compareHits(a: Hit, b: Hit): number {
// F8-12 / #4 follow-up: a click that sits *inside* a planet disc
// always picks the planet, even when a route shaft or a battle
// X-cross with a higher `priority` overlaps it. The base priority
// tie-break still rules every "near a line, outside any disc"
// case so battle markers / cargo arrows remain clickable in the
// gap between planets.
if (a.insideDisc !== b.insideDisc) {
return a.insideDisc ? -1 : 1;
}
if (a.primitive.priority !== b.primitive.priority) {
return b.primitive.priority - a.primitive.priority;
}
+102 -68
View File
@@ -465,19 +465,23 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
return c;
});
// Outline + label layers (F8-12 / #29 + #30). Both live in the
// origin copy only — replicating Pixi.Text / Graphics across all
// nine torus copies is the dominant cost on a 100+ planet map,
// and the player almost never sees a label "wrap" out of the
// central tile because the camera-wrap listener snaps the centre
// back into `[0, W) × [0, H)` whenever it walks past the seam.
// Outlines sit between the primitive disc and the labels so the
// stroke reads against the planet fill while staying below the
// textual layer.
const outlineLayer = new Container();
const labelLayer = new Container();
copies[ORIGIN_COPY_INDEX].addChild(outlineLayer);
copies[ORIGIN_COPY_INDEX].addChild(labelLayer);
// Outline + label layers (F8-12 / #29 + #30). One layer per torus
// copy so the textual legend / selection frame / bombing outline
// stay glued to whichever wrap tile the player is panned over.
// Performance is reasonable because `setPlanetLabels` /
// `setPlanetOutlines` skip the rebuild on no-data-change via the
// fingerprint guard, and the per-zoom transform update is just an
// `x/y/scale` assignment per entry.
const outlineLayers: Container[] = copies.map((c) => {
const layer = new Container();
c.addChild(layer);
return layer;
});
const labelLayers: Container[] = copies.map((c) => {
const layer = new Container();
c.addChild(layer);
return layer;
});
// Per-id `Graphics` lookup. Each primitive lives in nine copies
// (one per torus tile); pick-mode dims them by id, so the lookup
@@ -566,17 +570,17 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}
// Planet label state (F8-12 / #29 + #30). One Container per
// planet, anchored at the planet's `(x, y)` in the origin copy.
// `currentLabels` mirrors the dataset last passed into
// `setPlanetLabels` so a zoom-driven transform update does not
// need a fresh report.
// planet per torus copy, anchored at the planet's `(x, y)` in
// each tile. `currentLabels` mirrors the dataset last passed
// into `setPlanetLabels` so a zoom-driven transform update does
// not need a fresh report.
interface LabelGfx {
readonly container: Container;
readonly frame: Graphics;
readonly nameText: Text | null;
readonly numberText: Text;
}
const planetLabelInstances = new Map<number, LabelGfx>();
const planetLabelInstances = new Map<number, LabelGfx[]>();
let currentLabels: ReadonlyArray<PlanetLabelData> = [];
let currentLabelsFingerprint: string | null = null;
let currentLabelsSelectedId: number | null = null;
@@ -606,6 +610,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
fontSize: LABEL_FONT_SIZE_PX,
fill: fillColor,
align: "center",
// F8-12 / #5 fix: Pixi's Text rasteriser sometimes clips
// the last glyph by a subpixel when the texture bounds
// are computed from the un-padded measurement. A few
// pixels of `padding` give the rasteriser enough room
// to render the right edge cleanly without changing
// the text's anchor or position.
padding: 2,
},
});
t.anchor.set(0.5, 0);
@@ -621,8 +632,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
};
const clearAllLabels = (): void => {
for (const entry of planetLabelInstances.values()) {
disposeLabelGfx(entry);
for (const list of planetLabelInstances.values()) {
for (const entry of list) disposeLabelGfx(entry);
}
planetLabelInstances.clear();
currentLabelsFingerprint = null;
@@ -686,8 +697,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const labelScale = 1 / cameraScale;
const gapWorld = LABEL_OFFSET_PX / cameraScale;
for (const data of currentLabels) {
const entry = planetLabelInstances.get(data.planetNumber);
if (entry === undefined) continue;
const list = planetLabelInstances.get(data.planetNumber);
if (list === undefined) continue;
const planetPrim = pointPrimitivesById.get(data.planetNumber);
const visibleRadius =
planetPrim === undefined
@@ -697,9 +708,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
cameraScale,
currentScaleRef,
);
entry.container.x = data.x;
entry.container.y = data.y + visibleRadius + gapWorld;
entry.container.scale.set(labelScale);
const anchorY = data.y + visibleRadius + gapWorld;
for (const entry of list) {
entry.container.x = data.x;
entry.container.y = anchorY;
entry.container.scale.set(labelScale);
}
}
};
@@ -711,7 +725,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// (`pointRadiusBasePx` softening) or stay constant
// (`pointRadiusPx` pixel-space).
interface PlanetOutlineGfx {
readonly graphics: Graphics;
readonly graphics: Graphics[];
readonly spec: PlanetOutlineSpec;
}
const planetOutlineInstances = new Map<number, PlanetOutlineGfx>();
@@ -730,8 +744,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const clearAllOutlines = (): void => {
for (const entry of planetOutlineInstances.values()) {
entry.graphics.parent?.removeChild(entry.graphics);
entry.graphics.destroy();
for (const g of entry.graphics) {
g.parent?.removeChild(g);
g.destroy();
}
}
planetOutlineInstances.clear();
currentOutlinesFingerprint = null;
@@ -741,8 +757,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const cameraScale = viewport.scaled;
if (cameraScale <= 0) return;
const planetPrim = pointPrimitivesById.get(entry.spec.planetNumber);
entry.graphics.clear();
if (planetPrim === undefined) return;
if (planetPrim === undefined) {
for (const g of entry.graphics) g.clear();
return;
}
const visibleRadius = displayPointRadiusWorld(
planetPrim.style,
cameraScale,
@@ -752,12 +770,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const widthWorld =
(entry.spec.widthPx ?? OUTLINE_DEFAULT_WIDTH_PX) / cameraScale;
const outlineRadius = visibleRadius + paddingWorld;
entry.graphics.circle(planetPrim.x, planetPrim.y, outlineRadius);
entry.graphics.stroke({
color: entry.spec.color,
alpha: 0.95,
width: widthWorld,
});
for (const g of entry.graphics) {
g.clear();
g.circle(planetPrim.x, planetPrim.y, outlineRadius);
g.stroke({
color: entry.spec.color,
alpha: 0.95,
width: widthWorld,
});
}
};
const updateOutlineTransforms = (): void => {
@@ -782,9 +803,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
clearAllOutlines();
currentOutlinesFingerprint = fp;
for (const spec of outlines) {
const g = new Graphics();
outlineLayer.addChild(g);
const entry: PlanetOutlineGfx = { graphics: g, spec };
const list: Graphics[] = [];
for (const layer of outlineLayers) {
const g = new Graphics();
layer.addChild(g);
list.push(g);
}
const entry: PlanetOutlineGfx = { graphics: list, spec };
planetOutlineInstances.set(spec.planetNumber, entry);
paintOutlineEntry(entry);
}
@@ -810,9 +835,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// flips. Repaint the affected entries instead of rebuilding.
currentLabels = labels.slice();
for (const data of labels) {
const entry = planetLabelInstances.get(data.planetNumber);
if (entry === undefined) continue;
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
const list = planetLabelInstances.get(data.planetNumber);
if (list === undefined) continue;
for (const entry of list) {
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
}
}
currentLabelsSelectedId = selectedPlanetId;
updateLabelTransforms();
@@ -824,33 +851,40 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
currentLabelsFingerprint = fp;
currentLabelsSelectedId = selectedPlanetId;
for (const data of labels) {
const container = new Container();
const frame = new Graphics();
frame.visible = false;
container.addChild(frame);
const nameText =
data.name === null
? null
: buildLabelText(data.name, theme.labelText);
if (nameText !== null) container.addChild(nameText);
const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
container.addChild(numberText);
labelLayer.addChild(container);
const entry: LabelGfx = {
container,
frame,
nameText,
numberText,
};
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
planetLabelInstances.set(data.planetNumber, entry);
// F8-12 / #3 follow-up: a click on the label routes through
// the same hit-test path as a click on the disc so selection
// and pick-mode both fire the right callback.
const targetPlanet = data.planetNumber;
container.eventMode = "static";
container.cursor = "pointer";
container.on("pointertap", () => simulatePlanetClick(targetPlanet));
const list: LabelGfx[] = [];
for (const layer of labelLayers) {
const container = new Container();
const frame = new Graphics();
frame.visible = false;
container.addChild(frame);
const nameText =
data.name === null
? null
: buildLabelText(data.name, theme.labelText);
if (nameText !== null) container.addChild(nameText);
const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
container.addChild(numberText);
layer.addChild(container);
const entry: LabelGfx = {
container,
frame,
nameText,
numberText,
};
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
// F8-12 / #3 follow-up: a click on the label routes
// through the same hit-test path as a click on the
// disc so selection and pick-mode both fire the right
// callback.
container.eventMode = "static";
container.cursor = "pointer";
container.on("pointertap", () =>
simulatePlanetClick(targetPlanet),
);
list.push(entry);
}
planetLabelInstances.set(targetPlanet, list);
}
updateLabelTransforms();
requestRender();