From 24d75564bb3e077cfc5155c692c5da101c4fbf53 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 28 May 2026 10:17:08 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20F8-12=20=E2=80=94=20owner=20feedback?= =?UTF-8?q?=20round=203=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Hit-test: a click inside a planet's visible disc always picks the planet, regardless of overlapping route shafts or battle X-crosses with higher base `priority`. Closes the #1, #2, #4 reports (picker hover would only catch the circumference, planet+routes swallowed disc clicks, label click on a battled planet routed to the battle viewer). Slop-only hits (cursor near a line but not on any disc) still use the existing priority order. * Labels and planet outlines render in all nine torus copies again so they follow the player into wrap tiles — closes #3 (labels vanished on the wrong half of the viewport whenever the camera was panned past the wrap seam). The fingerprint guard keeps the per-toggle / per-selection rebuild cheap. * Pixi.Text gets a few px of `padding` so the rasteriser no longer clips the last letter on a half-pixel measurement — closes #5. Co-Authored-By: Claude Opus 4.7 --- ui/frontend/src/map/hit-test.ts | 25 ++++- ui/frontend/src/map/render.ts | 170 +++++++++++++++++++------------- 2 files changed, 126 insertions(+), 69 deletions(-) diff --git a/ui/frontend/src/map/hit-test.ts b/ui/frontend/src/map/hit-test.ts index d51caed..8d10017 100644 --- a/ui/frontend/src/map/hit-test.ts +++ b/ui/frontend/src/map/hit-test.ts @@ -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; } diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 468f95a..0d50cff 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -465,19 +465,23 @@ export async function createRenderer(opts: RendererOptions): Promise { + 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(); + const planetLabelInstances = new Map(); let currentLabels: ReadonlyArray = []; let currentLabelsFingerprint: string | null = null; let currentLabelsSelectedId: number | null = null; @@ -606,6 +610,13 @@ export async function createRenderer(opts: RendererOptions): Promise { - 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(); @@ -730,8 +744,10 @@ export async function createRenderer(opts: RendererOptions): Promise { 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 { @@ -782,9 +803,13 @@ export async function createRenderer(opts: RendererOptions): Promise 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();