feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55) #70
@@ -584,6 +584,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
let currentLabels: ReadonlyArray<PlanetLabelData> = [];
|
||||
let currentLabelsFingerprint: string | null = null;
|
||||
let currentLabelsSelectedId: number | null = null;
|
||||
let currentPlanetSetFingerprint: string | null = null;
|
||||
const fingerprintPlanetLabels = (
|
||||
labels: ReadonlyArray<PlanetLabelData>,
|
||||
): string => {
|
||||
@@ -593,6 +594,14 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
}
|
||||
return parts.join("|");
|
||||
};
|
||||
const fingerprintPlanetSet = (
|
||||
labels: ReadonlyArray<PlanetLabelData>,
|
||||
): string => {
|
||||
const ids: number[] = [];
|
||||
for (const l of labels) ids.push(l.planetNumber);
|
||||
ids.sort((a, b) => a - b);
|
||||
return ids.join(",");
|
||||
};
|
||||
const LABEL_FONT_SIZE_PX = 11;
|
||||
const LABEL_LINE_GAP_PX = 0;
|
||||
const LABEL_FRAME_PADDING_PX = 3;
|
||||
@@ -638,31 +647,34 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
planetLabelInstances.clear();
|
||||
currentLabelsFingerprint = null;
|
||||
currentLabelsSelectedId = null;
|
||||
currentPlanetSetFingerprint = null;
|
||||
};
|
||||
|
||||
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
|
||||
// Text colours flip on selection so the legend reads on the
|
||||
// inverse-fill frame.
|
||||
const nameVisible =
|
||||
entry.nameText !== null && entry.nameText.visible;
|
||||
const nameFill = isSelected ? theme.labelInverseText : theme.labelText;
|
||||
const numberFill = isSelected
|
||||
? theme.labelInverseText
|
||||
: entry.nameText !== null
|
||||
: nameVisible
|
||||
? 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 nameHeight = nameVisible ? 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;
|
||||
nameHeight + (nameVisible ? LABEL_LINE_GAP_PX : 0) + numberHeight;
|
||||
entry.numberText.y = nameVisible ? 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,
|
||||
nameVisible ? entry.nameText!.width : 0,
|
||||
entry.numberText.width,
|
||||
);
|
||||
const hitWidth = widestText + LABEL_FRAME_PADDING_PX * 2;
|
||||
@@ -846,10 +858,33 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
const planetSetFp = fingerprintPlanetSet(labels);
|
||||
if (planetSetFp === currentPlanetSetFingerprint) {
|
||||
// Same planet set, only the per-row text changed (almost
|
||||
// always the F8-12 / #29 `planetNames` toggle flipping name
|
||||
// strings on / off). Rebuilding 9-copies × N labels here
|
||||
// dwarfed everything else on a 500-planet map — instead, walk
|
||||
// the live entries and just update text content / visibility.
|
||||
currentLabels = labels.slice();
|
||||
for (const data of labels) {
|
||||
const list = planetLabelInstances.get(data.planetNumber);
|
||||
if (list === undefined) continue;
|
||||
for (const entry of list) {
|
||||
applyLabelContent(entry, data);
|
||||
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
||||
}
|
||||
}
|
||||
currentLabelsFingerprint = fp;
|
||||
currentLabelsSelectedId = selectedPlanetId;
|
||||
updateLabelTransforms();
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
clearAllLabels();
|
||||
currentLabels = labels.slice();
|
||||
currentLabelsFingerprint = fp;
|
||||
currentLabelsSelectedId = selectedPlanetId;
|
||||
currentPlanetSetFingerprint = planetSetFp;
|
||||
for (const data of labels) {
|
||||
const targetPlanet = data.planetNumber;
|
||||
const list: LabelGfx[] = [];
|
||||
@@ -858,11 +893,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
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);
|
||||
// We always build both lines and toggle visibility for
|
||||
// the name line, so the `planetNames` flip can swap
|
||||
// content without destroying Pixi.Text instances.
|
||||
const nameText = buildLabelText(data.name ?? "", theme.labelText);
|
||||
nameText.visible = data.name !== null;
|
||||
container.addChild(nameText);
|
||||
const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
|
||||
container.addChild(numberText);
|
||||
layer.addChild(container);
|
||||
@@ -890,6 +926,19 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
requestRender();
|
||||
};
|
||||
|
||||
const applyLabelContent = (entry: LabelGfx, data: PlanetLabelData): void => {
|
||||
if (entry.nameText !== null) {
|
||||
const desired = data.name ?? "";
|
||||
if (entry.nameText.text !== desired) {
|
||||
entry.nameText.text = desired;
|
||||
}
|
||||
entry.nameText.visible = data.name !== null;
|
||||
}
|
||||
if (entry.numberText.text !== data.numberLabel) {
|
||||
entry.numberText.text = data.numberLabel;
|
||||
}
|
||||
};
|
||||
|
||||
const simulatePlanetClick = (planetNumber: number): void => {
|
||||
const prim = pointPrimitivesById.get(planetNumber);
|
||||
if (prim === undefined) return;
|
||||
@@ -959,15 +1008,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
|
||||
opts.world,
|
||||
);
|
||||
const maxScale = maxScaleForViewport(
|
||||
viewport.screenWidth,
|
||||
viewport.screenHeight,
|
||||
);
|
||||
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
|
||||
// 3×3 grid of wrap copies at once; the copies are there to
|
||||
// fill the partial slack near a panned edge, not to be
|
||||
// visible simultaneously.
|
||||
viewport.clampZoom({ minScale });
|
||||
// visible simultaneously. The matching maxScale cap (F8-12
|
||||
// follow-up) keeps the longest viewport axis showing at least
|
||||
// `MIN_VISIBLE_WORLD_AT_MAX_ZOOM` world units of map, so the
|
||||
// player can never zoom past the point where individual
|
||||
// glyphs become uselessly large.
|
||||
viewport.clampZoom({ minScale, maxScale });
|
||||
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
|
||||
if (viewport.scaled > maxScale) viewport.setZoom(maxScale, true);
|
||||
if (newMode === "no-wrap") {
|
||||
viewport.clamp({ direction: "all" });
|
||||
viewport.on("moved", enforceCentreWhenLarger);
|
||||
@@ -1451,10 +1509,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
app.renderer.resize(w, h);
|
||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
|
||||
const maxScale = maxScaleForViewport(w, h);
|
||||
currentScaleRef = minScale;
|
||||
viewport.plugins.remove("clamp-zoom");
|
||||
viewport.clampZoom({ minScale });
|
||||
viewport.clampZoom({ minScale, maxScale });
|
||||
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
|
||||
if (viewport.scaled > maxScale) viewport.setZoom(maxScale, true);
|
||||
if (mode === "no-wrap") {
|
||||
enforceCentreWhenLarger();
|
||||
}
|
||||
@@ -1534,6 +1594,23 @@ function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" {
|
||||
*/
|
||||
const SMOOTH_CIRCLE_SEGMENTS = 32;
|
||||
|
||||
/**
|
||||
* MIN_VISIBLE_WORLD_AT_MAX_ZOOM caps zoom-in so the longest viewport
|
||||
* axis never covers fewer than this many world units. Tuned on the
|
||||
* F8-12 owner-feedback round: 5 keeps individual planet glyphs from
|
||||
* filling half the screen on either mobile (longest ≈ 412 px → max
|
||||
* scale ≈ 82) or desktop (longest ≈ 1200 px → max scale ≈ 240) — the
|
||||
* "useful detail" budget at full zoom is still a 5×5-world tile in
|
||||
* the centre of the viewport.
|
||||
*/
|
||||
const MIN_VISIBLE_WORLD_AT_MAX_ZOOM = 5;
|
||||
|
||||
function maxScaleForViewport(widthPx: number, heightPx: number): number {
|
||||
const longest = Math.max(widthPx, heightPx);
|
||||
if (longest <= 0) return Number.POSITIVE_INFINITY;
|
||||
return longest / MIN_VISIBLE_WORLD_AT_MAX_ZOOM;
|
||||
}
|
||||
|
||||
function traceSmoothCircle(
|
||||
g: Graphics,
|
||||
x: number,
|
||||
|
||||
Reference in New Issue
Block a user