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 141 additions and 6 deletions
Showing only changes of commit 4d729c1f50 - Show all commits
@@ -117,6 +117,50 @@ preference the store already manages.
let containerEl: HTMLDivElement | null = $state(null);
let mountError: string | null = $state(null);
// F8-12 follow-up: an opt-in technical overlay activated by adding
// `?debug=1` to the URL. Shows the current camera scale and the
// world-units rectangle currently visible inside the viewport — so
// the owner can decide what to clamp `maxScale` to once it lands.
const debugOverlayEnabled = (() => {
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).get("debug") === "1";
})();
let debugInfo: {
scale: number;
scaleRef: number;
viewWorldWidth: number;
viewWorldHeight: number;
} | null = $state(null);
let debugFrame: number | null = null;
function startDebugLoop(): void {
if (!debugOverlayEnabled) return;
const tick = (): void => {
if (handle !== null) {
const camera = handle.getCamera();
const vp = handle.getViewport();
const safeScale = camera.scale > 0 ? camera.scale : 1;
const worldW = store?.report?.mapWidth ?? 1;
const worldH = store?.report?.mapHeight ?? 1;
debugInfo = {
scale: camera.scale,
scaleRef: Math.max(vp.widthPx / worldW, vp.heightPx / worldH),
viewWorldWidth: vp.widthPx / safeScale,
viewWorldHeight: vp.heightPx / safeScale,
};
} else {
debugInfo = null;
}
debugFrame = requestAnimationFrame(tick);
};
debugFrame = requestAnimationFrame(tick);
}
function stopDebugLoop(): void {
if (debugFrame !== null) {
cancelAnimationFrame(debugFrame);
debugFrame = null;
}
}
let handle: RendererHandle | null = null;
let hitLookup = new Map<PrimitiveID, HitTarget>();
// currentCategories / currentPlanetDependents are populated by
@@ -797,6 +841,7 @@ preference the store already manages.
onMount(() => {
mounted = true;
startDebugLoop();
onResize = (): void => {
if (handle === null || containerEl === null) return;
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
@@ -814,6 +859,7 @@ preference the store already manages.
onDestroy(() => {
mounted = false;
stopDebugLoop();
if (onResize !== null) {
window.removeEventListener("resize", onResize);
onResize = null;
@@ -868,6 +914,30 @@ preference the store already manages.
{#if store !== undefined && store.status === "ready"}
<MapTogglesControl {store} />
{/if}
{#if debugOverlayEnabled && debugInfo !== null}
<div class="debug-overlay" data-testid="map-debug-overlay" aria-hidden="true">
<div class="debug-row">
<span class="debug-key">scale</span>
<span class="debug-val">{debugInfo.scale.toFixed(3)}</span>
</div>
<div class="debug-row">
<span class="debug-key">scale_ref</span>
<span class="debug-val">{debugInfo.scaleRef.toFixed(3)}</span>
</div>
<div class="debug-row">
<span class="debug-key">scale_ratio</span>
<span class="debug-val">
×{(debugInfo.scale / debugInfo.scaleRef).toFixed(2)}
</span>
</div>
<div class="debug-row">
<span class="debug-key">view W×H</span>
<span class="debug-val">
{debugInfo.viewWorldWidth.toFixed(1)} × {debugInfo.viewWorldHeight.toFixed(1)}
</span>
</div>
</div>
{/if}
</div>
</section>
@@ -911,4 +981,32 @@ preference the store already manages.
border-color: var(--color-danger);
color: var(--color-danger);
}
.debug-overlay {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
min-width: 11rem;
padding: 0.35rem 0.55rem;
background: rgba(0, 0, 0, 0.55);
color: #f3f5fb;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.72rem;
line-height: 1.25;
pointer-events: none;
user-select: none;
z-index: 5;
}
.debug-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.debug-key {
color: rgba(243, 245, 251, 0.65);
}
.debug-val {
font-variant-numeric: tabular-nums;
}
</style>
+43 -6
View File
@@ -772,7 +772,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const outlineRadius = visibleRadius + paddingWorld;
for (const g of entry.graphics) {
g.clear();
g.circle(planetPrim.x, planetPrim.y, outlineRadius);
traceSmoothCircle(g, planetPrim.x, planetPrim.y, outlineRadius);
g.stroke({
color: entry.spec.color,
alpha: 0.95,
@@ -1147,7 +1147,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// paints in `pickHighlight` so the player sees the
// destination candidate just like a selection — only
// in the warm picker accent.
g.circle(
traceSmoothCircle(
g,
spec.hoverOutline.x,
spec.hoverOutline.y,
spec.hoverOutline.radius,
@@ -1520,6 +1521,35 @@ function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" {
return "webgl";
}
/**
* SMOOTH_CIRCLE_SEGMENTS is the fixed segment count the renderer
* uses for every disc / ring. PixiJS v8's `Graphics.circle` picks
* the segment count from the world-space radius, which is fine for
* a fully-scaled scene but breaks the moment we draw the same disc
* at a small world radius inside a heavily-zoomed parent container:
* the planet renders as a visible 6-8-gon. Painting a fixed-density
* polygon ourselves keeps the silhouette round at every zoom level.
* 32 segments is the standard "perceptually round" budget; it stays
* cheap (~64 floats per disc) on a 500-planet map.
*/
const SMOOTH_CIRCLE_SEGMENTS = 32;
function traceSmoothCircle(
g: Graphics,
x: number,
y: number,
radius: number,
): void {
if (radius <= 0) return;
const step = (2 * Math.PI) / SMOOTH_CIRCLE_SEGMENTS;
g.moveTo(x + radius, y);
for (let i = 1; i < SMOOTH_CIRCLE_SEGMENTS; i++) {
const a = i * step;
g.lineTo(x + radius * Math.cos(a), y + radius * Math.sin(a));
}
g.closePath();
}
function drawPoint(
g: Graphics,
p: PointPrim,
@@ -1530,11 +1560,13 @@ function drawPoint(
const color = p.style.fillColor ?? theme.pointFill;
const alpha = p.style.fillAlpha ?? 1;
const radius = displayPointRadiusWorld(p.style, cameraScale, scaleRef);
g.circle(p.x, p.y, radius);
traceSmoothCircle(g, p.x, p.y, radius);
g.fill({ color, alpha });
if (p.style.strokeColor !== undefined && (p.style.strokeWidthPx ?? 0) > 0) {
const strokeAlpha = p.style.strokeAlpha ?? 1;
const strokeWidth = displayStrokeWidthWorld(p.style, cameraScale);
// Retrace because Pixi's `fill()` closes the current path.
traceSmoothCircle(g, p.x, p.y, radius);
g.stroke({
color: p.style.strokeColor,
alpha: strokeAlpha,
@@ -1549,9 +1581,14 @@ function drawCircle(
theme: Theme,
cameraScale: number,
): void {
g.circle(p.x, p.y, p.radius);
if (p.style.fillColor !== undefined) {
g.fill({ color: p.style.fillColor, alpha: p.style.fillAlpha ?? 1 });
const hasFill =
p.style.fillColor !== undefined && (p.style.fillAlpha ?? 1) > 0;
traceSmoothCircle(g, p.x, p.y, p.radius);
if (hasFill) {
g.fill({ color: p.style.fillColor!, alpha: p.style.fillAlpha ?? 1 });
// Pixi's `fill()` closes the current path — retrace before
// the stroke pass so the ring is actually painted on top.
traceSmoothCircle(g, p.x, p.y, p.radius);
}
const strokeColor = p.style.strokeColor ?? theme.circleStroke;
const strokeAlpha = p.style.strokeAlpha ?? 1;