mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
perf: cut redundant per-item work on map + table load
Map grid: the per-hex render loop called getTerrainFromFile + getIconOverrideFromFile + getGmIconsFromFile, each re-running getAbstractFileByPath + getFileCache — 3 identical cache lookups per hex (~11.5k redundant ops on a 3843-hex map). Fetch frontmatter once and feed three new pure extractors (terrainFromFm/iconOverrideFromFm/gmIconsFromFm). Reuse the bg-image layer across renders (bgLayerEl/bgLayerSrc) so renderGrid()'s viewportEl.empty() no longer re-decodes the image each time. Tag painted hexes with .duckmage-hex-painted (setHexColor) and switch the bg-image empty-hex rule to a class selector instead of a slower :not([style*="background-color"]) attribute-substring match. Table view: memoise the name→entry palette Map per region instead of rebuilding it per row, and re-filter only the rows just filled in each batch (applyFilters(rows?)) instead of re-scanning all rows after every batch. Adds dev/bg-hex-recalc-bench + unit tests for the new pure extractors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e74bc0d6f6
commit
b69131f16d
7 changed files with 340 additions and 36 deletions
125
dev/bg-hex-recalc-bench.html
Normal file
125
dev/bg-hex-recalc-bench.html
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { margin: 0; font-family: monospace; }
|
||||
.viewport { position: relative; will-change: transform; }
|
||||
.bg-layer {
|
||||
position: absolute; top: 0; left: 0; transform-origin: 0 0;
|
||||
transform: scale(4.84); z-index: 0; pointer-events: none;
|
||||
}
|
||||
.bg-layer img { display: block; max-width: none; max-height: none; }
|
||||
.grid { position: relative; display: flex; flex-direction: row; align-items: flex-start; }
|
||||
.col { display: flex; flex-direction: column; margin-right: -1.1em; }
|
||||
.col-offset { margin-top: 1.9em; }
|
||||
.hex {
|
||||
width: 4.4em; height: 3.81em; font-size: 8px;
|
||||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||||
background-color: #2a4d3a;
|
||||
position: relative;
|
||||
}
|
||||
.hex:hover::after { content: ""; position: absolute; inset: 0; background: rgba(255,255,255,.2); }
|
||||
|
||||
/* ── The two competing rules — only ONE is active at a time ── */
|
||||
/* A: attribute-substring selector (current code) */
|
||||
.mode-attr.has-bg-image .hex:not([style*="background-color"]) { background-color: transparent; }
|
||||
/* B: class selector (proposed) */
|
||||
.mode-class.has-bg-image .hex:not(.has-terrain) { background-color: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
const COLS = 63, ROWS = 61; // chult map dimensions
|
||||
const TERRAIN_FRACTION = 0.55; // ~half painted, rest show the image
|
||||
const HEX_COUNT = COLS * ROWS;
|
||||
const root = document.getElementById("root");
|
||||
|
||||
// A 1107×1499 image analogue (canvas data URL — comparable paint cost, no fetch).
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 1107; c.height = 1499;
|
||||
const ctx = c.getContext("2d");
|
||||
const grd = ctx.createLinearGradient(0, 0, 1107, 1499);
|
||||
grd.addColorStop(0, "#3b5"); grd.addColorStop(1, "#247");
|
||||
ctx.fillStyle = grd; ctx.fillRect(0, 0, 1107, 1499);
|
||||
const IMG_URL = c.toDataURL("image/png");
|
||||
|
||||
let painted = 0;
|
||||
// Build the full map subtree once, fresh — this is what renderGrid() does on
|
||||
// open: empty() the viewport then recreate everything. `config`:
|
||||
// "none" → no bg image, no has-bg-image class (the "fast" baseline)
|
||||
// "attr" → bg image + has-bg-image + the current [style*=…] empty-hex rule
|
||||
// "class" → bg image + has-bg-image + the proposed .has-terrain rule
|
||||
function build(config) {
|
||||
root.className = "";
|
||||
if (config !== "none") root.classList.add("has-bg-image", config === "attr" ? "mode-attr" : "mode-class");
|
||||
root.replaceChildren();
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "viewport";
|
||||
if (config !== "none") {
|
||||
const bg = document.createElement("div");
|
||||
bg.className = "bg-layer";
|
||||
const img = document.createElement("img");
|
||||
img.src = IMG_URL;
|
||||
bg.appendChild(img);
|
||||
viewport.appendChild(bg);
|
||||
}
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "grid";
|
||||
painted = 0;
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "col" + (x % 2 ? " col-offset" : "");
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
const hex = document.createElement("div");
|
||||
hex.className = "hex";
|
||||
if (((x * 31 + y * 17) % 100) / 100 < TERRAIN_FRACTION) {
|
||||
hex.style.backgroundColor = "#2a4d3a"; // inline → matches [style*=…]
|
||||
hex.classList.add("has-terrain"); // class → matches :not(.has-terrain)
|
||||
painted++;
|
||||
}
|
||||
col.appendChild(hex);
|
||||
}
|
||||
grid.appendChild(col);
|
||||
}
|
||||
viewport.appendChild(grid);
|
||||
root.appendChild(viewport);
|
||||
}
|
||||
|
||||
// Time a full rebuild + forced style/layout flush — the open-time cost.
|
||||
function bench(config, iters) {
|
||||
for (let i = 0; i < 3; i++) { build(config); void document.body.offsetHeight; } // warm
|
||||
const samples = [];
|
||||
for (let i = 0; i < iters; i++) {
|
||||
const t0 = performance.now();
|
||||
build(config);
|
||||
void document.body.offsetHeight; // force recalc + layout
|
||||
samples.push(performance.now() - t0);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return {
|
||||
median: samples[Math.floor(samples.length / 2)],
|
||||
mean: samples.reduce((s, v) => s + v, 0) / samples.length,
|
||||
p95: samples[Math.floor(samples.length * 0.95)],
|
||||
};
|
||||
}
|
||||
|
||||
window.__runBench = (iters = 40) => {
|
||||
const none1 = bench("none", iters);
|
||||
const attr1 = bench("attr", iters);
|
||||
const cls1 = bench("class", iters);
|
||||
const none2 = bench("none", iters);
|
||||
const attr2 = bench("attr", iters);
|
||||
const cls2 = bench("class", iters);
|
||||
const avg = (a, b, k) => (a[k] + b[k]) / 2;
|
||||
const pack = (a, b) => ({ median: avg(a, b, "median"), mean: avg(a, b, "mean"), p95: avg(a, b, "p95") });
|
||||
return {
|
||||
hexCount: HEX_COUNT, painted, empty: HEX_COUNT - painted, iters,
|
||||
none: pack(none1, none2), attr: pack(attr1, attr2), cls: pack(cls1, cls2),
|
||||
};
|
||||
};
|
||||
document.title = "ready";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
32
dev/bg-hex-recalc-bench.mjs
Normal file
32
dev/bg-hex-recalc-bench.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env node
|
||||
/** Headless benchmark: isolates the style-recalc cost of the current
|
||||
* `:not([style*="background-color"])` attribute-substring selector vs a
|
||||
* class-based `:not(.has-terrain)` selector, at the real chult map's hex
|
||||
* count (63×61). A :hover state change over the grid forces exactly this
|
||||
* recalc, so this measures the per-mousemove jank a bg image introduces.
|
||||
*
|
||||
* Relative A/B only — absolute ms will differ from the user's machine, but
|
||||
* the ratio between the two selectors is what we're after.
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { withBrowser } from "/mnt/c/Users/markr/work/tools/chromium-driver/lib/browser.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sandbox = path.join(here, "bg-hex-recalc-bench.html");
|
||||
|
||||
const result = await withBrowser(async ({ page }) => {
|
||||
await page.goto(pathToFileURL(sandbox).href, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.title === "ready", { timeout: 10000 });
|
||||
return page.evaluate(() => window.__runBench(40));
|
||||
});
|
||||
|
||||
const f = (n) => n.toFixed(2).padStart(8);
|
||||
console.log(`\nFull grid rebuild + layout flush (the renderGrid open-time cost)`);
|
||||
console.log(`hexes=${result.hexCount} painted=${result.painted} empty=${result.empty} iters=${result.iters}\n`);
|
||||
console.log(` median mean p95 (ms)`);
|
||||
console.log(`no bg image ${f(result.none.median)} ${f(result.none.mean)} ${f(result.none.p95)} baseline`);
|
||||
console.log(`bg + attr selector ${f(result.attr.median)} ${f(result.attr.mean)} ${f(result.attr.p95)} (current)`);
|
||||
console.log(`bg + class selector ${f(result.cls.median)} ${f(result.cls.mean)} ${f(result.cls.p95)} (proposed)`);
|
||||
console.log(`\nbg overhead (attr vs none): ${(result.attr.median - result.none.median).toFixed(2)} ms/render`);
|
||||
console.log(`selector saving (attr→class): ${(result.attr.median - result.cls.median).toFixed(2)} ms/render\n`);
|
||||
|
|
@ -59,9 +59,33 @@ export function getFrontMatter(app: App, path: string) {
|
|||
return frontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure extractors that read already-fetched frontmatter. The hex-grid render
|
||||
* loop fetches frontmatter ONCE per hex (getAbstractFileByPath + getFileCache)
|
||||
* and feeds it to all three — instead of calling the three `*FromFile` helpers
|
||||
* below, which each re-fetch the same cache (3× the lookups per hex, ~11.5k
|
||||
* redundant ops on a 3843-hex map).
|
||||
*/
|
||||
export function terrainFromFm(fm: Frontmatter | null | undefined): string | null {
|
||||
return typeof fm?.terrain === "string" ? fm.terrain : null;
|
||||
}
|
||||
|
||||
export function iconOverrideFromFm(fm: Frontmatter | null | undefined): string | null {
|
||||
return typeof fm?.icon === "string" ? fm.icon : null;
|
||||
}
|
||||
|
||||
export function gmIconsFromFm(fm: Frontmatter | null | undefined): string[] {
|
||||
if (!fm) return [];
|
||||
const arr = fm["gm-icons"];
|
||||
if (Array.isArray(arr)) {
|
||||
return arr.filter((v): v is string => typeof v === "string");
|
||||
}
|
||||
const legacy = fm["gm-icon"];
|
||||
return typeof legacy === "string" ? [legacy] : [];
|
||||
}
|
||||
|
||||
export function getTerrainFromFile(app: App, path: string): string | null {
|
||||
const terrain = getFrontMatter(app, path)?.terrain;
|
||||
return typeof terrain === "string" ? terrain : null;
|
||||
return terrainFromFm(getFrontMatter(app, path));
|
||||
}
|
||||
|
||||
export async function setTerrainInFile(
|
||||
|
|
@ -224,8 +248,7 @@ export async function setRegionStyleInFile(
|
|||
}
|
||||
|
||||
export function getIconOverrideFromFile(app: App, path: string): string | null {
|
||||
const icon = getFrontMatter(app, path)?.icon;
|
||||
return typeof icon === "string" ? icon : null;
|
||||
return iconOverrideFromFm(getFrontMatter(app, path));
|
||||
}
|
||||
|
||||
export async function setIconOverrideInFile(
|
||||
|
|
@ -263,14 +286,7 @@ export function getGmIconFromFile(app: App, path: string): string | null {
|
|||
* singular key `gm-icon` if present so existing notes Just Work.
|
||||
*/
|
||||
export function getGmIconsFromFile(app: App, path: string): string[] {
|
||||
const fm = getFrontMatter(app, path);
|
||||
if (!fm) return [];
|
||||
const arr = fm["gm-icons"];
|
||||
if (Array.isArray(arr)) {
|
||||
return arr.filter((v): v is string => typeof v === "string");
|
||||
}
|
||||
const legacy = fm["gm-icon"];
|
||||
return typeof legacy === "string" ? [legacy] : [];
|
||||
return gmIconsFromFm(getFrontMatter(app, path));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { normalizeFolder, getIconUrl, createIconEl } from "../utils";
|
|||
import {
|
||||
getTerrainFromFile,
|
||||
getIconOverrideFromFile,
|
||||
getGmIconsFromFile,
|
||||
getFrontMatter,
|
||||
terrainFromFm,
|
||||
iconOverrideFromFm,
|
||||
gmIconsFromFm,
|
||||
setGmIconsInFile,
|
||||
setTerrainInFile,
|
||||
setIconOverrideInFile,
|
||||
|
|
@ -178,6 +181,10 @@ export class HexMapView extends ItemView {
|
|||
private pendingZoomPivot: { cx: number; cy: number } | null = null;
|
||||
private zoomFrameId: number | null = null;
|
||||
private viewportEl: HTMLElement | null = null;
|
||||
// Cached bg-image layer reused across renders (see renderBackgroundImage) so
|
||||
// renderGrid()'s viewportEl.empty() doesn't force an image re-decode each time.
|
||||
private bgLayerEl: HTMLElement | null = null;
|
||||
private bgLayerSrc: string | null = null;
|
||||
private drawingMode:
|
||||
| "path"
|
||||
| "terrain"
|
||||
|
|
@ -2037,16 +2044,42 @@ export class HexMapView extends ItemView {
|
|||
const bg = map.backgroundImage;
|
||||
viewportEl.toggleClass("has-bg-image", !!bg?.path);
|
||||
viewportEl.toggleClass("is-bg-calibrating", this.bgCalibrating && !!bg?.path);
|
||||
if (!bg?.path) return;
|
||||
if (!bg?.path) {
|
||||
this.bgLayerEl = null;
|
||||
this.bgLayerSrc = null;
|
||||
return;
|
||||
}
|
||||
const file = this.app.vault.getAbstractFileByPath(bg.path);
|
||||
if (!(file instanceof TFile)) return;
|
||||
if (!(file instanceof TFile)) {
|
||||
this.bgLayerEl = null;
|
||||
this.bgLayerSrc = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const src = this.app.vault.adapter.getResourcePath(bg.path);
|
||||
|
||||
// Reuse the cached layer+img across renders when the image is unchanged.
|
||||
// renderGrid() calls viewportEl.empty() every render, which detaches the
|
||||
// layer but leaves our JS reference (and its already-decoded bitmap) alive.
|
||||
// Re-attaching it avoids recreating the <img> and forcing the browser to
|
||||
// re-decode + re-rasterise the (large, scaled) image on every single
|
||||
// render — the churn that makes editing a bg-image map sluggish. We skip
|
||||
// reuse during calibration so its drag/wheel handlers are wired exactly as
|
||||
// before (fresh layer each render, as the old code did).
|
||||
if (this.bgLayerEl && this.bgLayerSrc === src && !this.bgCalibrating) {
|
||||
viewportEl.prepend(this.bgLayerEl);
|
||||
this.applyBgLayerVars(this.bgLayerEl, bg);
|
||||
return;
|
||||
}
|
||||
|
||||
const layer = viewportEl.createDiv({ cls: "duckmage-bg-image-layer" });
|
||||
this.applyBgLayerVars(layer, bg);
|
||||
const img = layer.createEl("img", { cls: "duckmage-bg-image" });
|
||||
img.src = this.app.vault.adapter.getResourcePath(bg.path);
|
||||
img.src = src;
|
||||
img.alt = "";
|
||||
img.draggable = false;
|
||||
this.bgLayerEl = layer;
|
||||
this.bgLayerSrc = src;
|
||||
|
||||
if (this.bgCalibrating) this.attachCalibrationHandlers(layer, map);
|
||||
}
|
||||
|
|
@ -2818,9 +2851,15 @@ export class HexMapView extends ItemView {
|
|||
const addHex = (parent: HTMLElement, x: number, y: number) => {
|
||||
const path = folder ? `${folder}/${x}_${y}.md` : `${x}_${y}.md`;
|
||||
const exists = existingHexPaths.has(path);
|
||||
// Fetch this hex's frontmatter ONCE; terrain/icon/gm-icons all derive
|
||||
// from it. Calling getTerrainFromFile + getIconOverrideFromFile +
|
||||
// getGmIconsFromFile separately would re-run getAbstractFileByPath +
|
||||
// getFileCache three times per hex (~11.5k redundant lookups on a
|
||||
// 3843-hex map).
|
||||
const fm = getFrontMatter(this.app, path);
|
||||
const terrainKey = terrainOverrides?.has(path)
|
||||
? terrainOverrides.get(path)!
|
||||
: getTerrainFromFile(this.app, path);
|
||||
: terrainFromFm(fm);
|
||||
const terrainEntry = terrainKey != null ? paletteByName.get(terrainKey) : undefined;
|
||||
|
||||
const hexEl = parent.createDiv({
|
||||
|
|
@ -2829,11 +2868,11 @@ export class HexMapView extends ItemView {
|
|||
});
|
||||
hexEl.tabIndex = -1;
|
||||
|
||||
if (terrainEntry?.color) hexEl.style.backgroundColor = terrainEntry.color;
|
||||
this.setHexColor(hexEl, terrainEntry?.color);
|
||||
|
||||
const iconOverride = iconOverrides?.has(path)
|
||||
? iconOverrides.get(path)!
|
||||
: getIconOverrideFromFile(this.app, path);
|
||||
: iconOverrideFromFm(fm);
|
||||
if (iconOverride) {
|
||||
// Render terrain icon as hidden fallback — shown by CSS when overrides are off
|
||||
if (terrainEntry?.icon) {
|
||||
|
|
@ -2874,7 +2913,7 @@ export class HexMapView extends ItemView {
|
|||
// list lands on the dataset immediately, no race.
|
||||
const gmIcons = gmIconsOverrides?.has(path)
|
||||
? gmIconsOverrides.get(path)!
|
||||
: getGmIconsFromFile(this.app, path);
|
||||
: gmIconsFromFm(fm);
|
||||
if (gmIcons.length > 0) hexEl.dataset.gmIcons = JSON.stringify(gmIcons);
|
||||
|
||||
if (this.selectedHex?.x === x && this.selectedHex?.y === y)
|
||||
|
|
@ -3232,7 +3271,7 @@ export class HexMapView extends ItemView {
|
|||
`[data-x="${hx}"][data-y="${hy}"]`,
|
||||
);
|
||||
if (hexEl) {
|
||||
hexEl.style.backgroundColor = entry?.color ?? "";
|
||||
this.setHexColor(hexEl, entry?.color);
|
||||
hexEl.querySelector(".duckmage-hex-icon")?.remove();
|
||||
hexEl.querySelector(".duckmage-hex-dot")?.remove();
|
||||
if (entry?.icon) {
|
||||
|
|
@ -3567,6 +3606,24 @@ export class HexMapView extends ItemView {
|
|||
// writes: the in-flight one and then the final value. No writes are lost; no
|
||||
// stale intermediate value can overwrite a newer one.
|
||||
|
||||
/**
|
||||
* Set (or clear) a hex cell's terrain background color, keeping the
|
||||
* `duckmage-hex-painted` marker class in sync. The bg-image stylesheet uses
|
||||
* that class — rather than a `:not([style*="background-color"])`
|
||||
* attribute-substring selector, which is measurably slower for the engine to
|
||||
* match across the thousands of hexes on a large map — to decide which empty
|
||||
* hexes fall transparent over the image.
|
||||
*/
|
||||
private setHexColor(hexEl: HTMLElement, color: string | undefined | null): void {
|
||||
if (color) {
|
||||
hexEl.style.backgroundColor = color;
|
||||
hexEl.addClass("duckmage-hex-painted");
|
||||
} else {
|
||||
hexEl.style.removeProperty("background-color");
|
||||
hexEl.removeClass("duckmage-hex-painted");
|
||||
}
|
||||
}
|
||||
|
||||
private applyTerrainToHexEl(
|
||||
hexEl: HTMLElement,
|
||||
terrain: string | null,
|
||||
|
|
@ -3574,7 +3631,7 @@ export class HexMapView extends ItemView {
|
|||
const palette = this.plugin.getMapPalette(this.activeMapName);
|
||||
const entry =
|
||||
terrain != null ? palette.find((p) => p.name === terrain) : undefined;
|
||||
hexEl.style.backgroundColor = entry?.color ?? "";
|
||||
this.setHexColor(hexEl, entry?.color);
|
||||
hexEl.querySelector(".duckmage-hex-icon")?.remove();
|
||||
hexEl.querySelector(".duckmage-hex-dot")?.remove();
|
||||
if (entry?.icon) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
VIEW_TYPE_HEX_TABLE,
|
||||
VIEW_TYPE_RANDOM_TABLES,
|
||||
} from "../constants";
|
||||
import type { TerrainColor } from "../types";
|
||||
import { getAllSectionData } from "../sections";
|
||||
import { getTerrainFromFile } from "../frontmatter";
|
||||
import { normalizeFolder, makeTableTemplate } from "../utils";
|
||||
|
|
@ -39,6 +40,11 @@ export class HexTableView extends ItemView {
|
|||
private scrollEl: HTMLElement | null = null;
|
||||
private updateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private loadGeneration = 0;
|
||||
// Memoised name→entry palette maps, keyed by region. fillRow runs once per
|
||||
// hex per load (skeleton + fill batch) so rebuilding this Map inline cost
|
||||
// O(rows × palette) Map insertions; memoising makes it O(palette) per region.
|
||||
// Cleared at the start of every loadTable so palette edits take effect.
|
||||
private paletteMapCache = new Map<string, Map<string, TerrainColor>>();
|
||||
|
||||
// Sort state
|
||||
private sortPrimary: "x" | "y" = "x";
|
||||
|
|
@ -361,6 +367,7 @@ export class HexTableView extends ItemView {
|
|||
async loadTable(): Promise<void> {
|
||||
if (!this.scrollEl) return;
|
||||
const gen = ++this.loadGeneration;
|
||||
this.paletteMapCache.clear();
|
||||
|
||||
this.scrollEl.empty();
|
||||
this.scrollEl.createSpan({
|
||||
|
|
@ -477,12 +484,15 @@ export class HexTableView extends ItemView {
|
|||
slice.map((f) => getAllSectionData(this.app, f.path)),
|
||||
);
|
||||
if (gen !== this.loadGeneration) return false;
|
||||
const batchRows: HTMLTableRowElement[] = [];
|
||||
for (let j = 0; j < slice.length; j++) {
|
||||
const { path, x, y, region } = slice[j];
|
||||
const { text, links } = sectionData[j];
|
||||
this.fillRow(rows[start + j], path, x, y, region, text, links);
|
||||
batchRows.push(rows[start + j]);
|
||||
}
|
||||
this.applyFilters();
|
||||
// Only the rows we just filled gained data — re-filter just those.
|
||||
this.applyFilters(batchRows);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -555,12 +565,19 @@ export class HexTableView extends ItemView {
|
|||
this.applyFilters();
|
||||
}
|
||||
|
||||
private applyFilters(): void {
|
||||
/**
|
||||
* Re-evaluate row visibility against the current filters. Each row's
|
||||
* visibility depends only on its own dataset, so a `rows` subset can be
|
||||
* passed to filter just newly-filled rows — `loadTable` does this after each
|
||||
* fill batch so Phase 2 stays O(rows) total instead of O(rows × batches).
|
||||
* Called with no argument (all rows) when the filter state itself changes.
|
||||
*/
|
||||
private applyFilters(rows?: Iterable<HTMLTableRowElement>): void {
|
||||
if (!this.scrollEl) return;
|
||||
const tbody = this.scrollEl.querySelector("tbody");
|
||||
if (!tbody) return;
|
||||
|
||||
for (const tr of Array.from(tbody.rows)) {
|
||||
for (const tr of rows ?? Array.from(tbody.rows)) {
|
||||
const x = Number(tr.dataset.hexX);
|
||||
const y = Number(tr.dataset.hexY);
|
||||
const terrain = tr.dataset.terrain ?? "";
|
||||
|
|
@ -594,6 +611,22 @@ export class HexTableView extends ItemView {
|
|||
|
||||
// ── Row rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Name→entry palette map for a region, memoised for the current load.
|
||||
* First-wins on duplicate names — matches HexMapView's `.find()` behaviour.
|
||||
*/
|
||||
private getPaletteMap(region: string): Map<string, TerrainColor> {
|
||||
let m = this.paletteMapCache.get(region);
|
||||
if (!m) {
|
||||
m = new Map<string, TerrainColor>();
|
||||
for (const p of this.plugin.getMapPalette(region)) {
|
||||
if (!m.has(p.name)) m.set(p.name, p);
|
||||
}
|
||||
this.paletteMapCache.set(region, m);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private fillRow(
|
||||
tr: HTMLTableRowElement,
|
||||
path: string,
|
||||
|
|
@ -605,13 +638,7 @@ export class HexTableView extends ItemView {
|
|||
): void {
|
||||
tr.empty();
|
||||
|
||||
const palette = this.plugin.getMapPalette(region);
|
||||
// First-wins on duplicate-named palette entries — matches HexMapView's
|
||||
// .find() behaviour.
|
||||
const paletteMap = new Map<string, typeof palette[number]>();
|
||||
for (const p of palette) {
|
||||
if (!paletteMap.has(p.name)) paletteMap.set(p.name, p);
|
||||
}
|
||||
const paletteMap = this.getPaletteMap(region);
|
||||
const terrainName = getTerrainFromFile(this.app, path);
|
||||
|
||||
const hasTown = (links.get("towns") ?? []).length > 0;
|
||||
|
|
|
|||
|
|
@ -268,9 +268,12 @@
|
|||
.duckmage-calibration-handle-se { bottom: -9px; right: -9px; cursor: nwse-resize; transform: scale(var(--duckmage-cal-scale, 1)); }
|
||||
|
||||
/* When a background image is present, hexes without a terrain become visually
|
||||
transparent so the underlying image shows through. Hexes with a terrain
|
||||
color set inline (via style="background-color: …") render normally. */
|
||||
.duckmage-hex-map-viewport.has-bg-image .duckmage-hex:not([style*="background-color"]) {
|
||||
transparent so the underlying image shows through. Hexes with a terrain are
|
||||
tagged `.duckmage-hex-painted` (see setHexColor) and render normally. A class
|
||||
selector is used here rather than a `:not([style*="background-color"])`
|
||||
attribute-substring match because the engine matches it far more cheaply
|
||||
across the thousands of hexes on a large map. */
|
||||
.duckmage-hex-map-viewport.has-bg-image .duckmage-hex:not(.duckmage-hex-painted) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import expect from "expect";
|
||||
import { TFile } from "obsidian";
|
||||
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile, getSubmapFromFile, setSubmapInFile } from "../src/frontmatter";
|
||||
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile, getSubmapFromFile, setSubmapInFile, terrainFromFm, iconOverrideFromFm, gmIconsFromFm } from "../src/frontmatter";
|
||||
|
||||
/** Build a minimal mock App backed by an in-memory string. */
|
||||
function makeApp(filePath: string, initialContent: string) {
|
||||
|
|
@ -359,3 +359,47 @@ describe("updateSubmapReferences", () => {
|
|||
expect(getContent("world/hexes/map-a/1_1.md")).toContain("duckmage-submap: other-map");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pure frontmatter extractors (used by the hex-grid render loop, which
|
||||
// fetches frontmatter once per hex and feeds it to all three) ──────────────
|
||||
|
||||
describe("terrainFromFm", () => {
|
||||
it("returns the terrain string", () => {
|
||||
expect(terrainFromFm({ terrain: "forest" } as any)).toBe("forest");
|
||||
});
|
||||
it("returns null for missing/non-string/nullish frontmatter", () => {
|
||||
expect(terrainFromFm({ icon: "x.png" } as any)).toBeNull();
|
||||
expect(terrainFromFm({ terrain: 3 } as any)).toBeNull();
|
||||
expect(terrainFromFm(null)).toBeNull();
|
||||
expect(terrainFromFm(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("iconOverrideFromFm", () => {
|
||||
it("returns the icon string", () => {
|
||||
expect(iconOverrideFromFm({ icon: "castle.png" } as any)).toBe("castle.png");
|
||||
});
|
||||
it("returns null for missing/non-string/nullish frontmatter", () => {
|
||||
expect(iconOverrideFromFm({ terrain: "forest" } as any)).toBeNull();
|
||||
expect(iconOverrideFromFm({ icon: true } as any)).toBeNull();
|
||||
expect(iconOverrideFromFm(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gmIconsFromFm", () => {
|
||||
it("returns the gm-icons array, filtered to strings", () => {
|
||||
expect(gmIconsFromFm({ "gm-icons": ["a.png", "b.png"] } as any)).toEqual(["a.png", "b.png"]);
|
||||
expect(gmIconsFromFm({ "gm-icons": ["a.png", 2, null, "b.png"] } as any)).toEqual(["a.png", "b.png"]);
|
||||
});
|
||||
it("falls back to legacy single gm-icon", () => {
|
||||
expect(gmIconsFromFm({ "gm-icon": "legacy.png" } as any)).toEqual(["legacy.png"]);
|
||||
});
|
||||
it("prefers gm-icons array over legacy gm-icon", () => {
|
||||
expect(gmIconsFromFm({ "gm-icons": ["a.png"], "gm-icon": "legacy.png" } as any)).toEqual(["a.png"]);
|
||||
});
|
||||
it("returns [] for missing/nullish frontmatter", () => {
|
||||
expect(gmIconsFromFm({} as any)).toEqual([]);
|
||||
expect(gmIconsFromFm(null)).toEqual([]);
|
||||
expect(gmIconsFromFm(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue