feat(ui): F8-12 — owner feedback round 2 (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s

* 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 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-28 09:40:20 +02:00
parent 6c3cd25476
commit eb5018342e
10 changed files with 334 additions and 61 deletions
+65
View File
@@ -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,