fix: actually render multiple GM icons + auto-enable GM layer + hex-subgrid layout

Three fixes on top of the multi-GM-icon scaffold from 8926f60:

1. Auto-enable the GM layer when the user picks a GM-only paint icon.
   Without this, the paint silently writes `gm-icons` to the file but
   the layer is off so nothing visually shows — gave the impression
   that multi-icon wasn't working. Same self-preservation move the
   region/faction painters already do for their own overlays.

2. The "only one icon shows up" bug. The editor was writing the full
   icon list to frontmatter correctly, but the on-map render only saw
   one icon because:
     - editor calls onChanged()
     - HexMapView schedules renderGrid via 300ms setTimeout
     - renderGrid re-reads frontmatter via getGmIconsFromFile
   …and Obsidian's metadata cache lags processFrontMatter writes by a
   tick, so the cache read sometimes returned the pre-write state.
   Fix: extend onChanged with a `gmIconsOverrides: Map<path, list>`
   third arg (parallel to the existing terrain/icon overrides). The
   editor passes the new list directly. renderGrid applies it to the
   hex's `data-gm-icons` attribute without re-reading the cache. No
   race possible.

3. Replaced the tokenGroupOffsets layout (which centered the first
   icon) with a new `GM_ICON_HEX_SUBGRID` — 7 slots in a honeycomb
   pattern (NW, NE, W, C, E, SW, SE), filled in reading order so the
   first icon lands top-left and additions flow right then down.
   Matches the user's "hex-subgrid that starts at top-left" ask.
   Icons beyond the 7th unique icon stay in frontmatter but don't
   render (storage isn't capped, layout degrades gracefully).

351 unit tests / 18 e2e pass. Manual reload required for UX check.
This commit is contained in:
isaprettycoolguy@protonmail.com 2026-06-21 10:39:58 -04:00
parent 8926f60eb7
commit e98a6ab084
2 changed files with 72 additions and 25 deletions

View file

@ -51,6 +51,7 @@ export class HexEditorModal extends HexmakerModal {
private onChanged: (
terrainOverrides?: Map<string, string | null>,
iconOverrides?: Map<string, string | null>,
gmIconsOverrides?: Map<string, string[]>,
) => void,
private options: HexEditorOptions = {},
) {
@ -537,7 +538,12 @@ export class HexEditorModal extends HexmakerModal {
const persist = async (): Promise<void> => {
await this.ensureHexNote();
await setGmIconsInFile(this.app, path, list);
this.onChanged();
// Pass the new list directly via the overrides arg so the view
// can update the hex's data-gm-icons attribute immediately
// without round-tripping through the metadata cache (which lags
// file writes by a tick — that race meant rapid multi-add
// clicks could silently lose icons in the on-map render).
this.onChanged(undefined, undefined, new Map([[path, [...list]]]));
};
const makeTile = (icon: string | null): HTMLElement => {

View file

@ -104,6 +104,32 @@ function nullOverrides(paths: string[]): Map<string, null> {
return new Map(paths.map((p) => [p, null]));
}
/**
* GM icon sub-grid positions inside a hex. 7 slots arranged as a small
* honeycomb (top-row of 2, middle-row of 3, bottom-row of 2), filled
* in reading order first icon lands top-left, second top-right, etc.
* Values are unit offsets in [-1, +1]; multiply by the hex's
* half-width/half-height (`spread`) to get pixel offsets.
*
* Layout:
* 0 1
* 2 3 4
* 5 6
*
* Used by `renderPathOverlay`'s GM-icon section so multiple icons on
* the same hex form a hex-shaped cluster (matches the user's request
* in hexmaker#28 for "a sort of grid, a hex-subgrid").
*/
const GM_ICON_HEX_SUBGRID: [number, number][] = [
[-0.34, -0.55], // NW
[+0.34, -0.55], // NE
[-0.62, 0.00], // W
[ 0.00, 0.00], // C
[+0.62, 0.00], // E
[-0.34, +0.55], // SW
[+0.34, +0.55], // SE
];
// Returns [dx, dy] unit offsets (multiply by spread radius) for N tokens on one hex.
// Presets keep 1-5 tokens visually distinct; 6+ use an even radial ring.
function tokenGroupOffsets(n: number): [number, number][] {
@ -1297,6 +1323,19 @@ export class HexMapView extends ItemView {
this.paintIconGmOnly = gmOnly;
this.paintTerrainName = null;
this.paintBrushSize = 1;
// Auto-enable the GM layer when the user picks a GM-only paint —
// otherwise their painted icons are silently invisible because the
// layer is hidden. Mirrors the showFactionOverlay-on-paint
// auto-enable in onHexFactionPaintClick.
if (gmOnly) {
const map = this.getActiveMap();
if (!(map.showGmLayer ?? true)) {
map.showGmLayer = true;
void this.plugin.saveSettings();
this.overlayPanel?.syncToRegion();
this.updateGmIcons();
}
}
this.updateToolbarButtonStates();
},
this.paintIconGmOnly,
@ -2561,6 +2600,7 @@ export class HexMapView extends ItemView {
renderGrid(
terrainOverrides?: Map<string, string | null>,
iconOverrides?: Map<string, string | null>,
gmIconsOverrides?: Map<string, string[]>,
): void {
if (!this.viewportEl) return;
this.factionTooltipEl?.hide();
@ -2673,8 +2713,12 @@ export class HexMapView extends ItemView {
// GM icons stored as a JSON-encoded array on the dataset; allows
// multiple icons per hex (with duplicates representing a count).
// Empty list → no attribute at all so the overlay query still
// matches only hexes that have GM content.
const gmIcons = getGmIconsFromFile(this.app, path);
// matches only hexes that have GM content. The override path
// bypasses the metadata-cache read so the editor's just-written
// list lands on the dataset immediately, no race.
const gmIcons = gmIconsOverrides?.has(path)
? gmIconsOverrides.get(path)!
: getGmIconsFromFile(this.app, path);
if (gmIcons.length > 0) hexEl.dataset.gmIcons = JSON.stringify(gmIcons);
if (this.selectedHex?.x === x && this.selectedHex?.y === y)
@ -2763,9 +2807,9 @@ export class HexMapView extends ItemView {
x,
y,
this.activeMapName,
(t, i) => {
if (t !== undefined || i !== undefined) {
this.renderGrid(t, i);
(t, i, gmIcons) => {
if (t !== undefined || i !== undefined || gmIcons !== undefined) {
this.renderGrid(t, i, gmIcons);
} else {
window.setTimeout(() => this.renderGrid(), 300);
}
@ -4120,13 +4164,12 @@ export class HexMapView extends ItemView {
// GM layer icons — additive badges, terrain icon untouched.
// hexmaker#28: a hex can carry MULTIPLE GM icons (with duplicates
// representing a count of that icon). Each unique icon gets a slot
// in a hex-subgrid arrangement (reuses tokenGroupOffsets, the same
// function tokens use, so multi-icon layout stays visually
// consistent across the two systems). A "×N" badge appears when
// count > 1. Icons beyond the 5th unique icon overflow into an even
// radial ring; storage isn't capped, the layout just degrades
// gracefully.
// representing a count of that icon). Each unique icon takes one
// slot from the GM_ICON_HEX_SUBGRID, filled in reading order so the
// FIRST added icon lands top-left and additions flow right then
// down. Up to 7 unique icons display; further additions are kept
// in frontmatter but don't render. A "×N" badge appears on a slot
// when its icon was added 2+ times.
if (gmLayerActive) {
gridContainer
.querySelectorAll<HTMLElement>("[data-gm-icons]")
@ -4140,26 +4183,24 @@ export class HexMapView extends ItemView {
// Count occurrences per icon, preserving first-seen order.
const counts = new Map<string, number>();
for (const ic of list) counts.set(ic, (counts.get(ic) ?? 0) + 1);
const slots = Array.from(counts.entries());
const slots = Array.from(counts.entries()).slice(0, GM_ICON_HEX_SUBGRID.length);
const slotCount = slots.length;
const w = hexEl.offsetWidth;
const h = hexEl.offsetHeight;
// Spread radius: fraction of the hex's short dimension. Matches
// the token-layer constant for visual consistency.
const spread = Math.min(w, h) * 0.28;
// Icon size shrinks with slot count so multiple icons share a
// hex without overlapping. Single-icon hexes keep ~legacy size.
const sizeFactor = slotCount === 1 ? 0.38
: slotCount === 2 ? 0.32
: slotCount === 3 ? 0.28
: slotCount === 4 ? 0.26
// Spread = half the hex's short dimension so the sub-grid fits
// comfortably inside the parent hex's bounds.
const spread = Math.min(w, h) * 0.42;
// Icon size shrinks as the sub-grid fills so neighbouring icons
// don't overlap. Single-icon hexes keep ~legacy size.
const sizeFactor = slotCount === 1 ? 0.40
: slotCount <= 2 ? 0.30
: slotCount <= 4 ? 0.26
: 0.22;
const size = Math.round(w * sizeFactor);
const offsets = tokenGroupOffsets(slotCount);
slots.forEach(([iconName, count], slotIdx) => {
const [odx, ody] = offsets[slotIdx];
const [odx, ody] = GM_ICON_HEX_SUBGRID[slotIdx];
// Icons are positioned by top-left corner; subtract half the
// size so the icon CENTER lands on the sub-grid slot.
const ix = pos.cx + odx * spread - size / 2;