feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the renderer divides by the current camera scale on every `viewport.zoomed` so thin lines / small markers stay the same on-screen size at any zoom. * Known-size planets switch to `pointRadiusWorld`, softened against the reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified planets pin to a 3-px disc. * New planet label layer renders a two-line `name / #N` legend under each planet (`#N` only for unidentified or when the new `planetNames` toggle is off). Selection now paints an inverse-fill frame around the selected planet's label plus an outline on the disc; the old selection-ring primitive is retired. * Bombing markers swap the separate CirclePrim for a planet-outline overlay (damaged / wiped colour); the report deep-link moves to a "view bombing report" link in the planet inspector. * Docs + tests follow: `renderer.md` reflects the new sizing contract + label / outline layers, vitest covers the sizing math, label formatting, and the new toggle, and the map-toggles e2e adds a persistence case for `planetNames`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+411
-17
@@ -21,6 +21,7 @@ import {
|
||||
Application,
|
||||
Container,
|
||||
Graphics,
|
||||
Text,
|
||||
Ticker,
|
||||
UPDATE_PRIORITY,
|
||||
type Renderer,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
import { Viewport as PixiViewport } from "pixi-viewport";
|
||||
|
||||
import { hitTest, type Hit } from "./hit-test";
|
||||
import type { PlanetLabelData } from "./labels";
|
||||
import { screenToWorld } from "./math";
|
||||
import { minScaleNoWrap } from "./no-wrap";
|
||||
import {
|
||||
@@ -40,7 +42,8 @@ import {
|
||||
import { wrapCameraTorus } from "./torus";
|
||||
import {
|
||||
DARK_THEME,
|
||||
DEFAULT_POINT_RADIUS_PX,
|
||||
displayPointRadiusWorld,
|
||||
displayStrokeWidthWorld,
|
||||
World,
|
||||
type Camera,
|
||||
type CirclePrim,
|
||||
@@ -57,6 +60,20 @@ import {
|
||||
// selection. The map renderer always restricts to webgpu/webgl.
|
||||
export type RendererPreference = "webgpu" | "webgl";
|
||||
|
||||
/**
|
||||
* PlanetOutlineSpec drives the F8-12 planet-outline overlay (#30):
|
||||
* a thin stroke painted around the planet disc that signals
|
||||
* selection / bombing without adding a separate ring marker. The
|
||||
* renderer hugs the outline to the visible disc on every zoom step,
|
||||
* so callers do not have to recompute it.
|
||||
*/
|
||||
export interface PlanetOutlineSpec {
|
||||
readonly planetNumber: number;
|
||||
readonly color: number;
|
||||
/** Stroke width in screen pixels. Defaults to 1.5 when omitted. */
|
||||
readonly widthPx?: number;
|
||||
}
|
||||
|
||||
export interface RendererOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
world: World;
|
||||
@@ -202,6 +219,28 @@ export interface RendererHandle {
|
||||
setVisibilityFog(
|
||||
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
||||
): void;
|
||||
/**
|
||||
* setPlanetLabels replaces the on-map planet label dataset
|
||||
* (F8-12 / #29). Each entry is anchored to its planet's
|
||||
* `(x, y)` and the renderer keeps the labels just below the
|
||||
* disc, repositioning them on every zoom step so the gap stays
|
||||
* constant in screen pixels. `selectedPlanetId` (or `null`)
|
||||
* controls which label gets the inverse-fill selection frame
|
||||
* (F8-12 / #30); pass `null` when no planet is selected.
|
||||
*/
|
||||
setPlanetLabels(
|
||||
labels: ReadonlyArray<PlanetLabelData>,
|
||||
selectedPlanetId: number | null,
|
||||
): void;
|
||||
/**
|
||||
* setPlanetOutlines replaces the planet-outline overlay set
|
||||
* (F8-12 / #30). Each entry paints a thin stroke around the
|
||||
* planet's visible disc — the radius follows the soft / pixel
|
||||
* sizing rules so the outline hugs the planet at any zoom.
|
||||
* Used by both selection (selection accent colour) and bombing
|
||||
* (damaged / wiped colour) signals.
|
||||
*/
|
||||
setPlanetOutlines(outlines: ReadonlyArray<PlanetOutlineSpec>): void;
|
||||
resize(widthPx: number, heightPx: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -414,14 +453,52 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
return c;
|
||||
});
|
||||
|
||||
// Outline layer per copy (F8-12 / #30). Sits between the
|
||||
// primitive disc and the labels so the stroke reads against the
|
||||
// planet fill while staying below the textual layer. Each entry
|
||||
// is rebuilt in `updateOutlineTransforms` on every zoom step so
|
||||
// the radius hugs the visible disc.
|
||||
const outlineLayers: Container[] = copies.map((c) => {
|
||||
const layer = new Container();
|
||||
c.addChild(layer);
|
||||
return layer;
|
||||
});
|
||||
|
||||
// Label layer per copy (F8-12 / #29). Labels render above every
|
||||
// primitive so the text reads on top of fog / route lines, and the
|
||||
// per-copy layout mirrors the primitive copies so wrap mode still
|
||||
// shows the labels in whichever torus tile the user is panned over.
|
||||
// Each layer holds one `Container` per planet (built lazily by
|
||||
// `setPlanetLabels`), and we keep the scale + y-offset of those
|
||||
// containers in lock-step with the camera in `updateLabelTransforms`.
|
||||
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
|
||||
// indexes the full set of `Graphics` instances per primitive id.
|
||||
const primitiveGraphics = new Map<PrimitiveID, Graphics[]>();
|
||||
const pointPrimitivesById = new Map<PrimitiveID, PointPrim>();
|
||||
// primitiveById holds the original `Primitive` for every emitted
|
||||
// id so the F8-12 zoom rebuild can replay the geometry with the
|
||||
// current camera scale without having to re-derive it from a
|
||||
// fresh report. The map covers base + extras alike.
|
||||
const primitiveById = new Map<PrimitiveID, Primitive>();
|
||||
const allPrimitiveIds: PrimitiveID[] = [];
|
||||
const extraPrimitiveIds = new Set<PrimitiveID>();
|
||||
let currentWorld: World = opts.world;
|
||||
// currentScaleRef mirrors the `minScaleNoWrap` value: the scale at
|
||||
// which the whole world fits the viewport. The non-linear planet
|
||||
// size formula softens growth relative to this reference (#31), and
|
||||
// it changes when the viewport resizes — recomputed in `applyMode`
|
||||
// and `resize`.
|
||||
let currentScaleRef = minScaleNoWrap(
|
||||
{ widthPx: widthPx, heightPx: heightPx },
|
||||
opts.world,
|
||||
);
|
||||
// hiddenIds is the Phase 29 hide-by-id snapshot. Empty by default;
|
||||
// every map-view effect run replaces it with the current
|
||||
// MapToggles-derived set via `setHiddenPrimitiveIds`. Both
|
||||
@@ -436,9 +513,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const visible = !hiddenIds.has(id);
|
||||
for (const g of list) g.visible = visible;
|
||||
};
|
||||
const drawPrimitiveInto = (prim: Primitive, g: Graphics): void => {
|
||||
g.clear();
|
||||
if (prim.kind === "point") {
|
||||
drawPoint(g, prim, theme, viewport.scaled, currentScaleRef);
|
||||
} else if (prim.kind === "circle") {
|
||||
drawCircle(g, prim, theme, viewport.scaled);
|
||||
} else {
|
||||
drawLine(g, prim, theme, viewport.scaled);
|
||||
}
|
||||
};
|
||||
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
||||
for (const c of copies) {
|
||||
const g = buildGraphics(prim, theme);
|
||||
const g = new Graphics();
|
||||
drawPrimitiveInto(prim, g);
|
||||
c.addChild(g);
|
||||
let list = primitiveGraphics.get(prim.id);
|
||||
if (list === undefined) {
|
||||
@@ -448,6 +536,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
list.push(g);
|
||||
}
|
||||
allPrimitiveIds.push(prim.id);
|
||||
primitiveById.set(prim.id, prim);
|
||||
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
|
||||
if (isExtra) extraPrimitiveIds.add(prim.id);
|
||||
// Fresh primitives honour the current hide set so cargo-route
|
||||
@@ -456,10 +545,253 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const list = primitiveGraphics.get(prim.id);
|
||||
if (list !== undefined) applyHiddenStateTo(prim.id, list);
|
||||
};
|
||||
// redrawAllPrimitives replays every primitive's geometry into its
|
||||
// existing per-copy `Graphics` instances using the current camera
|
||||
// scale. The F8-12 zoom-invariant contract requires the renderer
|
||||
// to repaint pixel-space sizes (#28) and the softened planet
|
||||
// radius (#31) whenever the camera zoom changes — instead of
|
||||
// destroying and recreating the Graphics, we `clear()` and redraw
|
||||
// so dim/visibility state on each Graphics is preserved.
|
||||
const redrawAllPrimitives = (): void => {
|
||||
for (const [id, list] of primitiveGraphics) {
|
||||
const prim = primitiveById.get(id);
|
||||
if (prim === undefined) continue;
|
||||
for (const g of list) drawPrimitiveInto(prim, g);
|
||||
}
|
||||
};
|
||||
for (const p of opts.world.primitives) {
|
||||
populatePrimitives(p, false);
|
||||
}
|
||||
|
||||
// Planet label state (F8-12 / #29 + #30). The renderer holds one
|
||||
// `Container` per planet per torus copy; text + selection frame
|
||||
// live inside that container. `currentLabels` mirrors the dataset
|
||||
// last passed into `setPlanetLabels` so a zoom-driven transform
|
||||
// update does not need a fresh report.
|
||||
interface LabelGfx {
|
||||
readonly container: Container;
|
||||
readonly frame: Graphics;
|
||||
readonly nameText: Text | null;
|
||||
readonly numberText: Text;
|
||||
}
|
||||
const planetLabelInstances = new Map<number, LabelGfx[]>();
|
||||
let currentLabels: ReadonlyArray<PlanetLabelData> = [];
|
||||
const LABEL_FONT_SIZE_PX = 11;
|
||||
const LABEL_LINE_GAP_PX = 0;
|
||||
const LABEL_FRAME_PADDING_PX = 3;
|
||||
const LABEL_OFFSET_PX = 4; // gap between planet disc and the label
|
||||
|
||||
const buildLabelText = (
|
||||
content: string,
|
||||
fillColor: number,
|
||||
): Text => {
|
||||
const t = new Text({
|
||||
text: content,
|
||||
style: {
|
||||
fontFamily:
|
||||
"system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
fontSize: LABEL_FONT_SIZE_PX,
|
||||
fill: fillColor,
|
||||
align: "center",
|
||||
},
|
||||
});
|
||||
t.anchor.set(0.5, 0);
|
||||
return t;
|
||||
};
|
||||
|
||||
const disposeLabelGfx = (entry: LabelGfx): void => {
|
||||
entry.nameText?.destroy();
|
||||
entry.numberText.destroy();
|
||||
entry.frame.destroy();
|
||||
entry.container.parent?.removeChild(entry.container);
|
||||
entry.container.destroy();
|
||||
};
|
||||
|
||||
const clearAllLabels = (): void => {
|
||||
for (const list of planetLabelInstances.values()) {
|
||||
for (const entry of list) disposeLabelGfx(entry);
|
||||
}
|
||||
planetLabelInstances.clear();
|
||||
};
|
||||
|
||||
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
|
||||
// Text colours flip on selection so the legend reads on the
|
||||
// inverse-fill frame.
|
||||
const nameFill = isSelected ? theme.labelInverseText : theme.labelText;
|
||||
const numberFill = isSelected
|
||||
? theme.labelInverseText
|
||||
: entry.nameText !== null
|
||||
? theme.labelMuted
|
||||
: theme.labelText;
|
||||
if (entry.nameText !== null) {
|
||||
entry.nameText.style.fill = nameFill;
|
||||
}
|
||||
entry.numberText.style.fill = numberFill;
|
||||
const nameHeight = entry.nameText?.height ?? 0;
|
||||
const numberHeight = entry.numberText.height;
|
||||
const totalTextHeight =
|
||||
nameHeight + (entry.nameText !== null ? LABEL_LINE_GAP_PX : 0) + numberHeight;
|
||||
entry.numberText.y = entry.nameText !== null ? nameHeight + LABEL_LINE_GAP_PX : 0;
|
||||
entry.frame.clear();
|
||||
if (!isSelected) {
|
||||
entry.frame.visible = false;
|
||||
return;
|
||||
}
|
||||
const widestText = Math.max(
|
||||
entry.nameText?.width ?? 0,
|
||||
entry.numberText.width,
|
||||
);
|
||||
const frameWidth = widestText + LABEL_FRAME_PADDING_PX * 2;
|
||||
const frameHeight = totalTextHeight + LABEL_FRAME_PADDING_PX * 2;
|
||||
entry.frame.roundRect(
|
||||
-frameWidth / 2,
|
||||
-LABEL_FRAME_PADDING_PX,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
3,
|
||||
);
|
||||
entry.frame.fill({ color: theme.labelInverseBackground, alpha: 0.95 });
|
||||
entry.frame.visible = true;
|
||||
};
|
||||
|
||||
const updateLabelTransforms = (): void => {
|
||||
const cameraScale = viewport.scaled;
|
||||
if (cameraScale <= 0) return;
|
||||
const labelScale = 1 / cameraScale;
|
||||
const gapWorld = LABEL_OFFSET_PX / cameraScale;
|
||||
for (const [planetNumber, list] of planetLabelInstances) {
|
||||
const planetPrim = pointPrimitivesById.get(planetNumber);
|
||||
if (planetPrim === undefined) continue;
|
||||
const visibleRadius = displayPointRadiusWorld(
|
||||
planetPrim.style,
|
||||
cameraScale,
|
||||
currentScaleRef,
|
||||
);
|
||||
const labelData = currentLabels.find(
|
||||
(l) => l.planetNumber === planetNumber,
|
||||
);
|
||||
const anchorX = labelData?.x ?? planetPrim.x;
|
||||
const anchorY = labelData?.y ?? planetPrim.y;
|
||||
for (const entry of list) {
|
||||
entry.container.x = anchorX;
|
||||
entry.container.y = anchorY + visibleRadius + gapWorld;
|
||||
entry.container.scale.set(labelScale);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Planet outline state (F8-12 / #30). One Graphics per planet per
|
||||
// torus copy. Width and colour come from `PlanetOutlineSpec`; the
|
||||
// radius is recomputed on every zoom step so the outline tracks
|
||||
// the visible disc — the planet itself may grow / shrink with
|
||||
// zoom (`pointRadiusWorld` softening) or stay constant
|
||||
// (`pointRadiusPx` pixel-space).
|
||||
interface PlanetOutlineGfx {
|
||||
readonly graphics: Graphics[];
|
||||
readonly spec: PlanetOutlineSpec;
|
||||
}
|
||||
const planetOutlineInstances = new Map<number, PlanetOutlineGfx>();
|
||||
const OUTLINE_DEFAULT_WIDTH_PX = 1.5;
|
||||
const OUTLINE_RADIUS_PADDING_PX = 1; // gap between disc edge and stroke
|
||||
|
||||
const clearAllOutlines = (): void => {
|
||||
for (const entry of planetOutlineInstances.values()) {
|
||||
for (const g of entry.graphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
}
|
||||
planetOutlineInstances.clear();
|
||||
};
|
||||
|
||||
const paintOutlineEntry = (entry: PlanetOutlineGfx): void => {
|
||||
const cameraScale = viewport.scaled;
|
||||
if (cameraScale <= 0) return;
|
||||
const planetPrim = pointPrimitivesById.get(entry.spec.planetNumber);
|
||||
if (planetPrim === undefined) {
|
||||
for (const g of entry.graphics) g.clear();
|
||||
return;
|
||||
}
|
||||
const visibleRadius = displayPointRadiusWorld(
|
||||
planetPrim.style,
|
||||
cameraScale,
|
||||
currentScaleRef,
|
||||
);
|
||||
const paddingWorld = OUTLINE_RADIUS_PADDING_PX / cameraScale;
|
||||
const widthWorld =
|
||||
(entry.spec.widthPx ?? OUTLINE_DEFAULT_WIDTH_PX) / cameraScale;
|
||||
const outlineRadius = visibleRadius + paddingWorld;
|
||||
for (const g of entry.graphics) {
|
||||
g.clear();
|
||||
g.circle(planetPrim.x, planetPrim.y, outlineRadius);
|
||||
g.stroke({
|
||||
color: entry.spec.color,
|
||||
alpha: 0.95,
|
||||
width: widthWorld,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateOutlineTransforms = (): void => {
|
||||
for (const entry of planetOutlineInstances.values()) {
|
||||
paintOutlineEntry(entry);
|
||||
}
|
||||
};
|
||||
|
||||
const setPlanetOutlines = (
|
||||
outlines: ReadonlyArray<PlanetOutlineSpec>,
|
||||
): void => {
|
||||
clearAllOutlines();
|
||||
for (const spec of outlines) {
|
||||
const list: Graphics[] = [];
|
||||
for (const layer of outlineLayers) {
|
||||
const g = new Graphics();
|
||||
layer.addChild(g);
|
||||
list.push(g);
|
||||
}
|
||||
const entry: PlanetOutlineGfx = { graphics: list, spec };
|
||||
planetOutlineInstances.set(spec.planetNumber, entry);
|
||||
paintOutlineEntry(entry);
|
||||
}
|
||||
requestRender();
|
||||
};
|
||||
|
||||
const setPlanetLabels = (
|
||||
labels: ReadonlyArray<PlanetLabelData>,
|
||||
selectedPlanetId: number | null,
|
||||
): void => {
|
||||
clearAllLabels();
|
||||
currentLabels = labels.slice();
|
||||
for (const data of labels) {
|
||||
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);
|
||||
list.push(entry);
|
||||
}
|
||||
planetLabelInstances.set(data.planetNumber, list);
|
||||
}
|
||||
updateLabelTransforms();
|
||||
requestRender();
|
||||
};
|
||||
|
||||
let mode: WrapMode = opts.mode;
|
||||
|
||||
const enforceCentreWhenLarger = (): void => {
|
||||
@@ -513,6 +845,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
|
||||
opts.world,
|
||||
);
|
||||
currentScaleRef = minScale;
|
||||
// Both modes enforce minScale on zoom-out: the world (origin
|
||||
// copy) always fills at least the viewport. Without this, in
|
||||
// torus mode the user would zoom out far enough to see the
|
||||
@@ -535,6 +868,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
viewport.on("moved", wrapTorusCamera);
|
||||
wrapTorusCamera();
|
||||
}
|
||||
// The F8-12 zoom-invariant contract requires pixel-space sizes
|
||||
// (#28) and the softened planet radius (#31) to track the
|
||||
// current scale. `applyMode` can change `viewport.scaled` (e.g.
|
||||
// the `setZoom(minScale, true)` clamp above), so redraw all
|
||||
// primitives before the next paint and re-place the labels /
|
||||
// outlines too.
|
||||
redrawAllPrimitives();
|
||||
updateOutlineTransforms();
|
||||
updateLabelTransforms();
|
||||
// Toggling `copy.visible` does not move the camera, so request a
|
||||
// repaint explicitly; any camera change above also sets
|
||||
// `viewport.dirty`, which is harmless to request twice.
|
||||
@@ -543,6 +885,14 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
|
||||
applyMode(mode);
|
||||
|
||||
// `handleZoomed` is defined inline here so it can close over both
|
||||
// `redrawAllPrimitives` and the still-to-be-declared
|
||||
// `redrawPickOverlay`; the `viewport.on("zoomed", …)` subscription
|
||||
// happens further below, after both helpers are in scope. F8-12
|
||||
// (#28, #31): pixel-space sizes and the softened planet radius
|
||||
// depend on `viewport.scaled`, so a zoom step needs to repaint
|
||||
// every base primitive in the same frame.
|
||||
|
||||
// Pointer-move + hover plumbing. Listening on the underlying
|
||||
// canvas keeps the renderer agnostic of pixi-viewport's plugin
|
||||
// chain (drag/pinch can swallow Pixi-level pointer events while
|
||||
@@ -658,6 +1008,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
mode === "torus"
|
||||
? { width: opts.world.width, height: opts.world.height }
|
||||
: null,
|
||||
viewport.scaled,
|
||||
currentScaleRef,
|
||||
);
|
||||
for (const g of pickOverlays) {
|
||||
g.clear();
|
||||
@@ -772,6 +1124,18 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
};
|
||||
};
|
||||
|
||||
// Zoom-driven repaint. Both `redrawAllPrimitives` and
|
||||
// `redrawPickOverlay` are in scope now, so the subscription is
|
||||
// safe even on a synchronous zoomed event.
|
||||
const handleZoomed = (): void => {
|
||||
redrawAllPrimitives();
|
||||
updateOutlineTransforms();
|
||||
updateLabelTransforms();
|
||||
redrawPickOverlay();
|
||||
requestRender();
|
||||
};
|
||||
viewport.on("zoomed", handleZoomed);
|
||||
|
||||
const handle: RendererHandle = {
|
||||
app,
|
||||
viewport,
|
||||
@@ -809,6 +1173,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
primitiveGraphics.delete(id);
|
||||
}
|
||||
pointPrimitivesById.delete(id);
|
||||
primitiveById.delete(id);
|
||||
const idx = allPrimitiveIds.indexOf(id);
|
||||
if (idx >= 0) allPrimitiveIds.splice(idx, 1);
|
||||
}
|
||||
@@ -947,16 +1312,25 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
mask.destroy();
|
||||
}
|
||||
},
|
||||
setPlanetLabels,
|
||||
setPlanetOutlines,
|
||||
resize: (w, h) => {
|
||||
app.renderer.resize(w, h);
|
||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
|
||||
currentScaleRef = minScale;
|
||||
viewport.plugins.remove("clamp-zoom");
|
||||
viewport.clampZoom({ minScale });
|
||||
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
|
||||
if (mode === "no-wrap") {
|
||||
enforceCentreWhenLarger();
|
||||
}
|
||||
// Resize changes the reference scale and may clamp the zoom;
|
||||
// in both cases the softened planet radius / pixel-space
|
||||
// strokes need to follow.
|
||||
redrawAllPrimitives();
|
||||
updateOutlineTransforms();
|
||||
updateLabelTransforms();
|
||||
// The drawing buffer was resized; repaint at the new size.
|
||||
requestRender();
|
||||
},
|
||||
@@ -988,8 +1362,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
fogMask.destroy();
|
||||
fogMask = null;
|
||||
}
|
||||
clearAllLabels();
|
||||
clearAllOutlines();
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
viewport.off("moved", wrapTorusCamera);
|
||||
viewport.off("zoomed", handleZoomed);
|
||||
viewport.off("clicked", handleViewportClicked);
|
||||
canvas.removeEventListener("pointermove", handlePointerMove);
|
||||
canvas.removeEventListener("pointerleave", handlePointerLeave);
|
||||
@@ -1011,37 +1388,54 @@ function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" {
|
||||
return "webgl";
|
||||
}
|
||||
|
||||
function buildGraphics(p: Primitive, theme: Theme): Graphics {
|
||||
const g = new Graphics();
|
||||
if (p.kind === "point") drawPoint(g, p, theme);
|
||||
else if (p.kind === "circle") drawCircle(g, p, theme);
|
||||
else drawLine(g, p, theme);
|
||||
return g;
|
||||
}
|
||||
|
||||
function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
|
||||
function drawPoint(
|
||||
g: Graphics,
|
||||
p: PointPrim,
|
||||
theme: Theme,
|
||||
cameraScale: number,
|
||||
scaleRef: number,
|
||||
): void {
|
||||
const color = p.style.fillColor ?? theme.pointFill;
|
||||
const alpha = p.style.fillAlpha ?? 1;
|
||||
const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
|
||||
g.circle(p.x, p.y, radiusPx);
|
||||
const radius = displayPointRadiusWorld(p.style, cameraScale, scaleRef);
|
||||
g.circle(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);
|
||||
g.stroke({
|
||||
color: p.style.strokeColor,
|
||||
alpha: strokeAlpha,
|
||||
width: strokeWidth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawCircle(g: Graphics, p: CirclePrim, theme: Theme): void {
|
||||
function drawCircle(
|
||||
g: Graphics,
|
||||
p: CirclePrim,
|
||||
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 strokeColor = p.style.strokeColor ?? theme.circleStroke;
|
||||
const strokeAlpha = p.style.strokeAlpha ?? 1;
|
||||
const strokeWidth = p.style.strokeWidthPx ?? 1;
|
||||
const strokeWidth = displayStrokeWidthWorld(p.style, cameraScale);
|
||||
g.stroke({ color: strokeColor, alpha: strokeAlpha, width: strokeWidth });
|
||||
}
|
||||
|
||||
function drawLine(g: Graphics, p: LinePrim, theme: Theme): void {
|
||||
function drawLine(
|
||||
g: Graphics,
|
||||
p: LinePrim,
|
||||
theme: Theme,
|
||||
cameraScale: number,
|
||||
): void {
|
||||
const color = p.style.strokeColor ?? theme.lineStroke;
|
||||
const alpha = p.style.strokeAlpha ?? 1;
|
||||
const width = p.style.strokeWidthPx ?? 1;
|
||||
const width = displayStrokeWidthWorld(p.style, cameraScale);
|
||||
const dash = p.style.strokeDashPx;
|
||||
if (dash === undefined || dash <= 0) {
|
||||
g.moveTo(p.x1, p.y1);
|
||||
|
||||
Reference in New Issue
Block a user