From eb5018342ebc17b43d314e1c58ed8376b883205d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 28 May 2026 09:40:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20F8-12=20=E2=80=94=20owner=20feedbac?= =?UTF-8?q?k=20round=202=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bug fix: theme flip no longer leaves planets oversized. The camera-preserving remount now calls a new `RendererHandle.refreshCameraDerivedDraws` explicitly after the manual moveCenter/setZoom pair so the post-mount geometry tracks `viewport.scaled` even if pixi-viewport's `'zoomed'` listener races the next Ticker tick. * Doc #3: clicks on a planet label route through the same hit-test path as a click on the disc. The label `Container` now has a pointer hit area sized to the text + frame padding; pointertap simulates a click at the planet centre, so selection and pick-mode resolution behave identically. * Doc #4: battle X-crosses + cargo arrowhead wings grow sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New `Style.softLengthAnchor` ('center' / 'start') makes the renderer treat the recorded endpoints as the geometry "at the reference scale" and rescale around the midpoint (X-cross) or the start endpoint (arrow wings). Arrowhead base length is halved from 6 to 3 world units to match the owner's "in half" request. * Doc #5: picker overlay loses the anchor ring at the source, the cursor line drops to a cargo-route-thin 0.6 px stroke, and the hover ring around the destination is replaced by a planet-style outline (visible disc + 1 px padding) in the `pickHighlight` accent — so candidate destinations read like selection in warm yellow. * Doc #6: regression test pins the in-disc hit zone. * Perf #1: camera-driven redraws are throttled onto the next Ticker tick. A rapid wheel / pinch burst now coalesces into at most one `clear() + redraw` pass per painted frame, which keeps the 500-planet map responsive on zoom and toggle flips. Co-Authored-By: Claude Opus 4.7 --- ui/frontend/src/lib/active-view/map.svelte | 8 ++ ui/frontend/src/map/battle-markers.ts | 5 + ui/frontend/src/map/cargo-routes.ts | 79 +++++++++++--- ui/frontend/src/map/hit-test.ts | 36 ++++++- ui/frontend/src/map/pick-mode.ts | 27 +++-- ui/frontend/src/map/render.ts | 120 +++++++++++++++++---- ui/frontend/src/map/world.ts | 65 +++++++++++ ui/frontend/tests/map-cargo-routes.test.ts | 30 ++++-- ui/frontend/tests/map-hit-test.test.ts | 15 +++ ui/frontend/tests/map-pick-mode.test.ts | 10 +- 10 files changed, 334 insertions(+), 61 deletions(-) diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 8a321b4..8b60519 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -599,6 +599,14 @@ preference the store already manages. handle.viewport.moveCenter(world.width / 2, world.height / 2); handle.viewport.setZoom(minScale * 1.05, true); } + // `viewport.setZoom` emits `zoomed` through the next Ticker + // tick, but the handler can race the synchronous setExtras / + // label / outline calls that follow — and a theme-flip + // remount has been observed to leave primitives drawn at the + // boot scale until the user nudges the wheel. Force the + // camera-derived redraw explicitly here so the post-mount + // state always matches `viewport.scaled`. + handle.refreshCameraDerivedDraws(); if (mode === "no-wrap") handle.setMode("no-wrap"); detachClick = handle.onClick(handleMapClick); pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => { diff --git a/ui/frontend/src/map/battle-markers.ts b/ui/frontend/src/map/battle-markers.ts index 3d5eed3..b5dc107 100644 --- a/ui/frontend/src/map/battle-markers.ts +++ b/ui/frontend/src/map/battle-markers.ts @@ -117,6 +117,11 @@ export function buildBattleAndBombingMarkers( strokeColor: theme.battleMarker, strokeAlpha: 0.95, strokeWidthPx, + // F8-12 / #4 follow-up: grow the X-cross length sub-linearly + // with zoom (the planet disc does the same, so the marker + // stays proportional). Endpoints listed below are the "at + // reference scale" geometry. + softLengthAnchor: "center", }; const baseId = BATTLE_MARKER_ID_PREFIX | (i << 4); const lineA: LinePrim = { diff --git a/ui/frontend/src/map/cargo-routes.ts b/ui/frontend/src/map/cargo-routes.ts index 8c943b9..0ad45c0 100644 --- a/ui/frontend/src/map/cargo-routes.ts +++ b/ui/frontend/src/map/cargo-routes.ts @@ -20,13 +20,64 @@ import { DARK_THEME, type LinePrim, type PrimitiveID, type Style, type Theme } f * active theme. A single `Style` object is shared by every line of a * given load type within one call so the renderer can dedupe them. */ -function routeStylesByLoadType(theme: Theme): Record { - return { - COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 0.6 }, - CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 0.6 }, - MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 0.6 }, - EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 0.4 }, +function routeStylesByLoadType( + theme: Theme, +): Record { + const styles: Record = { + COL: { + shaft: { + strokeColor: theme.routeCol, + strokeAlpha: 0.95, + strokeWidthPx: 0.6, + }, + wing: { + strokeColor: theme.routeCol, + strokeAlpha: 0.95, + strokeWidthPx: 0.6, + softLengthAnchor: "start", + }, + }, + CAP: { + shaft: { + strokeColor: theme.routeCap, + strokeAlpha: 0.95, + strokeWidthPx: 0.6, + }, + wing: { + strokeColor: theme.routeCap, + strokeAlpha: 0.95, + strokeWidthPx: 0.6, + softLengthAnchor: "start", + }, + }, + MAT: { + shaft: { + strokeColor: theme.routeMat, + strokeAlpha: 0.95, + strokeWidthPx: 0.6, + }, + wing: { + strokeColor: theme.routeMat, + strokeAlpha: 0.95, + strokeWidthPx: 0.6, + softLengthAnchor: "start", + }, + }, + EMP: { + shaft: { + strokeColor: theme.routeEmp, + strokeAlpha: 0.85, + strokeWidthPx: 0.4, + }, + wing: { + strokeColor: theme.routeEmp, + strokeAlpha: 0.85, + strokeWidthPx: 0.4, + softLengthAnchor: "start", + }, + }, }; + return styles; } /** Per-load-type priority. Higher wins hit-test ties; planets sit @@ -59,9 +110,11 @@ const SHAFT_OFFSET = 0; const WING_LEFT_OFFSET = 1; const WING_RIGHT_OFFSET = 2; -/** Arrowhead size in world units. Picked so the head is visible - * at default zoom but does not eat the destination planet glyph. */ -const HEAD_LENGTH_WORLD = 6; +/** Arrowhead size in world units **at the reference zoom**. F8-12 / + * #4 follow-up halved the head from 6 → 3 world units and added + * `softLengthAnchor: "start"` so the wings grow sub-linearly with + * zoom instead of stretching across the whole approach. */ +const HEAD_LENGTH_WORLD = 3; /** Half-angle of the arrowhead opening, in radians (~25°). */ const HEAD_HALF_ANGLE = (25 * Math.PI) / 180; @@ -122,13 +175,13 @@ export function buildCargoRouteLines( route.sourcePlanetNumber, entry.loadType, ); - const style = styleByLoadType[entry.loadType]; + const styles = styleByLoadType[entry.loadType]; const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType]; lines.push({ kind: "line", id: baseId + SHAFT_OFFSET, priority, - style, + style: styles.shaft, hitSlopPx: 0, x1: source.x, y1: source.y, @@ -139,7 +192,7 @@ export function buildCargoRouteLines( kind: "line", id: baseId + WING_LEFT_OFFSET, priority, - style, + style: styles.wing, hitSlopPx: 0, x1: headX, y1: headY, @@ -150,7 +203,7 @@ export function buildCargoRouteLines( kind: "line", id: baseId + WING_RIGHT_OFFSET, priority, - style, + style: styles.wing, hitSlopPx: 0, x1: headX, y1: headY, diff --git a/ui/frontend/src/map/hit-test.ts b/ui/frontend/src/map/hit-test.ts index 6d9b2f0..d51caed 100644 --- a/ui/frontend/src/map/hit-test.ts +++ b/ui/frontend/src/map/hit-test.ts @@ -15,6 +15,7 @@ import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math" import { minScaleNoWrap } from "./no-wrap"; import { DEFAULT_HIT_SLOP_PX, + displayLineEndpoints, displayPointRadiusWorld, KIND_ORDER, type Camera, @@ -77,7 +78,14 @@ export function hitTest( } else if (p.kind === "circle") { result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null); } else { - result = matchLine(p, cursor, slopWorld, mode === "torus" ? world : null); + result = matchLine( + p, + cursor, + slopWorld, + camera.scale, + scaleRef, + mode === "torus" ? world : null, + ); } if (result !== null) { candidates.push({ primitive: p, distSq: result }); @@ -163,6 +171,8 @@ function matchLine( p: LinePrim, cursor: { x: number; y: number }, slopWorld: number, + cameraScale: number, + scaleRef: number, world: World | null, ): number | null { // In torus mode the canonical line representation goes from @@ -170,14 +180,30 @@ function matchLine( // shortest delta from end1 to end2. The cursor's distance is // then the perpendicular distance to this canonical segment, // using the torus-shortest cursor-to-end1 delta as the basis. + const ends = displayLineEndpoints( + p.style, + p.x1, + p.y1, + p.x2, + p.y2, + cameraScale, + scaleRef, + ); if (world === null) { - const distSq = distSqPointToSegment(cursor.x, cursor.y, p.x1, p.y1, p.x2, p.y2); + const distSq = distSqPointToSegment( + cursor.x, + cursor.y, + ends.x1, + ends.y1, + ends.x2, + ends.y2, + ); if (distSq <= slopWorld * slopWorld) return distSq; return null; } - const segDx = torusShortestDelta(p.x1, p.x2, world.width); - const segDy = torusShortestDelta(p.y1, p.y2, world.height); - const cur = torusDelta(p.x1, p.y1, cursor.x, cursor.y, world); + const segDx = torusShortestDelta(ends.x1, ends.x2, world.width); + const segDy = torusShortestDelta(ends.y1, ends.y2, world.height); + const cur = torusDelta(ends.x1, ends.y1, cursor.x, cursor.y, world); const distSq = distSqPointToSegment(cur.dx, cur.dy, 0, 0, segDx, segDy); if (distSq <= slopWorld * slopWorld) return distSq; return null; diff --git a/ui/frontend/src/map/pick-mode.ts b/ui/frontend/src/map/pick-mode.ts index 6f22c7b..3a5ebb8 100644 --- a/ui/frontend/src/map/pick-mode.ts +++ b/ui/frontend/src/map/pick-mode.ts @@ -83,10 +83,17 @@ export interface PickOverlaySpec { readonly dimmedIds: ReadonlySet; } -/** Anchor / hover outline padding in world units (the rings sit - * outside the visible disc so the planet stays clearly visible). */ +/** Anchor / hover outline padding. F8-12 / #5 retired the anchor + * ring from the picker overlay, so `ANCHOR_PADDING_WORLD` is now + * dead — kept exported for legacy test coverage that asserts the + * spec stays shaped the same way. `HOVER_PADDING_PX` is the + * screen-pixel gap the picker hover-ring leaves between the + * destination disc edge and the stroke; it matches the regular + * planet outline (`OUTLINE_RADIUS_PADDING_PX` in `render.ts`) so + * "selection" and "pick hover" outlines feel identical at every + * zoom. */ export const ANCHOR_PADDING_WORLD = 6; -export const HOVER_PADDING_WORLD = 4; +export const HOVER_PADDING_PX = 1; /** * computePickOverlay produces a `PickOverlaySpec` for the current @@ -173,10 +180,11 @@ export function computePickOverlay( cameraScale, scaleRef, ); + const paddingWorld = cameraScale > 0 ? HOVER_PADDING_PX / cameraScale : 0; hoverOutline = { x: target.x, y: target.y, - radius: targetRadius + HOVER_PADDING_WORLD, + radius: targetRadius + paddingWorld, }; } } @@ -208,8 +216,13 @@ export function computePickOverlay( * as obviously inert against the map background. */ export const PICK_OVERLAY_STYLE = { - anchor: { alpha: 0.9, width: 2 }, - line: { alpha: 0.5, width: 1 }, - hover: { alpha: 1, width: 2 }, + anchor: { alpha: 0.9, widthPx: 2 }, + // F8-12 / #5: cursor line uses the same screen-pixel thickness as + // a regular cargo-route shaft (0.6 px), and the hover ring around + // the destination matches the planet-outline stroke (1.5 px). The + // renderer divides by `cameraScale` before drawing so the values + // stay constant on screen at any zoom. + line: { alpha: 0.95, widthPx: 0.6 }, + hover: { alpha: 0.95, widthPx: 1.5 }, dimAlpha: 0.35, } as const; diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 4b6a201..468f95a 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -21,6 +21,7 @@ import { Application, Container, Graphics, + Rectangle, Text, Ticker, UPDATE_PRIORITY, @@ -42,6 +43,7 @@ import { import { wrapCameraTorus } from "./torus"; import { DARK_THEME, + displayLineEndpoints, displayPointRadiusWorld, displayStrokeWidthWorld, World, @@ -219,6 +221,16 @@ export interface RendererHandle { setVisibilityFog( circles: ReadonlyArray<{ x: number; y: number; radius: number }>, ): void; + /** + * refreshCameraDerivedDraws repaints every primitive / outline / + * label whose geometry depends on the current camera scale. The + * map view calls this right after a manual `viewport.moveCenter` / + * `viewport.setZoom` pair (e.g. on a theme-flip remount), where + * `pixi-viewport`'s `'zoomed'` listener can race the next Ticker + * tick and leave the scene drawn at the boot scale until the + * player nudges the wheel. + */ + refreshCameraDerivedDraws(): void; /** * setPlanetLabels replaces the on-map planet label dataset * (F8-12 / #29). Each entry is anchored to its planet's @@ -510,7 +522,7 @@ export async function createRenderer(opts: RendererOptions): Promise { @@ -635,17 +647,28 @@ export async function createRenderer(opts: RendererOptions): Promise simulatePlanetClick(targetPlanet)); } updateLabelTransforms(); requestRender(); }; + const simulatePlanetClick = (planetNumber: number): void => { + const prim = pointPrimitivesById.get(planetNumber); + if (prim === undefined) return; + const cameraScale = viewport.scaled; + if (cameraScale <= 0) return; + const screen = { + x: + viewport.screenWidth / 2 + + (prim.x - viewport.center.x) * cameraScale, + y: + viewport.screenHeight / 2 + + (prim.y - viewport.center.y) * cameraScale, + }; + handleViewportClicked({ screen }); + }; + let mode: WrapMode = opts.mode; const enforceCentreWhenLarger = (): void => { @@ -1045,24 +1091,28 @@ export async function createRenderer(opts: RendererOptions): Promise 0 ? viewport.scaled : 1; + const lineWidthWorld = PICK_OVERLAY_STYLE.line.widthPx / cameraScale; + const hoverWidthWorld = PICK_OVERLAY_STYLE.hover.widthPx / cameraScale; for (const g of pickOverlays) { g.clear(); - g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius); - g.stroke({ - color: theme.pickHighlight, - alpha: PICK_OVERLAY_STYLE.anchor.alpha, - width: PICK_OVERLAY_STYLE.anchor.width, - }); + // F8-12 / #5: anchor ring around the source is retired — + // the source planet's own outline already signals selection. if (spec.line !== null) { g.moveTo(spec.line.x1, spec.line.y1); g.lineTo(spec.line.x2, spec.line.y2); g.stroke({ color: theme.pickHighlight, alpha: PICK_OVERLAY_STYLE.line.alpha, - width: PICK_OVERLAY_STYLE.line.width, + width: lineWidthWorld, }); } if (spec.hoverOutline !== null) { + // F8-12 / #5: hover ring matches the regular planet + // outline geometry (visible disc + ~1 px padding) but + // paints in `pickHighlight` so the player sees the + // destination candidate just like a selection — only + // in the warm picker accent. g.circle( spec.hoverOutline.x, spec.hoverOutline.y, @@ -1071,7 +1121,7 @@ export async function createRenderer(opts: RendererOptions): Promise { + // safe even on a synchronous zoomed event. A rapid wheel / pinch + // burst fires many `zoomed` events per frame; we coalesce them + // onto the next Ticker tick so the expensive `clear() + redraw` + // pass runs at most once per painted frame even on a 500-planet + // map. + let zoomedRedrawPending = false; + const runCameraDerivedRedraws = (): void => { redrawAllPrimitives(); updateOutlineTransforms(); updateLabelTransforms(); redrawPickOverlay(); requestRender(); }; + const handleZoomed = (): void => { + if (zoomedRedrawPending) return; + zoomedRedrawPending = true; + Ticker.shared.addOnce(() => { + zoomedRedrawPending = false; + runCameraDerivedRedraws(); + }); + }; viewport.on("zoomed", handleZoomed); const handle: RendererHandle = { @@ -1348,6 +1411,7 @@ export async function createRenderer(opts: RendererOptions): Promise { app.renderer.resize(w, h); viewport.resize(w, h, opts.world.width, opts.world.height); @@ -1466,21 +1530,31 @@ function drawLine( p: LinePrim, theme: Theme, cameraScale: number, + scaleRef: number, ): void { const color = p.style.strokeColor ?? theme.lineStroke; const alpha = p.style.strokeAlpha ?? 1; const width = displayStrokeWidthWorld(p.style, cameraScale); + const ends = displayLineEndpoints( + p.style, + p.x1, + p.y1, + p.x2, + p.y2, + cameraScale, + scaleRef, + ); const dash = p.style.strokeDashPx; if (dash === undefined || dash <= 0) { - g.moveTo(p.x1, p.y1); - g.lineTo(p.x2, p.y2); + g.moveTo(ends.x1, ends.y1); + g.lineTo(ends.x2, ends.y2); g.stroke({ color, alpha, width }); return; } // PixiJS v8 has no native dashed-line API; segment the path into // equal-length dashes (dash and gap both `dash` units). - const dx = p.x2 - p.x1; - const dy = p.y2 - p.y1; + const dx = ends.x2 - ends.x1; + const dy = ends.y2 - ends.y1; const length = Math.hypot(dx, dy); if (length === 0) return; const ux = dx / length; @@ -1488,8 +1562,8 @@ function drawLine( const step = dash * 2; for (let t = 0; t < length; t += step) { const segEnd = Math.min(t + dash, length); - g.moveTo(p.x1 + ux * t, p.y1 + uy * t); - g.lineTo(p.x1 + ux * segEnd, p.y1 + uy * segEnd); + g.moveTo(ends.x1 + ux * t, ends.y1 + uy * t); + g.lineTo(ends.x1 + ux * segEnd, ends.y1 + uy * segEnd); } g.stroke({ color, alpha, width }); } diff --git a/ui/frontend/src/map/world.ts b/ui/frontend/src/map/world.ts index 2e51235..4f22cf2 100644 --- a/ui/frontend/src/map/world.ts +++ b/ui/frontend/src/map/world.ts @@ -47,6 +47,15 @@ export interface Style { // this for the IncomingGroup trajectory line; ignored on point // and circle primitives. strokeDashPx?: number; + // softLengthAnchor — when set on a `LinePrim`, the renderer treats + // the world-coord endpoints as the line length "at the reference + // scale" and grows / shrinks them with `PLANET_SIZE_ZOOM_ALPHA` + // the same way planet discs do. `'center'` scales both endpoints + // around the segment midpoint (used by battle X-crosses anchored + // on the planet centre); `'start'` keeps `(x1, y1)` fixed and + // only scales `(x2, y2)` along the original direction (used by + // cargo-route arrowhead wings anchored at the destination). + softLengthAnchor?: "center" | "start"; } // PrimitiveBase carries the fields shared by every primitive kind. @@ -278,6 +287,62 @@ export function displayStrokeWidthWorld( return px / cameraScale; } +/** + * softLengthFactor returns the multiplier that scales a line's + * length when `style.softLengthAnchor` is set. The factor matches + * the planet-radius softening rule: at `scale = scaleRef` it equals + * `1` (the recorded geometry is the reference length); zooming in + * shrinks the world-space length so the on-screen length grows by + * `(scale / scaleRef)^α`. `displayLineEndpoints` is the convenience + * wrapper that applies it to a line's `(x1, y1)–(x2, y2)` pair + * given the configured anchor. + */ +export function softLengthFactor( + cameraScale: number, + scaleRef: number, +): number { + if (cameraScale <= 0 || scaleRef <= 0) return 1; + return Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1); +} + +/** + * displayLineEndpoints returns the world-space endpoints the + * renderer should draw a `LinePrim` between, honouring + * `style.softLengthAnchor` if set. Used by both the renderer and + * the hit-test so the click zone always matches the visible stroke. + */ +export function displayLineEndpoints( + style: Style, + x1: number, + y1: number, + x2: number, + y2: number, + cameraScale: number, + scaleRef: number, +): { x1: number; y1: number; x2: number; y2: number } { + if (style.softLengthAnchor === undefined) { + return { x1, y1, x2, y2 }; + } + const factor = softLengthFactor(cameraScale, scaleRef); + if (factor === 1) return { x1, y1, x2, y2 }; + if (style.softLengthAnchor === "start") { + return { + x1, + y1, + x2: x1 + (x2 - x1) * factor, + y2: y1 + (y2 - y1) * factor, + }; + } + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + return { + x1: cx + (x1 - cx) * factor, + y1: cy + (y1 - cy) * factor, + x2: cx + (x2 - cx) * factor, + y2: cy + (y2 - cy) * factor, + }; +} + export const DARK_THEME: Theme = { background: 0x0a0e1a, fog: 0x12162a, diff --git a/ui/frontend/tests/map-cargo-routes.test.ts b/ui/frontend/tests/map-cargo-routes.test.ts index 740a4a0..963f5c8 100644 --- a/ui/frontend/tests/map-cargo-routes.test.ts +++ b/ui/frontend/tests/map-cargo-routes.test.ts @@ -137,17 +137,31 @@ describe("buildCargoRouteLines", () => { ); const lines = buildCargoRouteLines(report); expect(lines.length).toBe(12); - const styleByPriority = new Map(); + // F8-12 / #4 follow-up: shafts and wings now use different + // Style objects so the arrowhead wings can carry + // `softLengthAnchor: "start"`. Colour / priority remain shared + // across both, which is what the de-dupe loop here verifies. + const colourByPriority = new Map(); + const softLengthByLineId = new Map(); for (const line of lines) { - const existing = styleByPriority.get(line.priority); - if (existing === undefined) styleByPriority.set(line.priority, line.style); - else expect(existing).toBe(line.style); + const existing = colourByPriority.get(line.priority); + if (existing === undefined) { + colourByPriority.set(line.priority, line.style.strokeColor); + } else { + expect(line.style.strokeColor).toBe(existing); + } + softLengthByLineId.set(line.id & 0xf, line.style.softLengthAnchor); } + // Shaft (offset 0) stays linear; wings (offsets 1/2) get the + // new softening anchor so the arrowhead grows sub-linearly. + expect(softLengthByLineId.get(0)).toBeUndefined(); + expect(softLengthByLineId.get(1)).toBe("start"); + expect(softLengthByLineId.get(2)).toBe("start"); // Default (dark) palette colours, one per load type. - expect(styleByPriority.get(8)?.strokeColor).toBe(DARK_THEME.routeCol); - expect(styleByPriority.get(7)?.strokeColor).toBe(DARK_THEME.routeCap); - expect(styleByPriority.get(6)?.strokeColor).toBe(DARK_THEME.routeMat); - expect(styleByPriority.get(5)?.strokeColor).toBe(DARK_THEME.routeEmp); + expect(colourByPriority.get(8)).toBe(DARK_THEME.routeCol); + expect(colourByPriority.get(7)).toBe(DARK_THEME.routeCap); + expect(colourByPriority.get(6)).toBe(DARK_THEME.routeMat); + expect(colourByPriority.get(5)).toBe(DARK_THEME.routeEmp); }); test("uses the supplied palette's stroke colours", () => { diff --git a/ui/frontend/tests/map-hit-test.test.ts b/ui/frontend/tests/map-hit-test.test.ts index 3458549..06d6427 100644 --- a/ui/frontend/tests/map-hit-test.test.ts +++ b/ui/frontend/tests/map-hit-test.test.ts @@ -280,6 +280,21 @@ describe("hitTest — empty results and scale", () => { expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null); }); + test("F8-12 / #6 — clicks inside the disc hit, not just on its edge", () => { + // At scale=1 with pointRadiusBasePx=10 and scaleRef=1, the + // visible world radius is 10. Any cursor inside that disc must + // resolve to the planet — the bug owner spotted in the picker + // was the click being ignored once the cursor moved off the + // circumference toward the centre. + const camAtRef = camAt(500, 500, 1); + const w = new World(1000, 1000, [ + point(1, 500, 500, { style: { pointRadiusBasePx: 10 } }), + ]); + for (const dx of [0, 2, 5, 8, 9.5]) { + expect(ids(w, "torus", camAtRef, cursorOver(500 + dx, 500, camAtRef))).toBe(1); + } + }); + test("pointRadiusBasePx scales softly with zoom (F8-12 / #31)", () => { // world 1000×1000, viewport 200×200 → scaleRef = 0.2. At // scale=0.5 the on-screen pixel size is diff --git a/ui/frontend/tests/map-pick-mode.test.ts b/ui/frontend/tests/map-pick-mode.test.ts index 3a3f1b6..b02f944 100644 --- a/ui/frontend/tests/map-pick-mode.test.ts +++ b/ui/frontend/tests/map-pick-mode.test.ts @@ -8,7 +8,7 @@ import { describe, expect, test } from "vitest"; import { ANCHOR_PADDING_WORLD, - HOVER_PADDING_WORLD, + HOVER_PADDING_PX, computePickOverlay, type PickModeOptions, } from "../src/map/pick-mode"; @@ -206,7 +206,7 @@ describe("computePickOverlay", () => { expect(spec.hoverOutline).toEqual({ x: 200, y: 100, - radius: 5 + HOVER_PADDING_WORLD, + radius: 5 + HOVER_PADDING_PX, }); }); @@ -243,7 +243,7 @@ describe("computePickOverlay", () => { expect(spec.hoverOutline).toBeNull(); }); - test("hoverOutline reflects the reachable target with HOVER_PADDING_WORLD", () => { + test("hoverOutline reflects the reachable target with HOVER_PADDING_PX", () => { const spec = computePickOverlay( makeOptions(), { x: 1, y: 1 }, @@ -254,7 +254,7 @@ describe("computePickOverlay", () => { expect(spec.hoverOutline).toEqual({ x: 200, y: 100, - radius: 5 + HOVER_PADDING_WORLD, + radius: 5 + HOVER_PADDING_PX, }); }); @@ -267,7 +267,7 @@ describe("computePickOverlay", () => { allIds, ); expect(spec.hoverOutline?.radius).toBe( - DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_WORLD, + DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_PX, ); }); });