feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55) #70
@@ -599,6 +599,14 @@ preference the store already manages.
|
|||||||
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
||||||
handle.viewport.setZoom(minScale * 1.05, true);
|
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");
|
if (mode === "no-wrap") handle.setMode("no-wrap");
|
||||||
detachClick = handle.onClick(handleMapClick);
|
detachClick = handle.onClick(handleMapClick);
|
||||||
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
|
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ export function buildBattleAndBombingMarkers(
|
|||||||
strokeColor: theme.battleMarker,
|
strokeColor: theme.battleMarker,
|
||||||
strokeAlpha: 0.95,
|
strokeAlpha: 0.95,
|
||||||
strokeWidthPx,
|
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 baseId = BATTLE_MARKER_ID_PREFIX | (i << 4);
|
||||||
const lineA: LinePrim = {
|
const lineA: LinePrim = {
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* given load type within one call so the renderer can dedupe them.
|
||||||
*/
|
*/
|
||||||
function routeStylesByLoadType(theme: Theme): Record<CargoLoadType, Style> {
|
function routeStylesByLoadType(
|
||||||
return {
|
theme: Theme,
|
||||||
COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
): Record<CargoLoadType, { shaft: Style; wing: Style }> {
|
||||||
CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
const styles: Record<CargoLoadType, { shaft: Style; wing: Style }> = {
|
||||||
MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
COL: {
|
||||||
EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 0.4 },
|
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
|
/** 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_LEFT_OFFSET = 1;
|
||||||
const WING_RIGHT_OFFSET = 2;
|
const WING_RIGHT_OFFSET = 2;
|
||||||
|
|
||||||
/** Arrowhead size in world units. Picked so the head is visible
|
/** Arrowhead size in world units **at the reference zoom**. F8-12 /
|
||||||
* at default zoom but does not eat the destination planet glyph. */
|
* #4 follow-up halved the head from 6 → 3 world units and added
|
||||||
const HEAD_LENGTH_WORLD = 6;
|
* `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°). */
|
/** Half-angle of the arrowhead opening, in radians (~25°). */
|
||||||
const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
|
const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
|
||||||
|
|
||||||
@@ -122,13 +175,13 @@ export function buildCargoRouteLines(
|
|||||||
route.sourcePlanetNumber,
|
route.sourcePlanetNumber,
|
||||||
entry.loadType,
|
entry.loadType,
|
||||||
);
|
);
|
||||||
const style = styleByLoadType[entry.loadType];
|
const styles = styleByLoadType[entry.loadType];
|
||||||
const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType];
|
const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType];
|
||||||
lines.push({
|
lines.push({
|
||||||
kind: "line",
|
kind: "line",
|
||||||
id: baseId + SHAFT_OFFSET,
|
id: baseId + SHAFT_OFFSET,
|
||||||
priority,
|
priority,
|
||||||
style,
|
style: styles.shaft,
|
||||||
hitSlopPx: 0,
|
hitSlopPx: 0,
|
||||||
x1: source.x,
|
x1: source.x,
|
||||||
y1: source.y,
|
y1: source.y,
|
||||||
@@ -139,7 +192,7 @@ export function buildCargoRouteLines(
|
|||||||
kind: "line",
|
kind: "line",
|
||||||
id: baseId + WING_LEFT_OFFSET,
|
id: baseId + WING_LEFT_OFFSET,
|
||||||
priority,
|
priority,
|
||||||
style,
|
style: styles.wing,
|
||||||
hitSlopPx: 0,
|
hitSlopPx: 0,
|
||||||
x1: headX,
|
x1: headX,
|
||||||
y1: headY,
|
y1: headY,
|
||||||
@@ -150,7 +203,7 @@ export function buildCargoRouteLines(
|
|||||||
kind: "line",
|
kind: "line",
|
||||||
id: baseId + WING_RIGHT_OFFSET,
|
id: baseId + WING_RIGHT_OFFSET,
|
||||||
priority,
|
priority,
|
||||||
style,
|
style: styles.wing,
|
||||||
hitSlopPx: 0,
|
hitSlopPx: 0,
|
||||||
x1: headX,
|
x1: headX,
|
||||||
y1: headY,
|
y1: headY,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math"
|
|||||||
import { minScaleNoWrap } from "./no-wrap";
|
import { minScaleNoWrap } from "./no-wrap";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HIT_SLOP_PX,
|
DEFAULT_HIT_SLOP_PX,
|
||||||
|
displayLineEndpoints,
|
||||||
displayPointRadiusWorld,
|
displayPointRadiusWorld,
|
||||||
KIND_ORDER,
|
KIND_ORDER,
|
||||||
type Camera,
|
type Camera,
|
||||||
@@ -77,7 +78,14 @@ export function hitTest(
|
|||||||
} else if (p.kind === "circle") {
|
} else if (p.kind === "circle") {
|
||||||
result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
|
result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
|
||||||
} else {
|
} 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) {
|
if (result !== null) {
|
||||||
candidates.push({ primitive: p, distSq: result });
|
candidates.push({ primitive: p, distSq: result });
|
||||||
@@ -163,6 +171,8 @@ function matchLine(
|
|||||||
p: LinePrim,
|
p: LinePrim,
|
||||||
cursor: { x: number; y: number },
|
cursor: { x: number; y: number },
|
||||||
slopWorld: number,
|
slopWorld: number,
|
||||||
|
cameraScale: number,
|
||||||
|
scaleRef: number,
|
||||||
world: World | null,
|
world: World | null,
|
||||||
): number | null {
|
): number | null {
|
||||||
// In torus mode the canonical line representation goes from
|
// 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
|
// shortest delta from end1 to end2. The cursor's distance is
|
||||||
// then the perpendicular distance to this canonical segment,
|
// then the perpendicular distance to this canonical segment,
|
||||||
// using the torus-shortest cursor-to-end1 delta as the basis.
|
// 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) {
|
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;
|
if (distSq <= slopWorld * slopWorld) return distSq;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const segDx = torusShortestDelta(p.x1, p.x2, world.width);
|
const segDx = torusShortestDelta(ends.x1, ends.x2, world.width);
|
||||||
const segDy = torusShortestDelta(p.y1, p.y2, world.height);
|
const segDy = torusShortestDelta(ends.y1, ends.y2, world.height);
|
||||||
const cur = torusDelta(p.x1, p.y1, cursor.x, cursor.y, world);
|
const cur = torusDelta(ends.x1, ends.y1, cursor.x, cursor.y, world);
|
||||||
const distSq = distSqPointToSegment(cur.dx, cur.dy, 0, 0, segDx, segDy);
|
const distSq = distSqPointToSegment(cur.dx, cur.dy, 0, 0, segDx, segDy);
|
||||||
if (distSq <= slopWorld * slopWorld) return distSq;
|
if (distSq <= slopWorld * slopWorld) return distSq;
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -83,10 +83,17 @@ export interface PickOverlaySpec {
|
|||||||
readonly dimmedIds: ReadonlySet<PrimitiveID>;
|
readonly dimmedIds: ReadonlySet<PrimitiveID>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anchor / hover outline padding in world units (the rings sit
|
/** Anchor / hover outline padding. F8-12 / #5 retired the anchor
|
||||||
* outside the visible disc so the planet stays clearly visible). */
|
* 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 ANCHOR_PADDING_WORLD = 6;
|
||||||
export const HOVER_PADDING_WORLD = 4;
|
export const HOVER_PADDING_PX = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* computePickOverlay produces a `PickOverlaySpec` for the current
|
* computePickOverlay produces a `PickOverlaySpec` for the current
|
||||||
@@ -173,10 +180,11 @@ export function computePickOverlay(
|
|||||||
cameraScale,
|
cameraScale,
|
||||||
scaleRef,
|
scaleRef,
|
||||||
);
|
);
|
||||||
|
const paddingWorld = cameraScale > 0 ? HOVER_PADDING_PX / cameraScale : 0;
|
||||||
hoverOutline = {
|
hoverOutline = {
|
||||||
x: target.x,
|
x: target.x,
|
||||||
y: target.y,
|
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.
|
* as obviously inert against the map background.
|
||||||
*/
|
*/
|
||||||
export const PICK_OVERLAY_STYLE = {
|
export const PICK_OVERLAY_STYLE = {
|
||||||
anchor: { alpha: 0.9, width: 2 },
|
anchor: { alpha: 0.9, widthPx: 2 },
|
||||||
line: { alpha: 0.5, width: 1 },
|
// F8-12 / #5: cursor line uses the same screen-pixel thickness as
|
||||||
hover: { alpha: 1, width: 2 },
|
// 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,
|
dimAlpha: 0.35,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Application,
|
Application,
|
||||||
Container,
|
Container,
|
||||||
Graphics,
|
Graphics,
|
||||||
|
Rectangle,
|
||||||
Text,
|
Text,
|
||||||
Ticker,
|
Ticker,
|
||||||
UPDATE_PRIORITY,
|
UPDATE_PRIORITY,
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
import { wrapCameraTorus } from "./torus";
|
import { wrapCameraTorus } from "./torus";
|
||||||
import {
|
import {
|
||||||
DARK_THEME,
|
DARK_THEME,
|
||||||
|
displayLineEndpoints,
|
||||||
displayPointRadiusWorld,
|
displayPointRadiusWorld,
|
||||||
displayStrokeWidthWorld,
|
displayStrokeWidthWorld,
|
||||||
World,
|
World,
|
||||||
@@ -219,6 +221,16 @@ export interface RendererHandle {
|
|||||||
setVisibilityFog(
|
setVisibilityFog(
|
||||||
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
||||||
): void;
|
): 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
|
* setPlanetLabels replaces the on-map planet label dataset
|
||||||
* (F8-12 / #29). Each entry is anchored to its planet's
|
* (F8-12 / #29). Each entry is anchored to its planet's
|
||||||
@@ -510,7 +522,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
} else if (prim.kind === "circle") {
|
} else if (prim.kind === "circle") {
|
||||||
drawCircle(g, prim, theme, viewport.scaled);
|
drawCircle(g, prim, theme, viewport.scaled);
|
||||||
} else {
|
} else {
|
||||||
drawLine(g, prim, theme, viewport.scaled);
|
drawLine(g, prim, theme, viewport.scaled, currentScaleRef);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
||||||
@@ -635,17 +647,28 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
const totalTextHeight =
|
const totalTextHeight =
|
||||||
nameHeight + (entry.nameText !== null ? LABEL_LINE_GAP_PX : 0) + numberHeight;
|
nameHeight + (entry.nameText !== null ? LABEL_LINE_GAP_PX : 0) + numberHeight;
|
||||||
entry.numberText.y = entry.nameText !== null ? nameHeight + LABEL_LINE_GAP_PX : 0;
|
entry.numberText.y = entry.nameText !== null ? 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(
|
||||||
|
entry.nameText?.width ?? 0,
|
||||||
|
entry.numberText.width,
|
||||||
|
);
|
||||||
|
const hitWidth = widestText + LABEL_FRAME_PADDING_PX * 2;
|
||||||
|
const hitHeight = totalTextHeight + LABEL_FRAME_PADDING_PX * 2;
|
||||||
|
entry.container.hitArea = new Rectangle(
|
||||||
|
-hitWidth / 2,
|
||||||
|
-LABEL_FRAME_PADDING_PX,
|
||||||
|
hitWidth,
|
||||||
|
hitHeight,
|
||||||
|
);
|
||||||
entry.frame.clear();
|
entry.frame.clear();
|
||||||
if (!isSelected) {
|
if (!isSelected) {
|
||||||
entry.frame.visible = false;
|
entry.frame.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const widestText = Math.max(
|
const frameWidth = hitWidth;
|
||||||
entry.nameText?.width ?? 0,
|
const frameHeight = hitHeight;
|
||||||
entry.numberText.width,
|
|
||||||
);
|
|
||||||
const frameWidth = widestText + LABEL_FRAME_PADDING_PX * 2;
|
|
||||||
const frameHeight = totalTextHeight + LABEL_FRAME_PADDING_PX * 2;
|
|
||||||
entry.frame.roundRect(
|
entry.frame.roundRect(
|
||||||
-frameWidth / 2,
|
-frameWidth / 2,
|
||||||
-LABEL_FRAME_PADDING_PX,
|
-LABEL_FRAME_PADDING_PX,
|
||||||
@@ -821,11 +844,34 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
};
|
};
|
||||||
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
||||||
planetLabelInstances.set(data.planetNumber, entry);
|
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));
|
||||||
}
|
}
|
||||||
updateLabelTransforms();
|
updateLabelTransforms();
|
||||||
requestRender();
|
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;
|
let mode: WrapMode = opts.mode;
|
||||||
|
|
||||||
const enforceCentreWhenLarger = (): void => {
|
const enforceCentreWhenLarger = (): void => {
|
||||||
@@ -1045,24 +1091,28 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
viewport.scaled,
|
viewport.scaled,
|
||||||
currentScaleRef,
|
currentScaleRef,
|
||||||
);
|
);
|
||||||
|
const cameraScale = viewport.scaled > 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) {
|
for (const g of pickOverlays) {
|
||||||
g.clear();
|
g.clear();
|
||||||
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
|
// F8-12 / #5: anchor ring around the source is retired —
|
||||||
g.stroke({
|
// the source planet's own outline already signals selection.
|
||||||
color: theme.pickHighlight,
|
|
||||||
alpha: PICK_OVERLAY_STYLE.anchor.alpha,
|
|
||||||
width: PICK_OVERLAY_STYLE.anchor.width,
|
|
||||||
});
|
|
||||||
if (spec.line !== null) {
|
if (spec.line !== null) {
|
||||||
g.moveTo(spec.line.x1, spec.line.y1);
|
g.moveTo(spec.line.x1, spec.line.y1);
|
||||||
g.lineTo(spec.line.x2, spec.line.y2);
|
g.lineTo(spec.line.x2, spec.line.y2);
|
||||||
g.stroke({
|
g.stroke({
|
||||||
color: theme.pickHighlight,
|
color: theme.pickHighlight,
|
||||||
alpha: PICK_OVERLAY_STYLE.line.alpha,
|
alpha: PICK_OVERLAY_STYLE.line.alpha,
|
||||||
width: PICK_OVERLAY_STYLE.line.width,
|
width: lineWidthWorld,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (spec.hoverOutline !== null) {
|
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(
|
g.circle(
|
||||||
spec.hoverOutline.x,
|
spec.hoverOutline.x,
|
||||||
spec.hoverOutline.y,
|
spec.hoverOutline.y,
|
||||||
@@ -1071,7 +1121,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
g.stroke({
|
g.stroke({
|
||||||
color: theme.pickHighlight,
|
color: theme.pickHighlight,
|
||||||
alpha: PICK_OVERLAY_STYLE.hover.alpha,
|
alpha: PICK_OVERLAY_STYLE.hover.alpha,
|
||||||
width: PICK_OVERLAY_STYLE.hover.width,
|
width: hoverWidthWorld,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1160,14 +1210,27 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
|
|
||||||
// Zoom-driven repaint. Both `redrawAllPrimitives` and
|
// Zoom-driven repaint. Both `redrawAllPrimitives` and
|
||||||
// `redrawPickOverlay` are in scope now, so the subscription is
|
// `redrawPickOverlay` are in scope now, so the subscription is
|
||||||
// safe even on a synchronous zoomed event.
|
// safe even on a synchronous zoomed event. A rapid wheel / pinch
|
||||||
const handleZoomed = (): void => {
|
// 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();
|
redrawAllPrimitives();
|
||||||
updateOutlineTransforms();
|
updateOutlineTransforms();
|
||||||
updateLabelTransforms();
|
updateLabelTransforms();
|
||||||
redrawPickOverlay();
|
redrawPickOverlay();
|
||||||
requestRender();
|
requestRender();
|
||||||
};
|
};
|
||||||
|
const handleZoomed = (): void => {
|
||||||
|
if (zoomedRedrawPending) return;
|
||||||
|
zoomedRedrawPending = true;
|
||||||
|
Ticker.shared.addOnce(() => {
|
||||||
|
zoomedRedrawPending = false;
|
||||||
|
runCameraDerivedRedraws();
|
||||||
|
});
|
||||||
|
};
|
||||||
viewport.on("zoomed", handleZoomed);
|
viewport.on("zoomed", handleZoomed);
|
||||||
|
|
||||||
const handle: RendererHandle = {
|
const handle: RendererHandle = {
|
||||||
@@ -1348,6 +1411,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
},
|
},
|
||||||
setPlanetLabels,
|
setPlanetLabels,
|
||||||
setPlanetOutlines,
|
setPlanetOutlines,
|
||||||
|
refreshCameraDerivedDraws: runCameraDerivedRedraws,
|
||||||
resize: (w, h) => {
|
resize: (w, h) => {
|
||||||
app.renderer.resize(w, h);
|
app.renderer.resize(w, h);
|
||||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||||
@@ -1466,21 +1530,31 @@ function drawLine(
|
|||||||
p: LinePrim,
|
p: LinePrim,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
cameraScale: number,
|
cameraScale: number,
|
||||||
|
scaleRef: number,
|
||||||
): void {
|
): void {
|
||||||
const color = p.style.strokeColor ?? theme.lineStroke;
|
const color = p.style.strokeColor ?? theme.lineStroke;
|
||||||
const alpha = p.style.strokeAlpha ?? 1;
|
const alpha = p.style.strokeAlpha ?? 1;
|
||||||
const width = displayStrokeWidthWorld(p.style, cameraScale);
|
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;
|
const dash = p.style.strokeDashPx;
|
||||||
if (dash === undefined || dash <= 0) {
|
if (dash === undefined || dash <= 0) {
|
||||||
g.moveTo(p.x1, p.y1);
|
g.moveTo(ends.x1, ends.y1);
|
||||||
g.lineTo(p.x2, p.y2);
|
g.lineTo(ends.x2, ends.y2);
|
||||||
g.stroke({ color, alpha, width });
|
g.stroke({ color, alpha, width });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// PixiJS v8 has no native dashed-line API; segment the path into
|
// PixiJS v8 has no native dashed-line API; segment the path into
|
||||||
// equal-length dashes (dash and gap both `dash` units).
|
// equal-length dashes (dash and gap both `dash` units).
|
||||||
const dx = p.x2 - p.x1;
|
const dx = ends.x2 - ends.x1;
|
||||||
const dy = p.y2 - p.y1;
|
const dy = ends.y2 - ends.y1;
|
||||||
const length = Math.hypot(dx, dy);
|
const length = Math.hypot(dx, dy);
|
||||||
if (length === 0) return;
|
if (length === 0) return;
|
||||||
const ux = dx / length;
|
const ux = dx / length;
|
||||||
@@ -1488,8 +1562,8 @@ function drawLine(
|
|||||||
const step = dash * 2;
|
const step = dash * 2;
|
||||||
for (let t = 0; t < length; t += step) {
|
for (let t = 0; t < length; t += step) {
|
||||||
const segEnd = Math.min(t + dash, length);
|
const segEnd = Math.min(t + dash, length);
|
||||||
g.moveTo(p.x1 + ux * t, p.y1 + uy * t);
|
g.moveTo(ends.x1 + ux * t, ends.y1 + uy * t);
|
||||||
g.lineTo(p.x1 + ux * segEnd, p.y1 + uy * segEnd);
|
g.lineTo(ends.x1 + ux * segEnd, ends.y1 + uy * segEnd);
|
||||||
}
|
}
|
||||||
g.stroke({ color, alpha, width });
|
g.stroke({ color, alpha, width });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ export interface Style {
|
|||||||
// this for the IncomingGroup trajectory line; ignored on point
|
// this for the IncomingGroup trajectory line; ignored on point
|
||||||
// and circle primitives.
|
// and circle primitives.
|
||||||
strokeDashPx?: number;
|
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.
|
// PrimitiveBase carries the fields shared by every primitive kind.
|
||||||
@@ -278,6 +287,62 @@ export function displayStrokeWidthWorld(
|
|||||||
return px / cameraScale;
|
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 = {
|
export const DARK_THEME: Theme = {
|
||||||
background: 0x0a0e1a,
|
background: 0x0a0e1a,
|
||||||
fog: 0x12162a,
|
fog: 0x12162a,
|
||||||
|
|||||||
@@ -137,17 +137,31 @@ describe("buildCargoRouteLines", () => {
|
|||||||
);
|
);
|
||||||
const lines = buildCargoRouteLines(report);
|
const lines = buildCargoRouteLines(report);
|
||||||
expect(lines.length).toBe(12);
|
expect(lines.length).toBe(12);
|
||||||
const styleByPriority = new Map<number, typeof lines[number]["style"]>();
|
// 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<number, number | undefined>();
|
||||||
|
const softLengthByLineId = new Map<number, string | undefined>();
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const existing = styleByPriority.get(line.priority);
|
const existing = colourByPriority.get(line.priority);
|
||||||
if (existing === undefined) styleByPriority.set(line.priority, line.style);
|
if (existing === undefined) {
|
||||||
else expect(existing).toBe(line.style);
|
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.
|
// Default (dark) palette colours, one per load type.
|
||||||
expect(styleByPriority.get(8)?.strokeColor).toBe(DARK_THEME.routeCol);
|
expect(colourByPriority.get(8)).toBe(DARK_THEME.routeCol);
|
||||||
expect(styleByPriority.get(7)?.strokeColor).toBe(DARK_THEME.routeCap);
|
expect(colourByPriority.get(7)).toBe(DARK_THEME.routeCap);
|
||||||
expect(styleByPriority.get(6)?.strokeColor).toBe(DARK_THEME.routeMat);
|
expect(colourByPriority.get(6)).toBe(DARK_THEME.routeMat);
|
||||||
expect(styleByPriority.get(5)?.strokeColor).toBe(DARK_THEME.routeEmp);
|
expect(colourByPriority.get(5)).toBe(DARK_THEME.routeEmp);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses the supplied palette's stroke colours", () => {
|
test("uses the supplied palette's stroke colours", () => {
|
||||||
|
|||||||
@@ -280,6 +280,21 @@ describe("hitTest — empty results and scale", () => {
|
|||||||
expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null);
|
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)", () => {
|
test("pointRadiusBasePx scales softly with zoom (F8-12 / #31)", () => {
|
||||||
// world 1000×1000, viewport 200×200 → scaleRef = 0.2. At
|
// world 1000×1000, viewport 200×200 → scaleRef = 0.2. At
|
||||||
// scale=0.5 the on-screen pixel size is
|
// scale=0.5 the on-screen pixel size is
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ANCHOR_PADDING_WORLD,
|
ANCHOR_PADDING_WORLD,
|
||||||
HOVER_PADDING_WORLD,
|
HOVER_PADDING_PX,
|
||||||
computePickOverlay,
|
computePickOverlay,
|
||||||
type PickModeOptions,
|
type PickModeOptions,
|
||||||
} from "../src/map/pick-mode";
|
} from "../src/map/pick-mode";
|
||||||
@@ -206,7 +206,7 @@ describe("computePickOverlay", () => {
|
|||||||
expect(spec.hoverOutline).toEqual({
|
expect(spec.hoverOutline).toEqual({
|
||||||
x: 200,
|
x: 200,
|
||||||
y: 100,
|
y: 100,
|
||||||
radius: 5 + HOVER_PADDING_WORLD,
|
radius: 5 + HOVER_PADDING_PX,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ describe("computePickOverlay", () => {
|
|||||||
expect(spec.hoverOutline).toBeNull();
|
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(
|
const spec = computePickOverlay(
|
||||||
makeOptions(),
|
makeOptions(),
|
||||||
{ x: 1, y: 1 },
|
{ x: 1, y: 1 },
|
||||||
@@ -254,7 +254,7 @@ describe("computePickOverlay", () => {
|
|||||||
expect(spec.hoverOutline).toEqual({
|
expect(spec.hoverOutline).toEqual({
|
||||||
x: 200,
|
x: 200,
|
||||||
y: 100,
|
y: 100,
|
||||||
radius: 5 + HOVER_PADDING_WORLD,
|
radius: 5 + HOVER_PADDING_PX,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ describe("computePickOverlay", () => {
|
|||||||
allIds,
|
allIds,
|
||||||
);
|
);
|
||||||
expect(spec.hoverOutline?.radius).toBe(
|
expect(spec.hoverOutline?.radius).toBe(
|
||||||
DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_WORLD,
|
DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_PX,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user