Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/lib/dom-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,7 @@ export function prefabFootprintCssRect(
};
}

// 90°/270° rotations swap the world-aligned width/depth; same formula as
// the footprint pass in src/worker/lib/map-renderer.ts.
// WHY: rotations by 90° or 270° swap the world-aligned width and depth.
const odd = ((prefab.rotation ?? 0) & 1) === 1;
let width = (odd ? size[1] : size[0]) * scaleX;
let height = (odd ? size[0] : size[1]) * scaleY;
Expand All @@ -125,14 +124,12 @@ export function canvasEventToGameCoords(
mapSize: GameMapSize,
canvasSize: HTMLCanvasElement,
): GameCoords | null {
// in-game scale coords with left-top offset
const gx = (event.offsetX * mapSize.width) / canvasSize.width;
const gz = (event.offsetY * mapSize.height) / canvasSize.height;
if (gx < 0 || gx >= mapSize.width || gz < 0 || gz >= mapSize.height) {
return null;
}

// in-game coords (center offset)
const x = gx - Math.floor(mapSize.width / 2);
const z = Math.floor(mapSize.height / 2) - gz;
return gameCoords({ x: Math.round(x), z: Math.round(z) });
Expand Down
20 changes: 12 additions & 8 deletions src/lib/footprint-color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ import type {
PrefabDensityScores,
} from "../types/7dtdmap.ts";

// Edge length of an `rwg_tile_*` prefab in game blocks.
/** Edge length of an `rwg_tile_*` prefab in game blocks. */
const TILE_SIZE = 150;

export interface TileIndex {
offsetX: number;
offsetZ: number;
// Key: `${gridX},${gridZ}` district name (extracted from tile filename).
/** Key `${gridX},${gridZ}` maps to a district name extracted from the tile filename. */
cells: Map<string, string>;
}

// Tile prefabs are 150×150 `rwg_tile_<district>_*` blocks on a regular lattice;
// index each by grid cell for O(1) per-POI district lookups.
/**
* Tile prefabs are 150×150 `rwg_tile_<district>_*` blocks on a regular
* lattice. Indexes each by grid cell for O(1) per-POI district lookups.
*/
export function buildTileIndex(allPrefabs: Prefab[]): TileIndex {
const tiles: { x: number; z: number; district: string }[] = [];
for (const p of allPrefabs) {
Expand All @@ -24,8 +26,7 @@ export function buildTileIndex(allPrefabs: Prefab[]): TileIndex {
// deno-lint-ignore no-non-null-assertion
tiles.push({ x: p.x, z: p.z, district: m[1]! });
}
// The lattice origin is the world-centre offset, not a multiple of 150 in
// world coords. Derive it from any tile (mod 150 normalised to [0, 150)).
// WHY: the lattice origin is the world-centre offset, not a multiple of 150 in world coords. Derive it from any tile (mod 150 normalised to [0, 150)).
const mod = (n: number) => ((n % TILE_SIZE) + TILE_SIZE) % TILE_SIZE;
// deno-lint-ignore no-non-null-assertion
const offsetX = tiles.length > 0 ? mod(tiles[0]!.x) : 0;
Expand All @@ -40,8 +41,11 @@ export function buildTileIndex(allPrefabs: Prefab[]): TileIndex {
return { offsetX, offsetZ, cells };
}

// Mirrors WorldGenerationEngineFinal.StreetTile.SpawnMarkerPartsAndPrefabs:
// preview_color × (remnant/abandoned 0.75, low-density 0.4), trader #994d4d, wilderness white.
/**
* Mirrors `WorldGenerationEngineFinal.StreetTile.SpawnMarkerPartsAndPrefabs`.
* Returns `preview_color × (remnant/abandoned 0.75, low-density 0.4)`,
* trader `#994d4d`, wilderness white.
*/
export function footprintColorRgb(
prefab: Prefab,
tileIndex: TileIndex,
Expand Down
10 changes: 6 additions & 4 deletions src/lib/glyph-sprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
GLYPH_MARKER_VIEWPORT,
} from "../../lib/glyph-marker.ts";

// Stamps a glyph path baked by tools/generate-glyph-markers.ts into a square
// sprite twice the target pixel size, matching the baked SVGs' layered look.
/**
* Stamps a glyph path baked by `tools/generate-glyph-markers.ts` into
* a square sprite twice the target pixel size, matching the baked
* SVGs' layered look.
*/
export function buildGlyphSprite(
path2D: Path2D,
pixelSize: number,
Expand All @@ -14,8 +17,7 @@ export function buildGlyphSprite(
const sprite = new OffscreenCanvas(spriteSize, spriteSize);
const sc = sprite.getContext("2d");
if (sc) {
// Scale the baked path's viewport to `pixelSize`, then centre it on the
// sprite; the path itself is built centred on VIEWPORT/2 at build time.
// WHY: scale the baked path's viewport to pixelSize, then centre it on the sprite. The path itself is built centred on VIEWPORT/2 at build time.
const k = pixelSize / GLYPH_MARKER_FONT_SIZE;
sc.lineJoin = "round";
sc.lineCap = "round";
Expand Down
5 changes: 1 addition & 4 deletions src/lib/label-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,7 @@ export class LabelHandler {
this.#doms = doms;
this.#holder = new LabelHolder(labelsBaseUrl, navigatorLanguages);
this.#buildSelectOptions();
// Resolve and apply the initial language synchronously so that callers
// can pull it via `labelHandler.language` / `holder` right after
// construction. The initial value is delivered by pull, not push;
// subscribers only receive subsequent user changes via `addListener`.
// WHY: resolve and apply the initial language synchronously so callers can pull it via labelHandler.language / holder right after construction. Subscribers via addListener() only receive subsequent user changes.
this.#commitLanguage(
(localStorage.getItem("language") as Language | null) ??
resolveLanguage(navigatorLanguages),
Expand Down
24 changes: 14 additions & 10 deletions src/lib/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ export const LANGUAGES = [
] as const;
export type Language = (typeof LANGUAGES)[number];

// Maps a BCP47 primary language subtag to a 7 Days to Die label set.
// Chinese is resolved separately because its label set depends on the script
// subtag (Hans vs Hant), which we derive via Intl.Locale's CLDR-backed
// Likely Subtags expansion.
/**
* Maps a BCP47 primary language subtag to a 7 Days to Die label set.
* Chinese is resolved separately because its label set depends on the
* script subtag (Hans vs Hant), derived via `Intl.Locale`'s CLDR-backed
* Likely Subtags expansion.
*/
const PRIMARY_SUBTAG_LANGUAGES: { [primary: string]: Language } = {
en: "english",
de: "german",
Expand Down Expand Up @@ -131,12 +133,14 @@ function matchLanguage(clientTag: string): Language | undefined {
return PRIMARY_SUBTAG_LANGUAGES[locale.language];
}

// Use Intl.Locale's `maximize()` (backed by CLDR Likely Subtags) to fill in the
// script subtag from language/region — so `zh-CN`, `zh-SG` expand to Hans and
// `zh-TW`, `zh-HK`, `zh-MO` expand to Hant without us hand-maintaining the
// region list. Bare `zh` expands to `zh-Hans-CN`, matching the upstream
// 7 Days to Die / Steam convention where `schinese` is the canonical Chinese
// label set.
/**
* Uses `Intl.Locale.maximize()` (backed by CLDR Likely Subtags) to
* fill in the script subtag from language and region. `zh-CN` and
* `zh-SG` expand to Hans, while `zh-TW`, `zh-HK`, and `zh-MO` expand
* to Hant without a hand-maintained region list. Bare `zh` expands to
* `zh-Hans-CN`, matching the upstream 7 Days to Die / Steam convention
* where `schinese` is the canonical Chinese label set.
*/
function resolveChinese(locale: Intl.Locale): Language {
return locale.maximize().script === "Hant" ? "tchinese" : "schinese";
}
3 changes: 1 addition & 2 deletions src/lib/oneshot-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ function awaitOneshotWorker<T>(worker: Worker): Promise<T> {
settle(() => resolve(event.data));
});
worker.addEventListener("error", (event) => {
// Prevent the browser from also logging an "Uncaught" notice for an
// error we are about to surface through the promise.
// WHY: prevent the browser from also logging an "Uncaught" notice for an error we are about to surface through the promise.
event.preventDefault();
const { message, filename, lineno } = event;
const detail = message || filename
Expand Down
15 changes: 10 additions & 5 deletions src/lib/prefab-added-versions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { PrefabAddedVersions } from "../types/7dtdmap.ts";

// Parses each dot-separated version string (e.g. "3.10") into a numeric
// segment array and keeps the numerically greatest one, so "3.10" is treated
// as newer than "3.2" (a plain lexicographic sort would get this backwards).
// Kept separate from lib/prefabs.ts (which uses DOMParser) so this is
// importable from the filter worker, where DOM types are unavailable.
/**
* Parses each dot-separated version string (e.g. `"3.10"`) into a
* numeric segment array and keeps the numerically greatest one, so
* `"3.10"` is treated as newer than `"3.2"`. Plain lexicographic sort
* would order them backwards.
*
* Kept separate from `lib/prefabs.ts` (which uses `DOMParser`) so it
* is importable from the filter worker, where DOM types are
* unavailable.
*/
export function latestAddedVersion(
addedVersions: PrefabAddedVersions,
): string {
Expand Down
26 changes: 14 additions & 12 deletions src/lib/prefab-hit-index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import type { GameCoords, Prefab, PrefabMeshSizes } from "../types/7dtdmap.ts";

// Mirrors the exclusions in map-renderer's footprint draw so that hit-tests
// target exactly the rectangles a user can see on the map. Tiles are the
// placement framework; part_driveway overlaps its parent POI and is never the
// thing the user is asking about.
/**
* Mirrors the exclusions in `map-renderer`'s footprint draw so hit-tests
* target exactly the rectangles a user can see on the map. `rwg_tile_`
* is the placement framework and `part_driveway_` overlaps its parent
* POI, so neither is ever what a user is asking about.
*/
export const EXCLUDED_NAME_FRAGMENTS = [
"rwg_tile_",
"part_driveway_",
] as const;

// AABB stored in four parallel Int32Arrays so the hit-test loop is pure
// integer compares (no string `.includes`, no PrefabMeshSizes lookup, no
// rotation parity per sample). Rows are sorted ascending by footprint area
// so the first hit is the smallest, naturally preferring the more specific
// POI on overlapping footprints.
/**
* AABB stored in four parallel `Int32Array`s so the hit-test loop is
* pure integer compares (no string `.includes`, no `PrefabMeshSizes`
* lookup, no rotation parity per sample). Rows are sorted ascending by
* footprint area so the first hit is the smallest, naturally preferring
* the more specific POI on overlapping footprints.
*/
export class PrefabHitIndex {
readonly source: Prefab[];
readonly prefabs: Prefab[];
Expand All @@ -38,9 +42,7 @@ export class PrefabHitIndex {
const size = meshSizes[p.name];
if (!size) continue;
const [sx, sz] = size;
// decoration.position is the SW corner of the rotated AABB; 90°/270°
// rotations swap world-aligned width/depth. Matches the renderer's
// footprint formula in src/worker/lib/map-renderer.ts.
// WHY: decoration.position is the SW corner of the rotated AABB. Rotations by 90° or 270° swap world-aligned width and depth.
const odd = ((p.rotation ?? 0) & 1) === 1;
const w = odd ? sz : sx;
const d = odd ? sx : sz;
Expand Down
11 changes: 7 additions & 4 deletions src/lib/prefab-link-tooltip.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Pixels offset from the cursor so the tooltip does not sit on top of it.
/** Pixels offset from the cursor so the tooltip does not sit on top of it. */
const CURSOR_OFFSET = 16;

const HREF_PATTERN = /(?:^|\/)prefabs\/([^/]+)\.html(?:[?#]|$)/;
Expand All @@ -8,9 +8,12 @@ interface Doms {
image: HTMLImageElement;
}

// Shows a preview image popover when the user hovers a link to an individual
// prefab page. The popover element is placed in the top layer so it overlays
// modal `<dialog>` content (e.g. the prefab inspector).
/**
* Shows a preview image popover when the user hovers a link to an
* individual prefab page. The popover element is placed in the top
* layer so it overlays modal `<dialog>` content (e.g. the prefab
* inspector).
*/
export function installPrefabLinkTooltip(doms: Doms): void {
let currentLink: HTMLAnchorElement | null = null;
let lastEvent: MouseEvent | null = null;
Expand Down
55 changes: 36 additions & 19 deletions src/lib/prefab-tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import type { LabelHandler } from "./label-handler.ts";
import { escapeHtml } from "./utils.ts";
import { latestAddedVersion } from "./prefab-added-versions.ts";

// Pixels between the anchor (footprint box edge, or the cursor on the fallback
// path) and the tooltip, so it does not sit on top of the crosshair.
/**
* Pixels between the anchor (footprint box edge, or the cursor on the
* fallback path) and the tooltip. Keeps the tooltip off the crosshair.
*/
export const CURSOR_OFFSET = 16;

// Which action hints to list. The 2D map wires all three; the 3D terrain
// viewer only supports Shift+Click, so it passes just that one.
/**
* Which action hints to list. The 2D map wires all three. The 3D
* terrain viewer only supports Shift+Click and passes just that one.
*/
type TooltipHint = "click" | "dblclick" | "shift-click";

interface TooltipContent {
Expand All @@ -31,12 +35,18 @@ const HINT_HTML: Record<TooltipHint, string> = {
`<div class="hint shift-click">🔗 Shift+Click: Open prefab page</div>`,
};

// Shared prefab popover (image, badges, name, hints) for the 2D map and 3D
// hover; each caller computes its own anchor and passes viewport coords to showAt().
/**
* Shared prefab popover (image, badges, name, hints) for the 2D map
* and 3D hover. Each caller computes its own anchor and passes viewport
* coords to `showAt()`.
*/
class PrefabTooltip {
#dom: HTMLElement;
// Rebuild innerHTML only when the shown prefab changes so hovering within
// one POI does not re-request the <img> (and flash) on every sample.
/**
* Rebuild `innerHTML` only when the shown prefab changes so hovering
* within one POI does not re-request the `<img>` (and flash) on
* every sample.
*/
#shownKey: string | null = null;

constructor(dom: HTMLElement) {
Expand Down Expand Up @@ -75,8 +85,7 @@ class PrefabTooltip {
showAt(left: number, top: number): void {
this.#dom.style.left = `${left.toString()}px`;
this.#dom.style.top = `${top.toString()}px`;
// showPopover() throws if already open, so only enter the top layer once
// and let subsequent calls just reposition (e.g. while the camera pans).
// WHY: showPopover() throws if already open, so only enter the top layer once and let subsequent calls just reposition (e.g. while the camera pans).
if (!this.#dom.matches(":popover-open")) this.#dom.showPopover();
}

Expand All @@ -86,18 +95,24 @@ class PrefabTooltip {
}
}

// Single owner of the shared tooltip DOM for both the 2D map and the terrain
// viewer. Each caller supplies only hit detection and an anchor; this resolves
// the prefab to content, owns the Shift-hint state, and positions the popover.
/**
* Single owner of the shared tooltip DOM for both the 2D map and the
* terrain viewer. Each caller supplies hit detection and an anchor.
* This class resolves the prefab to content, owns the Shift-hint
* state, and positions the popover.
*/
export class PrefabTooltipController {
#view: PrefabTooltip;
#labelHandler: LabelHandler;
#difficulties: Promise<PrefabDifficulties>;
#addedVersions: Promise<PrefabAddedVersions>;
#latestAddedVersion: Promise<string>;
#shiftActive = false;
// Bumped by every showFor and hide so a late-resolving content lookup that
// has since been superseded (newer hover, or a hide) never renders.
/**
* Bumped by every `showFor` and `hide` so a late-resolving content
* lookup that has since been superseded (newer hover or a hide)
* never renders.
*/
#token = 0;

constructor(
Expand All @@ -112,8 +127,7 @@ export class PrefabTooltipController {
this.#addedVersions = addedVersions;
this.#latestAddedVersion = addedVersions.then(latestAddedVersion);

// Track Shift globally so the hint highlight flips the moment the modifier
// is pressed or released, without needing pointer movement.
// WHY: track Shift globally so the hint highlight flips the moment the modifier is pressed or released, without needing pointer movement.
globalThis.addEventListener("keydown", (e) => {
if (e.key === "Shift") this.#setShiftActive(true);
});
Expand All @@ -123,8 +137,11 @@ export class PrefabTooltipController {
globalThis.addEventListener("blur", () => this.#setShiftActive(false));
}

// `anchor` is evaluated after the content lookup resolves, so it reflects the
// freshest pointer/camera state for the request that wins the token race.
/**
* `anchor` is evaluated after the content lookup resolves so it
* reflects the freshest pointer or camera state for the request that
* wins the token race.
*/
async showFor(
prefab: Pick<Prefab, "name">,
hints: TooltipHint[],
Expand Down
11 changes: 7 additions & 4 deletions src/lib/prefabs-filter-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ export function readPreExcludes(
return doms.preExcludes.flatMap((i) => (i.checked ? [i.value] : []));
}

// Wire the controls common to every PrefabsHandler variant: text filters,
// tier range inputs with change-suppression, pre-exclude checkboxes, and
// the language switch. Callers handle the variant-specific concerns
// (initial postMessage, marker/file integration, output dispatch).
/**
* Wires the controls common to every `PrefabsHandler` variant: text
* filters, tier range inputs with change-suppression, pre-exclude
* checkboxes, and the language switch. Callers handle the
* variant-specific concerns (initial `postMessage`, marker or file
* integration, output dispatch).
*/
export function bindPrefabsFilterControls(
doms: PrefabsFilterControlsDoms,
worker: PrefabsFilterWorker,
Expand Down
Loading