sbuffkin_hexmaker/src/hex-map/HexMapView.ts
isaprettycoolguy@protonmail.com 31d3256192 add submap links: link hex to another map, centre-dot nav, back button, map link insert
- Right-click hex → "Link submap" writes duckmage-submap frontmatter
- Hex flower shows centre dot (hidden when no link) that navigates into the submap
- Centre dot centering is orientation-aware (flat-top top:24px, pointy-top top:20px)
- ← Back button appears next to map dropdown (via flex nav group) after submap drill-in
- "Insert map link" editor context menu inserts obsidian://duckmage-openmap URI
- Map rename updates all duckmage-submap references via updateSubmapReferences()
- HexEditorOptions refactored: onNavigate/onModalClose/onSwitchMap in options object
- 10 new tests for submap frontmatter helpers and rename propagation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:29:22 -04:00

3551 lines
127 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ItemView, Menu, Notice, TFile, WorkspaceLeaf } from "obsidian";
import type HexmakerPlugin from "../HexmakerPlugin";
import { normalizeFolder, getIconUrl, createIconEl } from "../utils";
import {
getTerrainFromFile,
getIconOverrideFromFile,
getGmIconFromFile,
setGmIconInFile,
setTerrainInFile,
setIconOverrideInFile,
Frontmatter,
} from "../frontmatter";
import { HexEditorModal } from "./HexEditorModal";
import { TerrainPickerModal } from "./TerrainPickerModal";
import { IconPickerModal } from "./IconPickerModal";
import { addLinkToSection, getLinksInSection, removeLinkFromSection } from "../sections";
import { getFactionColorFromFile, getRegionColorFromFile, setHexRegionInFile, getSubmapFromFile, setSubmapInFile } from "../frontmatter";
import {
VIEW_TYPE_HEX_MAP,
VIEW_TYPE_HEX_TABLE,
VIEW_TYPE_RANDOM_TABLES,
} from "../constants";
import { MapModal } from "./MapModal";
import { PathPickerModal } from "./PathPickerModal";
import type { MapData, PathChain, TokenEntry } from "../types";
import {
hexNeighbors,
smoothPath,
sharpPath,
buildMeanderPts,
buildEdgePts,
} from "./hexGeometry";
import { GotoHexModal } from "./GotoHexModal";
import { HexHelpModal } from "./HexHelpModal";
import { FolderTreePickerModal } from "./FolderTreePickerModal";
import { FactionPickerModal } from "./FactionPickerModal";
import { GeoRegionPickerModal } from "./GeoRegionPickerModal";
import { DrawingToolPanel, OverlayPanel } from "./HexSidePanel";
import { TokenModal } from "./TokenModal";
import { SubmapPickerModal } from "./SubmapPickerModal";
import { TokenInfoModal } from "./TokenInfoModal";
import {
getTokenDataFromCache,
applyTokenFrontmatter,
removeTokenFrontmatter,
setTokenHex,
} from "../frontmatter";
type TerrainUndoEntry = {
x: number;
y: number;
path: string;
oldTerrain: string | null;
newTerrain: string | null;
};
type UndoItem =
| { kind: "terrain"; entries: TerrainUndoEntry[] }
| { kind: "swap"; x1: number; y1: number; x2: number; y2: number }
| {
kind: "path";
mapName: string;
before: PathChain[];
after: PathChain[];
};
function nullOverrides(paths: string[]): Map<string, null> {
return new Map(paths.map((p) => [p, null]));
}
export class HexMapView extends ItemView {
plugin: HexmakerPlugin;
private zoom = 1;
private panX = 0;
private panY = 0;
private zoomSettleTimer: number | null = null;
private viewportEl: HTMLElement | null = null;
private drawingMode:
| "path"
| "terrain"
| "icon"
| "tableLink"
| "factionLink"
| "regionLink"
| "swap"
| "placeToken"
| null = null;
private pathToolbarBtn: HTMLButtonElement | null = null;
private pathBtnSwatch: HTMLElement | null = null;
private terrainToolbarBtn: HTMLButtonElement | null = null;
private terrainBtnPreview: HTMLSpanElement | null = null;
private iconToolbarBtn: HTMLButtonElement | null = null;
private iconBtnPreview: HTMLImageElement | null = null;
private tableLinkBtn: HTMLButtonElement | null = null;
private tableLinkBtnLabel: HTMLSpanElement | null = null;
private paintTablePath: string | null = null;
private factionLinkBtn: HTMLButtonElement | null = null;
private factionLinkBtnLabel: HTMLSpanElement | null = null;
private paintFactionPath: string | null = null;
private regionLinkBtn: HTMLButtonElement | null = null;
private regionLinkBtnLabel: HTMLSpanElement | null = null;
private paintRegionPath: string | null = null;
private swapBtn: HTMLButtonElement | null = null;
private deselToolBtn: HTMLButtonElement | null = null;
private overlayPanel: OverlayPanel | null = null;
private toolsPanel: DrawingToolPanel | null = null;
private swapSource: { x: number; y: number } | null = null;
private swapDest: { x: number; y: number } | null = null;
// The last-clicked hex key and the specific chain being extended
private activePathTypeName: string | null = null;
private activePathEnd: string | null = null;
private activePathChain: PathChain | null = null;
private paintTerrainName: string | null = null;
private paintIconName: string | null = null;
private paintIconGmOnly = false;
private terrainPickMode = false;
private paintBrushSize: 1 | 3 | 7 = 1;
private brushHoverHexes: Array<[number, number]> = [];
private selectedHex: { x: number; y: number } | null = null;
// Per-hex write queues: always stores the *latest* desired value so rapid
// repaints of the same hex coalesce into at most one queued write.
private pendingTerrainWrites = new Map<
string,
{ x: number; y: number; terrain: string | null }
>();
private pendingIconWrites = new Map<
string,
{ x: number; y: number; icon: string | null }
>();
private pendingGmIconWrites = new Map<
string,
{ x: number; y: number; icon: string | null }
>();
private flushing = new Set<string>(); // "t:<path>", "i:<path>", or "g:<path>"
// Faction links painted but not yet reflected in the metadata cache
private pendingFactionLinks = new Map<string, Set<string>>();
// Faction links erased but cache may still reflect them — excluded from overlay
private erasedFactionLinks = new Map<string, Set<string>>();
// Region painted/erased but not yet in metadata cache
private pendingRegions = new Map<string, string>(); // hexPath → regionBasename
private erasedRegions = new Set<string>(); // hexPaths with cleared region
private savingIndicatorEl: HTMLElement | null = null;
// Undo / redo
private readonly UNDO_DEPTH = 20;
private undoStack: UndoItem[] = [];
private redoStack: UndoItem[] = [];
private currentTerrainStroke: Map<string, TerrainUndoEntry> | null = null;
private undoBtn: HTMLButtonElement | null = null;
private redoBtn: HTMLButtonElement | null = null;
activeMapName = "default";
private mapHistory: string[] = [];
private mapBtn: HTMLButtonElement | null = null;
private backBtn: HTMLButtonElement | null = null;
private tokenEntries: TokenEntry[] = [];
private pendingTokenNotePath: string | null = null;
private pendingTokenPlaceData: { icon?: string; shape: import("../types").TokenShape; size: import("../types").TokenSize; color?: string; border?: string; description?: string } | null = null;
private tokenBtn: HTMLButtonElement | null = null;
constructor(leaf: WorkspaceLeaf, plugin: HexmakerPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return VIEW_TYPE_HEX_MAP;
}
getDisplayText(): string {
return `Hex map — ${this.activeMapName}`;
}
private getActiveMap(): MapData {
return this.plugin.getOrCreateMap(this.activeMapName);
}
private updateMapBtnLabel(): void {
this.mapBtn?.setText(`${this.activeMapName}`);
}
switchToMap(name: string): void {
this.activeMapName = name;
this.updateMapBtnLabel();
interface WithUpdateHeader { updateHeader?(): void; }
(this.leaf as unknown as WithUpdateHeader).updateHeader?.();
this.renderGrid();
}
navigateToMap(name: string): void {
this.mapHistory.push(this.activeMapName);
this.switchToMap(name);
this.refreshBackBtn();
}
private navigateBack(): void {
const prev = this.mapHistory.pop();
if (prev) this.switchToMap(prev);
this.refreshBackBtn();
}
private refreshBackBtn(): void {
if (this.mapHistory.length > 0) this.backBtn?.show();
else this.backBtn?.hide();
}
onOpen(): Promise<void> {
// Initialise to the configured default map (falls back to first map or "default")
this.activeMapName =
this.plugin.settings.defaultMap ||
this.plugin.settings.maps[0]?.name ||
"default";
const { contentEl } = this;
contentEl.addClass("duckmage-hex-map-container");
// clipEl clips the panning viewport; controlsEl overlays buttons without clipping
const clipEl = contentEl.createDiv({ cls: "duckmage-hex-map-clip" });
const controlsEl = contentEl.createDiv({
cls: "duckmage-hex-map-controls",
});
this.viewportEl = clipEl.createDiv({ cls: "duckmage-hex-map-viewport" });
this.applyTransform();
this.registerDomEvent(clipEl, "mouseleave", () => {
this.updateBrushHighlight(null, null);
});
// ── Zoom (scroll wheel, no modifier required) ──────────────────────────
this.registerDomEvent(
contentEl,
"wheel",
(e: WheelEvent) => {
e.preventDefault();
const rect = contentEl.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const newZoom = Math.min(5, Math.max(0.2, this.zoom * factor));
this.panX = cx - (cx - this.panX) * (newZoom / this.zoom);
this.panY = cy - (cy - this.panY) * (newZoom / this.zoom);
this.zoom = newZoom;
this.applyTransform();
this.scheduleZoomBake();
},
{ passive: false },
);
// ── Pan (click-drag) & Terrain drag-paint ─────────────────────────────
let isDragging = false;
let hasDragged = false;
let dragStartX = 0,
dragStartY = 0,
panStartX = 0,
panStartY = 0;
let isTerrainPainting = false;
let lastPaintedKey: string | null = null;
let isRightDragging = false;
let rightDragMoved = false;
this.registerDomEvent(contentEl, "mousedown", (e: MouseEvent) => {
// Middle click: always pan
if (e.button === 1) {
e.preventDefault(); // suppress auto-scroll cursor
isDragging = true;
hasDragged = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = this.panX;
panStartY = this.panY;
this.viewportEl?.addClass("is-dragging");
return;
}
// Right click with no active tool: pan (contextmenu suppressed if drag occurs)
if (e.button === 2 && this.drawingMode === null) {
isRightDragging = true;
rightDragMoved = false;
isDragging = true;
hasDragged = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = this.panX;
panStartY = this.panY;
this.viewportEl?.addClass("is-dragging");
return;
}
if (e.button !== 0) return;
if (this.drawingMode === "terrain" || this.drawingMode === "icon" || this.drawingMode === "factionLink" || this.drawingMode === "regionLink") {
isTerrainPainting = true;
lastPaintedKey = null;
if (this.drawingMode === "terrain")
this.currentTerrainStroke = new Map();
// Paint the hex under the cursor immediately
const hexEl = (e.target as HTMLElement).closest<HTMLElement>(
".duckmage-hex",
);
if (hexEl) {
const x = Number(hexEl.dataset.x);
const y = Number(hexEl.dataset.y);
lastPaintedKey = `${x}_${y}`;
if (this.drawingMode === "terrain") this.onHexPaintClick(x, y);
else if (this.drawingMode === "icon") this.onHexIconClick(x, y);
else if (this.drawingMode === "factionLink") void this.onHexFactionPaintClick(x, y);
else void this.onHexRegionPaintClick(x, y);
hasDragged = true; // suppress the subsequent click event
}
return; // skip pan setup
}
// Any other active tool (road, river, tableLink, swap):
// let the click event handle it — don't set up drag/pan so hasDragged
// never swallows the click.
if (this.drawingMode !== null) return;
isDragging = true;
hasDragged = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = this.panX;
panStartY = this.panY;
this.viewportEl?.addClass("is-dragging");
});
this.registerDomEvent(document, "mousemove", (e: MouseEvent) => {
if (isTerrainPainting) {
const el = document.elementFromPoint(
e.clientX,
e.clientY,
) as HTMLElement | null;
const hexEl = el?.closest<HTMLElement>(".duckmage-hex");
if (hexEl) {
const x = Number(hexEl.dataset.x);
const y = Number(hexEl.dataset.y);
const key = `${x}_${y}`;
if (key !== lastPaintedKey) {
lastPaintedKey = key;
if (this.drawingMode === "terrain") this.onHexPaintClick(x, y);
else if (this.drawingMode === "icon") this.onHexIconClick(x, y);
else if (this.drawingMode === "factionLink") void this.onHexFactionPaintClick(x, y);
else void this.onHexRegionPaintClick(x, y);
}
this.updateBrushHighlight(x, y);
} else {
this.updateBrushHighlight(null, null);
}
return;
}
if (this.drawingMode === "terrain" || this.drawingMode === "icon") {
const el = document.elementFromPoint(
e.clientX,
e.clientY,
) as HTMLElement | null;
const hexEl = el?.closest<HTMLElement>(".duckmage-hex");
if (hexEl) {
this.updateBrushHighlight(
Number(hexEl.dataset.x),
Number(hexEl.dataset.y),
);
} else {
this.updateBrushHighlight(null, null);
}
}
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
if (!hasDragged && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
hasDragged = true;
if (isRightDragging) rightDragMoved = true;
}
if (hasDragged) {
this.panX = panStartX + dx;
this.panY = panStartY + dy;
this.applyTransform();
}
});
this.registerDomEvent(document, "mouseup", () => {
if (isTerrainPainting && this.drawingMode === "terrain")
this.commitTerrainStroke();
isTerrainPainting = false;
lastPaintedKey = null;
isDragging = false;
isRightDragging = false;
this.viewportEl?.removeClass("is-dragging");
});
// Swallow clicks that ended a drag so hex click-handlers don't fire
this.registerDomEvent(
contentEl,
"click",
(e: MouseEvent) => {
if (hasDragged) {
e.stopPropagation();
hasDragged = false;
}
},
{ capture: true } as AddEventListenerOptions,
);
// Right-click: on a hex in road/river mode → let onHexContextMenu handle the delete.
// Double-right-click off a hex → exit the active tool.
let lastOffHexRightClick = 0;
this.registerDomEvent(
contentEl,
"contextmenu",
(e: MouseEvent) => {
if (rightDragMoved) {
e.preventDefault();
e.stopPropagation();
rightDragMoved = false;
return;
}
if (this.drawingMode === null) return;
const onHex = (e.target as HTMLElement).closest(".duckmage-hex");
if (onHex && (this.drawingMode === "path" || this.drawingMode === "factionLink" || this.drawingMode === "regionLink")) return;
e.preventDefault();
e.stopPropagation();
const now = Date.now();
if (now - lastOffHexRightClick < 400) {
lastOffHexRightClick = 0;
if (this.drawingMode === "terrain") this.exitTerrainMode();
else if (this.drawingMode === "icon") this.exitIconMode();
else if (this.drawingMode === "tableLink") this.exitTableLinkMode();
else if (this.drawingMode === "factionLink")
this.exitFactionLinkMode();
else if (this.drawingMode === "regionLink")
this.exitRegionLinkMode();
else if (this.drawingMode === "swap") this.exitSwapMode();
else {
if (this.drawingMode === "path") this.exitPathMode();
this.drawingMode = null;
this.updateToolbarButtonStates();
this.updatePathOverlay();
}
} else {
lastOffHexRightClick = now;
}
},
{ capture: true } as AddEventListenerOptions,
);
// Double-clicking off the hex grid (but inside the viewport) exits terrain/icon mode
this.registerDomEvent(contentEl, "dblclick", (e: MouseEvent) => {
if (this.drawingMode !== "terrain" && this.drawingMode !== "icon") return;
const inViewport = (e.target as HTMLElement).closest(
".duckmage-hex-map-viewport",
);
const onHex = (e.target as HTMLElement).closest(".duckmage-hex");
if (inViewport && !onHex) {
if (this.drawingMode === "terrain") this.exitTerrainMode();
else this.exitIconMode();
}
});
// Expand buttons and view buttons — always visible (not collapsible)
this.createExpandButtons(controlsEl);
const tableBtn = controlsEl.createEl("button", {
cls: "duckmage-table-btn",
title: "Open hex table (middle-click for new tab)",
text: "⊞",
});
tableBtn.addEventListener("click", () => {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_HEX_TABLE);
if (existing.length > 0) {
void this.app.workspace.revealLeaf(existing[0]);
} else {
void this.app.workspace
.getLeaf()
.setViewState({ type: VIEW_TYPE_HEX_TABLE });
}
});
tableBtn.addEventListener("auxclick", (e: MouseEvent) => {
if (e.button !== 1) return;
e.preventDefault();
void this.app.workspace
.getLeaf("tab")
.setViewState({ type: VIEW_TYPE_HEX_TABLE });
void this.app.workspace.revealLeaf(this.leaf);
});
const rtBtn = controlsEl.createEl("button", {
cls: "duckmage-rt-btn",
title: "Open random tables (middle-click for new tab)",
text: "🎲",
});
rtBtn.addEventListener("click", () => {
const existing = this.app.workspace.getLeavesOfType(
VIEW_TYPE_RANDOM_TABLES,
);
if (existing.length > 0) {
void this.app.workspace.revealLeaf(existing[0]);
} else {
void this.app.workspace
.getLeaf()
.setViewState({ type: VIEW_TYPE_RANDOM_TABLES });
}
});
rtBtn.addEventListener("auxclick", (e: MouseEvent) => {
if (e.button !== 1) return;
e.preventDefault();
void this.app.workspace
.getLeaf("tab")
.setViewState({ type: VIEW_TYPE_RANDOM_TABLES });
void this.app.workspace.revealLeaf(this.leaf);
});
const mapNavGroup = controlsEl.createDiv({ cls: "duckmage-map-nav-group" });
this.mapBtn = mapNavGroup.createEl("button", {
cls: "duckmage-region-btn",
title: "Manage maps",
});
this.updateMapBtnLabel();
this.mapBtn.addEventListener("click", () =>
new MapModal(this.app, this.plugin, this, () => {
this.exitTerrainMode();
this.exitPathMode();
this.undoStack = [];
this.redoStack = [];
this.updateUndoButton();
this.updateMapBtnLabel();
interface WithUpdateHeader {
updateHeader?(): void;
}
(this.leaf as unknown as WithUpdateHeader).updateHeader?.();
this.renderGrid();
}).open(),
);
this.backBtn = mapNavGroup.createEl("button", {
cls: "duckmage-map-back-btn",
text: "← Back", // eslint-disable-line obsidianmd/ui/sentence-case
title: "Back to previous map",
});
this.backBtn.hide();
this.backBtn.addEventListener("click", () => this.navigateBack());
this.undoBtn = controlsEl.createEl("button", {
cls: "duckmage-undo-btn-map",
text: "↩",
attr: { title: "Undo (up to 20)" },
});
this.undoBtn.disabled = true;
this.undoBtn.addEventListener("click", () => {
void this.undo();
});
this.redoBtn = controlsEl.createEl("button", {
cls: "duckmage-undo-btn-map duckmage-redo-btn-map",
text: "↪",
attr: { title: "Redo" },
});
this.redoBtn.disabled = true;
this.redoBtn.addEventListener("click", () => {
void this.redo();
});
const helpBtn = controlsEl.createEl("button", {
cls: "duckmage-help-btn",
title: "Controls & tools",
text: "?",
});
helpBtn.addEventListener("click", () => new HexHelpModal(this.app).open());
// Side panels — drawing tools (pencil) + overlays (layers), mutually exclusive
this.toolsPanel = new DrawingToolPanel(controlsEl, (panel) =>
this.buildDrawingToolbarContent(panel),
);
const toolsPanel = this.toolsPanel;
this.overlayPanel = new OverlayPanel(
controlsEl,
this.plugin,
() => this.viewportEl,
() => this.getActiveMap(),
(show) => { if (show) this.updateFactionOverlay(); else this.clearFactionOverlay(); },
(show) => { if (show) this.updateRegionOverlay(); else this.clearRegionOverlay(); },
() => { this.updateGmIcons(); },
(show) => { if (show) this.updateTokenLayer(); else this.viewportEl?.querySelector(".duckmage-token-layer")?.remove(); },
);
toolsPanel.onBeforeOpen = () => this.overlayPanel?.close();
this.overlayPanel.onBeforeOpen = () => toolsPanel.close();
// Saving indicator — appears while background writes are in flight
this.savingIndicatorEl = controlsEl.createEl("span", {
cls: "duckmage-saving-indicator",
text: "Saving…",
});
this.registerEvent(
this.app.metadataCache.on("changed", (file) => {
// Clear stale pending/erased entries for this file
this.pendingFactionLinks.delete(file.path);
this.erasedFactionLinks.delete(file.path);
this.pendingRegions.delete(file.path);
this.erasedRegions.delete(file.path);
if (this.getActiveMap().showFactionOverlay) {
const folder = normalizeFolder(this.plugin.settings.factionsFolder);
const isFactionFile =
!file.basename.startsWith("_") &&
(folder ? file.path.startsWith(folder + "/") : true);
if (isFactionFile) this.updateFactionOverlay();
}
if (this.getActiveMap().showRegionOverlay) {
const rFolder = normalizeFolder(this.plugin.settings.regionsFolder);
const isRegionNoteFile =
!file.basename.startsWith("_") &&
(rFolder ? file.path.startsWith(rFolder + "/") : true);
if (isRegionNoteFile) this.updateRegionOverlay();
// Also re-render when any hex file changes (region assignment may have changed)
else this.updateRegionOverlay();
}
// Refresh token layer when a token file changes (added, edited, or removed)
const cache = this.app.metadataCache.getFileCache(file);
const hasToken = !!cache?.frontmatter?.["token"];
const wasToken = this.tokenEntries.some((t) => t.filePath === file.path);
if (hasToken || wasToken) this.updateTokenLayer();
}),
);
this.renderGrid();
return Promise.resolve();
}
private createExpandButtons(container: HTMLElement): void {
const dirs: Array<{
groupCls: string;
expandAction: () => Promise<void>;
shrinkAction: () => Promise<void>;
canShrink: () => boolean;
edgePaths: () => string[];
}> = [
{
groupCls: "duckmage-expand-group-top",
expandAction: async () => {
this.getActiveMap().gridOffset.y--;
this.getActiveMap().gridSize.rows++;
await this.plugin.saveSettings();
const r = this.getActiveMap();
const xs = Array.from({ length: r.gridSize.cols }, (_, i) => r.gridOffset.x + i);
const newPaths = xs.map((x) => this.plugin.hexPath(x, r.gridOffset.y, this.activeMapName));
this.renderGrid(nullOverrides(newPaths), nullOverrides(newPaths));
void this.plugin.generateHexNotes(this.activeMapName, xs, [r.gridOffset.y]);
},
shrinkAction: async () => {
this.getActiveMap().gridSize.rows--;
this.getActiveMap().gridOffset.y++;
await this.plugin.saveSettings();
this.renderGrid();
},
canShrink: () => this.getActiveMap().gridSize.rows > 1,
edgePaths: () => {
const r = this.getActiveMap();
return Array.from({ length: r.gridSize.cols }, (_, i) =>
this.plugin.hexPath(r.gridOffset.x + i, r.gridOffset.y, this.activeMapName),
);
},
},
{
groupCls: "duckmage-expand-group-bottom",
expandAction: async () => {
this.getActiveMap().gridSize.rows++;
await this.plugin.saveSettings();
const r = this.getActiveMap();
const newY = r.gridOffset.y + r.gridSize.rows - 1;
const xs = Array.from({ length: r.gridSize.cols }, (_, i) => r.gridOffset.x + i);
const newPaths = xs.map((x) => this.plugin.hexPath(x, newY, this.activeMapName));
this.renderGrid(nullOverrides(newPaths), nullOverrides(newPaths));
void this.plugin.generateHexNotes(this.activeMapName, xs, [newY]);
},
shrinkAction: async () => {
this.getActiveMap().gridSize.rows--;
await this.plugin.saveSettings();
this.renderGrid();
},
canShrink: () => this.getActiveMap().gridSize.rows > 1,
edgePaths: () => {
const r = this.getActiveMap();
const lastY = r.gridOffset.y + r.gridSize.rows - 1;
return Array.from({ length: r.gridSize.cols }, (_, i) =>
this.plugin.hexPath(r.gridOffset.x + i, lastY, this.activeMapName),
);
},
},
{
groupCls: "duckmage-expand-group-left",
expandAction: async () => {
this.getActiveMap().gridOffset.x--;
this.getActiveMap().gridSize.cols++;
await this.plugin.saveSettings();
const r = this.getActiveMap();
const ys = Array.from({ length: r.gridSize.rows }, (_, i) => r.gridOffset.y + i);
const newPaths = ys.map((y) => this.plugin.hexPath(r.gridOffset.x, y, this.activeMapName));
this.renderGrid(nullOverrides(newPaths), nullOverrides(newPaths));
void this.plugin.generateHexNotes(this.activeMapName, [r.gridOffset.x], ys);
},
shrinkAction: async () => {
this.getActiveMap().gridSize.cols--;
this.getActiveMap().gridOffset.x++;
await this.plugin.saveSettings();
this.renderGrid();
},
canShrink: () => this.getActiveMap().gridSize.cols > 1,
edgePaths: () => {
const r = this.getActiveMap();
return Array.from({ length: r.gridSize.rows }, (_, i) =>
this.plugin.hexPath(r.gridOffset.x, r.gridOffset.y + i, this.activeMapName),
);
},
},
{
groupCls: "duckmage-expand-group-right",
expandAction: async () => {
this.getActiveMap().gridSize.cols++;
await this.plugin.saveSettings();
const r = this.getActiveMap();
const newX = r.gridOffset.x + r.gridSize.cols - 1;
const ys = Array.from({ length: r.gridSize.rows }, (_, i) => r.gridOffset.y + i);
const newPaths = ys.map((y) => this.plugin.hexPath(newX, y, this.activeMapName));
this.renderGrid(nullOverrides(newPaths), nullOverrides(newPaths));
void this.plugin.generateHexNotes(this.activeMapName, [newX], ys);
},
shrinkAction: async () => {
this.getActiveMap().gridSize.cols--;
await this.plugin.saveSettings();
this.renderGrid();
},
canShrink: () => this.getActiveMap().gridSize.cols > 1,
edgePaths: () => {
const r = this.getActiveMap();
const lastX = r.gridOffset.x + r.gridSize.cols - 1;
return Array.from({ length: r.gridSize.rows }, (_, i) =>
this.plugin.hexPath(lastX, r.gridOffset.y + i, this.activeMapName),
);
},
},
];
for (const { groupCls, expandAction, shrinkAction, canShrink, edgePaths } of dirs) {
const group = container.createDiv({
cls: `duckmage-expand-group ${groupCls}`,
});
group.createEl("button", { cls: "duckmage-expand-btn", text: "+" })
.addEventListener("click", () => void expandAction());
const shrinkBtn = group.createEl("button", {
cls: "duckmage-shrink-btn",
text: "",
});
const warnEl = group.createEl("span", { cls: "duckmage-shrink-warn", text: "⚠ has content" });
const resetShrinkBtn = () => {
shrinkBtn.removeClass("is-dirty", "is-confirming");
shrinkBtn.setText("");
warnEl.removeClass("is-visible");
};
group.addEventListener("mouseleave", () => resetShrinkBtn());
let pendingPaths: string[] = [];
let confirmTimer: number | null = null;
shrinkBtn.addEventListener("click", () => {
if (!canShrink()) return;
const paths = edgePaths();
const dirty = paths.some((p) => this.hexHasContent(p));
if (dirty && !shrinkBtn.hasClass("is-confirming")) {
pendingPaths = paths;
shrinkBtn.addClass("is-dirty", "is-confirming");
shrinkBtn.setText("OK?");
warnEl.addClass("is-visible");
if (confirmTimer !== null) window.clearTimeout(confirmTimer);
confirmTimer = window.setTimeout(() => {
confirmTimer = null;
pendingPaths = [];
resetShrinkBtn();
}, 3000);
return;
}
if (confirmTimer !== null) { window.clearTimeout(confirmTimer); confirmTimer = null; }
const toDelete = dirty ? pendingPaths : paths;
pendingPaths = [];
resetShrinkBtn();
void (async () => {
await shrinkAction();
for (const p of toDelete) {
const f = this.app.vault.getAbstractFileByPath(p);
if (f instanceof TFile) await this.app.fileManager.trashFile(f);
}
})();
});
}
}
private hexHasContent(path: string): boolean {
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
const cache = this.app.metadataCache.getFileCache(file);
if (!cache) return false;
if (cache.frontmatter?.terrain) return true;
if (cache.links && cache.links.length > 0) return true;
return false;
}
private buildDrawingToolbarContent(toolbar: HTMLElement): void {
// "Deselect tool" button — visible only when a tool is active
this.deselToolBtn = toolbar.createEl("button", {
cls: "duckmage-desel-tool-btn",
text: "↩ map mode",
});
this.deselToolBtn.hide();
this.deselToolBtn.addEventListener("click", () => {
if (this.drawingMode === "terrain") this.exitTerrainMode();
else if (this.drawingMode === "icon") this.exitIconMode();
else if (this.drawingMode === "tableLink") this.exitTableLinkMode();
else if (this.drawingMode === "factionLink") this.exitFactionLinkMode();
else if (this.drawingMode === "swap") this.exitSwapMode();
else if (this.drawingMode === "placeToken") this.exitTokenMode();
else if (this.drawingMode === "path") {
this.exitPathMode();
this.drawingMode = null;
this.updateToolbarButtonStates();
this.updatePathOverlay();
}
});
const centerHexBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-center-hex-btn",
text: "Center hex",
});
centerHexBtn.addEventListener("click", () => {
new GotoHexModal(this.app, (x, y) => this.centerOnHex(x, y)).open();
});
this.terrainToolbarBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-draw-btn-terrain",
});
this.terrainToolbarBtn.createSpan({ text: "Terrain" });
this.terrainBtnPreview = this.terrainToolbarBtn.createSpan({
cls: "duckmage-terrain-btn-preview",
});
this.terrainToolbarBtn.addEventListener("click", () =>
this.handleTerrainButton(),
);
this.iconToolbarBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-draw-btn-terrain",
});
this.iconToolbarBtn.createSpan({ text: "Icon" });
this.iconBtnPreview = this.iconToolbarBtn.createEl("img", {
cls: "duckmage-icon-btn-preview",
});
this.iconToolbarBtn.addEventListener("click", () =>
this.handleIconButton(),
);
this.pathToolbarBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-draw-btn-path",
});
this.pathToolbarBtn.createSpan({ text: "Path" });
this.pathBtnSwatch = this.pathToolbarBtn.createSpan({
cls: "duckmage-path-btn-swatch",
});
this.pathToolbarBtn.addEventListener("click", () =>
this.handlePathButton(),
);
this.tableLinkBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-draw-btn-tablelink",
});
this.tableLinkBtnLabel = this.tableLinkBtn.createSpan({
text: "Link table",
});
this.tableLinkBtn.addEventListener("click", () =>
this.handleTableLinkButton(),
);
this.factionLinkBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-draw-btn-tablelink",
});
this.factionLinkBtnLabel = this.factionLinkBtn.createSpan({
text: "Factions",
});
this.factionLinkBtn.addEventListener("click", () =>
this.handleFactionLinkButton(),
);
this.regionLinkBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-draw-btn-tablelink",
});
this.regionLinkBtnLabel = this.regionLinkBtn.createSpan({
text: "Regions",
});
this.regionLinkBtn.addEventListener("click", () =>
this.handleRegionLinkButton(),
);
this.tokenBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-draw-btn-token",
text: "Token",
});
this.tokenBtn.addEventListener("click", () => this.handleTokenButton());
}
private exitPathMode(): void {
this.activePathEnd = null;
this.activePathChain = null;
}
openTerrainPicker(): void {
this.toolsPanel?.open();
this.handleTerrainButton();
}
private handleTerrainButton(): void {
if (this.drawingMode === "terrain") this.exitTerrainMode();
// Show crosshair on the viewport while the picker is open
this.viewportEl?.addClass("duckmage-terrain-picking");
// Always open the picker — even if already active, so user can switch terrain
new TerrainPickerModal(
this.app,
this.plugin,
this.plugin.getMapPalette(this.activeMapName),
(terrainName: string | null) => {
this.viewportEl?.removeClass("duckmage-terrain-picking");
this.drawingMode = "terrain";
this.terrainPickMode = false;
this.paintTerrainName = terrainName;
this.paintIconName = null;
this.updateToolbarButtonStates();
},
() => {
// Eyedropper: enter terrain mode in pick-from-map state
this.viewportEl?.removeClass("duckmage-terrain-picking");
this.drawingMode = "terrain";
this.terrainPickMode = true;
this.paintTerrainName = null;
this.updateToolbarButtonStates();
},
() => {
// Dismissed without selecting
this.viewportEl?.removeClass("duckmage-terrain-picking");
},
this.paintBrushSize,
(size) => {
this.paintBrushSize = size;
},
).open();
}
private exitTerrainMode(): void {
if (this.drawingMode !== "terrain") return;
this.commitTerrainStroke();
this.drawingMode = null;
this.paintTerrainName = null;
this.terrainPickMode = false;
this.updateBrushHighlight(null, null);
this.updateToolbarButtonStates();
}
private handleIconButton(): void {
if (this.drawingMode === "icon") { this.exitIconMode(); return; }
new IconPickerModal(
this.app,
this.plugin,
(iconName: string | null, gmOnly: boolean) => {
this.drawingMode = "icon";
this.paintIconName = iconName;
this.paintIconGmOnly = gmOnly;
this.paintTerrainName = null;
this.paintBrushSize = 1;
this.updateToolbarButtonStates();
},
this.paintIconGmOnly,
).open();
}
private exitIconMode(): void {
if (this.drawingMode !== "icon") return;
this.drawingMode = null;
this.paintIconName = null;
this.paintIconGmOnly = false;
this.updateBrushHighlight(null, null);
this.updateToolbarButtonStates();
}
private handleTableLinkButton(): void {
if (this.drawingMode === "tableLink") { this.exitTableLinkMode(); return; }
new FolderTreePickerModal(
this.app,
this.plugin,
this.plugin.settings.tablesFolder,
"Select table",
"Filter tables…",
"No tables found.",
(file) => {
this.drawingMode = "tableLink";
this.paintTablePath = file.path;
this.updateToolbarButtonStates();
},
() => {
void this.app.workspace
.getLeaf("tab")
.setViewState({ type: VIEW_TYPE_RANDOM_TABLES });
},
).open();
}
private exitTableLinkMode(): void {
if (this.drawingMode !== "tableLink") return;
this.drawingMode = null;
this.paintTablePath = null;
this.updateToolbarButtonStates();
}
private handleFactionLinkButton(): void {
new FactionPickerModal(this.app, this.plugin, (filePath) => {
this.drawingMode = "factionLink";
this.paintFactionPath = filePath;
this.updateToolbarButtonStates();
}).open();
}
private exitFactionLinkMode(): void {
if (this.drawingMode !== "factionLink") return;
this.drawingMode = null;
this.paintFactionPath = null;
this.updateToolbarButtonStates();
}
private handleRegionLinkButton(): void {
new GeoRegionPickerModal(this.app, this.plugin, (filePath) => {
this.drawingMode = "regionLink";
this.paintRegionPath = filePath;
this.updateToolbarButtonStates();
// Auto-enable region overlay when entering paint mode
if (!this.getActiveMap().showRegionOverlay) {
this.getActiveMap().showRegionOverlay = true;
void this.plugin.saveSettings();
this.overlayPanel?.syncToRegion();
}
}).open();
}
private exitRegionLinkMode(): void {
if (this.drawingMode !== "regionLink") return;
this.drawingMode = null;
this.paintRegionPath = null;
this.updateToolbarButtonStates();
}
private handleTokenButton(): void {
if (this.drawingMode === "placeToken") {
this.exitTokenMode();
return;
}
new TokenModal(
this.app,
this.plugin,
undefined,
"",
{},
(notePath, data) => {
this.pendingTokenNotePath = notePath;
this.pendingTokenPlaceData = { icon: data.icon, shape: data.shape, size: data.size, color: data.color, border: data.border, description: data.description };
this.drawingMode = "placeToken";
this.updateToolbarButtonStates();
},
).open();
}
private exitTokenMode(): void {
if (this.drawingMode !== "placeToken") return;
this.drawingMode = null;
this.pendingTokenNotePath = null;
this.pendingTokenPlaceData = null;
this.updateToolbarButtonStates();
}
private handleSwapButton(): void {
if (this.drawingMode === "swap") {
this.exitSwapMode();
} else {
this.drawingMode = "swap";
this.swapSource = null;
this.swapDest = null;
this.updateToolbarButtonStates();
}
}
private exitSwapMode(): void {
if (this.drawingMode !== "swap") return;
this.drawingMode = null;
this.clearSwapHighlights();
this.swapSource = null;
this.swapDest = null;
this.updateToolbarButtonStates();
}
// Remove all swap overlay spans from the viewport DOM
private clearSwapHighlights(): void {
this.viewportEl
?.querySelectorAll(".duckmage-hex-swap-source, .duckmage-hex-swap-dest")
.forEach((el) => el.remove());
}
// Insert an overlay span INSIDE the hex element so it's shaped by clip-path
private highlightSwapHex(
x: number,
y: number,
cls: "duckmage-hex-swap-source" | "duckmage-hex-swap-dest",
): void {
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${x}"][data-y="${y}"]`,
);
if (!hexEl) return;
// Remove any existing overlay on this hex first
hexEl
.querySelector(".duckmage-hex-swap-source, .duckmage-hex-swap-dest")
?.remove();
hexEl.createSpan({ cls });
}
private async onHexSwapClick(x: number, y: number): Promise<void> {
if (this.drawingMode !== "swap") return;
// No source yet: select this hex as source
if (!this.swapSource) {
this.swapSource = { x, y };
this.highlightSwapHex(x, y, "duckmage-hex-swap-source");
return;
}
// Clicking the source again: cancel selection
if (x === this.swapSource.x && y === this.swapSource.y) {
this.swapSource = null;
this.clearSwapHighlights();
return;
}
// Any other hex: execute swap immediately then deselect tool
const src = { ...this.swapSource };
this.clearSwapHighlights();
this.swapSource = null;
this.swapDest = null;
await this.executeHexSwap(src.x, src.y, x, y);
this.exitSwapMode();
}
// Double-click on the destination confirms the swap
private onHexDblClick(x: number, y: number): void {
if (this.drawingMode === "path") {
this.exitPathMode();
this.updatePathOverlay();
return;
}
}
private async onHexTokenPlaceClick(x: number, y: number): Promise<void> {
if (!this.pendingTokenNotePath || !this.pendingTokenPlaceData) return;
const hexKey = `${x}_${y}`;
await applyTokenFrontmatter(this.app, this.pendingTokenNotePath, {
...this.pendingTokenPlaceData,
hex: hexKey,
map: this.activeMapName,
visible: true,
});
this.exitTokenMode();
this.updateTokenLayer();
}
private async performSwap(pathA: string, pathB: string): Promise<void> {
const hexBase = normalizeFolder(this.plugin.settings.hexFolder);
const folder = hexBase
? `${hexBase}/${this.activeMapName}`
: this.activeMapName;
const tempPath = `${folder}/__swap_tmp.md`;
// Recover from a previous partial swap that left a temp file
const leftover = this.app.vault.getAbstractFileByPath(tempPath);
if (leftover instanceof TFile) {
// If pathB is now free, complete the partial swap; otherwise abort
if (!this.app.vault.getAbstractFileByPath(pathB)) {
await this.app.vault.rename(leftover, pathB);
return;
}
new Notice("Swap: stale temp file found — check your hex folder.");
return;
}
const fileA = this.app.vault.getAbstractFileByPath(pathA);
const fileB = this.app.vault.getAbstractFileByPath(pathB);
const hasA = fileA instanceof TFile;
const hasB = fileB instanceof TFile;
if (!hasA && !hasB) return;
if (fileA instanceof TFile && !hasB) {
await this.app.vault.rename(fileA, pathB);
} else if (!hasA && fileB instanceof TFile) {
await this.app.vault.rename(fileB, pathA);
} else if (fileA instanceof TFile && fileB instanceof TFile) {
await this.app.vault.rename(fileA, tempPath);
await this.app.vault.rename(fileB, pathA);
const tmp = this.app.vault.getAbstractFileByPath(tempPath);
if (!(tmp instanceof TFile)) throw new Error("temp file missing");
await this.app.vault.rename(tmp, pathB);
}
}
private async executeHexSwap(
x1: number,
y1: number,
x2: number,
y2: number,
isUndoRedo = false,
): Promise<void> {
const pathA = this.plugin.hexPath(x1, y1, this.activeMapName);
const pathB = this.plugin.hexPath(x2, y2, this.activeMapName);
// Discard any pending (not-yet-started) writes for the two paths.
// Without this the flush loop would find no file after the rename and
// call createHexNote(), recreating a ghost file at the old position.
this.pendingTerrainWrites.delete(pathA);
this.pendingTerrainWrites.delete(pathB);
this.pendingIconWrites.delete(pathA);
this.pendingIconWrites.delete(pathB);
this.pendingGmIconWrites.delete(pathA);
this.pendingGmIconWrites.delete(pathB);
// Wait for any already-in-flight flushes on these paths to finish before
// renaming files — a flush that completes after the rename would write
// terrain to the wrong file or recreate a file that was just moved.
const flushKeys = [
`t:${pathA}`, `t:${pathB}`,
`i:${pathA}`, `i:${pathB}`,
`g:${pathA}`, `g:${pathB}`,
];
const deadline = Date.now() + 2000;
while (
flushKeys.some((k) => this.flushing.has(k)) &&
Date.now() < deadline
) {
await new Promise<void>((r) => setTimeout(r, 30));
}
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await this.performSwap(pathA, pathB);
break;
} catch (e) {
if (attempt === 2) {
new Notice(
`Swap failed: ${e instanceof Error ? e.message : String(e)}`,
);
this.renderGrid();
return;
}
await new Promise<void>((r) => setTimeout(r, 300));
}
}
// Immediate re-render so the map reflects the swap
this.renderGrid();
// Blip both positions in the freshly rendered grid
for (const [x, y] of [
[x1, y1],
[x2, y2],
]) {
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${x}"][data-y="${y}"]`,
);
if (hexEl) {
const blip = hexEl.createSpan({ cls: "duckmage-hex-blip" });
blip.addEventListener("animationend", () => blip.remove(), {
once: true,
});
}
}
if (!isUndoRedo) {
this.undoStack.push({ kind: "swap", x1, y1, x2, y2 });
if (this.undoStack.length > this.UNDO_DEPTH) this.undoStack.shift();
this.redoStack = [];
this.updateUndoButton();
}
}
private updateToolbarButtonStates(): void {
if (this.drawingMode !== null) this.deselToolBtn?.show();
else this.deselToolBtn?.hide();
this.pathToolbarBtn?.toggleClass("is-active", this.drawingMode === "path");
// Update path button swatch color to show active type
if (this.pathBtnSwatch) {
const activeType = this.activePathTypeName
? this.plugin.settings.pathTypes.find(
(p) => p.name === this.activePathTypeName,
)
: this.plugin.settings.pathTypes[0];
if (activeType) {
this.pathBtnSwatch.setCssProps({
"background-color": activeType.color,
});
this.pathBtnSwatch.show();
} else {
this.pathBtnSwatch.hide();
}
}
this.terrainToolbarBtn?.toggleClass(
"is-active",
this.drawingMode === "terrain",
);
this.iconToolbarBtn?.toggleClass("is-active", this.drawingMode === "icon");
this.tableLinkBtn?.toggleClass(
"is-active",
this.drawingMode === "tableLink",
);
this.factionLinkBtn?.toggleClass(
"is-active",
this.drawingMode === "factionLink",
);
this.regionLinkBtn?.toggleClass(
"is-active",
this.drawingMode === "regionLink",
);
this.swapBtn?.toggleClass("is-active", this.drawingMode === "swap");
this.tokenBtn?.toggleClass("is-active", this.drawingMode === "placeToken");
this.viewportEl?.toggleClass(
"duckmage-draw-mode",
this.drawingMode !== null,
);
this.viewportEl?.toggleClass(
"duckmage-terrain-paint",
this.drawingMode === "terrain" && !this.terrainPickMode,
);
// Icon button preview
if (this.drawingMode === "icon" && this.paintIconName) {
if (this.iconBtnPreview) {
this.iconBtnPreview.src = getIconUrl(this.plugin, this.paintIconName);
this.iconBtnPreview.show();
}
} else {
if (this.iconBtnPreview) this.iconBtnPreview.hide();
}
if (this.drawingMode === "terrain") {
if (this.terrainPickMode) {
// Eyedropper waiting for a click — show ⌖ as the preview
if (this.terrainToolbarBtn) {
this.terrainToolbarBtn.setCssProps({
"border-color": "var(--interactive-accent)",
color: "var(--interactive-accent)",
});
}
if (this.terrainBtnPreview) {
this.terrainBtnPreview.setCssProps({ "background-color": "" });
this.terrainBtnPreview.show();
this.terrainBtnPreview.textContent = "⌖";
}
} else {
if (this.terrainBtnPreview) this.terrainBtnPreview.textContent = "";
const entry = this.paintTerrainName
? this.plugin
.getMapPalette(this.activeMapName)
.find((p) => p.name === this.paintTerrainName)
: undefined;
if (entry) {
if (this.terrainToolbarBtn) {
this.terrainToolbarBtn.setCssProps({ "border-color": entry.color });
}
if (this.terrainBtnPreview) {
this.terrainBtnPreview.setCssProps({
"background-color": entry.color,
});
this.terrainBtnPreview.show();
}
} else {
// Clear mode — show active state without a color
if (this.terrainToolbarBtn) {
this.terrainToolbarBtn.setCssProps({ "border-color": "" });
}
if (this.terrainBtnPreview) {
this.terrainBtnPreview.hide();
}
}
}
} else {
if (this.terrainToolbarBtn) {
this.terrainToolbarBtn.setCssProps({ "border-color": "", color: "" });
}
if (this.terrainBtnPreview) {
this.terrainBtnPreview.hide();
}
}
// Table link button label
if (this.tableLinkBtnLabel) {
if (this.drawingMode === "tableLink" && this.paintTablePath) {
const name =
this.paintTablePath.split("/").pop()?.replace(/.md$/, "") ?? "Table";
this.tableLinkBtnLabel.setText("Link: " + name);
} else {
this.tableLinkBtnLabel.setText("Link table");
}
}
// Faction link button label
if (this.factionLinkBtnLabel) {
if (this.drawingMode === "factionLink" && this.paintFactionPath) {
const name =
this.paintFactionPath.split("/").pop()?.replace(/.md$/, "") ??
"Faction";
this.factionLinkBtnLabel.setText("Link: " + name);
} else {
this.factionLinkBtnLabel.setText("Factions");
}
}
// Region link button label
if (this.regionLinkBtnLabel) {
if (this.drawingMode === "regionLink" && this.paintRegionPath) {
const name =
this.paintRegionPath.split("/").pop()?.replace(/.md$/, "") ??
"Region";
this.regionLinkBtnLabel.setText("Paint: " + name);
} else {
this.regionLinkBtnLabel.setText("Regions");
}
}
}
private applyTransform(): void {
if (this.viewportEl) {
this.viewportEl.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
}
}
private scheduleZoomBake(): void {
if (this.zoomSettleTimer !== null) window.clearTimeout(this.zoomSettleTimer);
this.zoomSettleTimer = window.setTimeout(() => {
this.zoomSettleTimer = null;
this.bakeZoom();
}, 250);
}
// Bake the current CSS scale() into the viewport's font-size so the DOM
// renders natively at the zoomed size instead of upscaling a compositor
// bitmap (which causes pixelation). Pan offsets stay correct because layout
// coordinates scale by the same factor as the transform did.
private bakeZoom(): void {
if (!this.viewportEl || this.zoom === 1) return;
const currentFs = parseFloat(getComputedStyle(this.viewportEl).fontSize);
this.viewportEl.style.fontSize = `${currentFs * this.zoom}px`;
this.zoom = 1;
this.applyTransform();
this.updatePathOverlay();
this.updateFactionOverlay();
this.updateRegionOverlay();
this.updateTokenLayer();
}
setSelectedHex(x: number, y: number): void {
if (this.selectedHex) {
this.viewportEl
?.querySelector<HTMLElement>(
`[data-x="${this.selectedHex.x}"][data-y="${this.selectedHex.y}"]`,
)
?.removeClass("is-selected");
}
this.selectedHex = { x, y };
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${x}"][data-y="${y}"]`)
?.addClass("is-selected");
}
centerOnHex(x: number, y: number): void {
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${x}"][data-y="${y}"]`,
);
if (!hexEl) {
new Notice(`Hex ${x},${y} is not in the current grid.`);
return;
}
const clipEl = this.viewportEl?.parentElement;
if (!clipEl) return;
// Use getBoundingClientRect for reliable positions — the offsetParent chain
// can silently break (e.g. fixed-position ancestors), causing wrong results.
const hexRect = hexEl.getBoundingClientRect();
const clipRect = clipEl.getBoundingClientRect();
// Back-compute the hex centre in pre-transform viewport coordinates
const hexScreenX = hexRect.left + hexRect.width / 2;
const hexScreenY = hexRect.top + hexRect.height / 2;
const hexViewX = (hexScreenX - clipRect.left - this.panX) / this.zoom;
const hexViewY = (hexScreenY - clipRect.top - this.panY) / this.zoom;
const targetZoom = 1.5;
this.zoom = targetZoom;
this.panX = clipRect.width / 2 - hexViewX * targetZoom;
this.panY = clipRect.height / 2 - hexViewY * targetZoom;
this.applyTransform();
this.scheduleZoomBake();
}
renderGrid(
terrainOverrides?: Map<string, string | null>,
iconOverrides?: Map<string, string | null>,
): void {
if (!this.viewportEl) return;
this.viewportEl.empty();
const gap = this.plugin.settings.hexGap?.trim() || "0.15";
this.viewportEl.style.setProperty(
"--duckmage-hex-gap",
/^\d*\.?\d+$/.test(gap) ? `${gap}em` : gap,
);
const region = this.getActiveMap();
// Sync overlay checkboxes and CSS classes to the active region's saved state
this.overlayPanel?.syncToRegion();
const { cols, rows } = region.gridSize;
const { x: ox, y: oy } = region.gridOffset;
const hexBase = normalizeFolder(this.plugin.settings.hexFolder);
const folder = hexBase
? `${hexBase}/${this.activeMapName}`
: this.activeMapName;
const palette = this.plugin.getMapPalette(this.activeMapName);
const isFlat = this.plugin.settings.hexOrientation === "flat";
const gridContainer = this.viewportEl.createDiv({
cls: `duckmage-hex-map-grid${isFlat ? " duckmage-grid-flat" : ""}`,
});
const addHex = (parent: HTMLElement, x: number, y: number) => {
const path = folder ? `${folder}/${x}_${y}.md` : `${x}_${y}.md`;
const exists =
this.app.vault.getAbstractFileByPath(path) instanceof TFile;
const terrainKey = terrainOverrides?.has(path)
? terrainOverrides.get(path)!
: getTerrainFromFile(this.app, path);
const terrainEntry =
terrainKey != null
? palette.find((p) => p.name === terrainKey)
: undefined;
const hexEl = parent.createDiv({
cls: `duckmage-hex${exists ? " duckmage-hex-exists" : ""}`,
attr: { "data-x": String(x), "data-y": String(y) },
});
hexEl.tabIndex = -1;
if (terrainEntry?.color) hexEl.style.backgroundColor = terrainEntry.color;
const iconOverride = iconOverrides?.has(path)
? iconOverrides.get(path)!
: getIconOverrideFromFile(this.app, path);
if (iconOverride) {
// Render terrain icon as hidden fallback — shown by CSS when overrides are off
if (terrainEntry?.icon) {
createIconEl(
hexEl,
getIconUrl(this.plugin, terrainEntry.icon),
terrainEntry.name,
terrainEntry.iconColor,
"duckmage-hex-terrain-icon",
);
}
// Render the override icon (primary, visible by default)
createIconEl(
hexEl,
getIconUrl(this.plugin, iconOverride),
terrainEntry?.name ?? "",
undefined,
"duckmage-hex-icon duckmage-hex-override-icon",
);
// Tag the hex so the SVG overlay can elevate this icon above roads/rivers
hexEl.dataset.iconOverride = iconOverride;
} else if (terrainEntry?.icon) {
createIconEl(
hexEl,
getIconUrl(this.plugin, terrainEntry.icon),
terrainEntry.name,
terrainEntry.iconColor,
"duckmage-hex-icon",
);
}
// Tag hex for GM icon overlay (rendered in path SVG when GM layer is active)
const gmIcon = getGmIconFromFile(this.app, path);
if (gmIcon) hexEl.dataset.gmIcon = gmIcon;
if (this.selectedHex?.x === x && this.selectedHex?.y === y)
hexEl.addClass("is-selected");
hexEl.createSpan({ cls: "duckmage-hex-label", text: `${x},${y}` });
if (exists && !terrainEntry)
hexEl.createSpan({ cls: "duckmage-hex-dot" });
hexEl.addEventListener("click", () => {
void this.onHexClick(x, y);
});
hexEl.addEventListener("dblclick", () => {
void this.onHexDblClick(x, y);
});
hexEl.addEventListener("contextmenu", (evt) =>
this.onHexContextMenu(evt, x, y),
);
};
if (isFlat) {
// Flat-top: iterate columns; odd columns shift down by half hex height
for (let i = 0; i < cols; i++) {
const x = ox + i;
const colEl = gridContainer.createDiv({
cls: `duckmage-hex-col${x % 2 !== 0 ? " duckmage-hex-col-offset" : ""}`,
});
for (let j = 0; j < rows; j++) {
addHex(colEl, x, oy + j);
}
}
} else {
// Pointy-top: iterate rows; odd rows shift right by half hex width
for (let j = 0; j < rows; j++) {
const y = oy + j;
const rowEl = gridContainer.createDiv({
cls: `duckmage-hex-row${y % 2 !== 0 ? " duckmage-hex-row-offset" : ""}`,
});
for (let i = 0; i < cols; i++) {
addHex(rowEl, ox + i, y);
}
}
}
this.renderPathOverlay(gridContainer);
this.renderRegionOverlay(gridContainer);
this.renderFactionOverlay(gridContainer);
this.renderTokenLayer(gridContainer);
}
private openHexEditorModal(x: number, y: number): void {
this.setSelectedHex(x, y);
const modal = new HexEditorModal(
this.app,
this.plugin,
x,
y,
this.activeMapName,
(t, i) => {
if (t !== undefined || i !== undefined) {
this.renderGrid(t, i);
} else {
setTimeout(() => this.renderGrid(), 300);
}
},
{
gmLayerActive: this.getActiveMap().showGmLayer ?? true,
onNavigate: (nx: number, ny: number) => this.setSelectedHex(nx, ny),
onModalClose: () => {
if (this.selectedHex) {
this.viewportEl
?.querySelector<HTMLElement>(
`[data-x="${this.selectedHex.x}"][data-y="${this.selectedHex.y}"]`,
)
?.removeClass("is-selected");
this.selectedHex = null;
}
},
onSwitchMap: (name: string) => this.navigateToMap(name),
},
);
modal.open();
}
private onHexContextMenu(evt: MouseEvent, x: number, y: number): void {
evt.preventDefault();
if (this.drawingMode === "path") {
void this.onHexPathDeleteClick(x, y);
return;
}
if (this.drawingMode === "factionLink") {
void this.onHexFactionEraseClick(x, y);
return;
}
if (this.drawingMode === "regionLink") {
void this.onHexRegionEraseClick(x, y);
return;
}
if (this.drawingMode === "swap") {
this.exitSwapMode();
return;
}
if (this.drawingMode === "placeToken") {
this.exitTokenMode();
return;
}
const menu = new Menu();
menu.addItem((item) =>
item
.setTitle("Center on this hex")
.setIcon("crosshair")
.onClick(() => this.centerOnHex(x, y)),
);
menu.addSeparator();
menu.addItem((item) =>
item
.setTitle("Open note")
.setIcon("file-text")
.onClick(async () => {
const path = this.plugin.hexPath(x, y, this.activeMapName);
const existing = this.app.vault.getAbstractFileByPath(path);
const file =
existing instanceof TFile
? existing
: await this.plugin.createHexNote(x, y, this.activeMapName);
if (file) await this.app.workspace.getLeaf().openFile(file);
}),
);
menu.addItem((item) =>
item
.setTitle("Link submap")
.setIcon("map-pin")
.onClick(() => {
const hexPath = this.plugin.hexPath(x, y, this.activeMapName);
const current = getSubmapFromFile(this.app, hexPath);
new SubmapPickerModal(
this.app,
this.plugin,
current,
(mapName) => {
void (async () => {
if (!this.app.vault.getAbstractFileByPath(hexPath)) {
await this.plugin.createHexNote(x, y, this.activeMapName);
}
await setSubmapInFile(this.app, hexPath, mapName);
this.renderGrid();
})();
},
() => {
void setSubmapInFile(this.app, hexPath, null).then(() => this.renderGrid());
},
).open();
}),
);
menu.addItem((item) =>
item
.setTitle("Swap hex")
.setIcon("arrow-left-right")
.onClick(() => {
if (this.drawingMode !== "swap") this.handleSwapButton();
void this.onHexSwapClick(x, y);
}),
);
menu.showAtMouseEvent(evt);
}
private async onHexClick(x: number, y: number): Promise<void> {
if (this.drawingMode === "path") {
await this.onHexPathDrawClick(x, y);
return;
}
if (this.drawingMode === "terrain") {
this.onHexPaintClick(x, y);
return;
}
if (this.drawingMode === "icon") {
this.onHexIconClick(x, y);
return;
}
if (this.drawingMode === "tableLink") {
await this.onHexTableLinkClick(x, y);
return;
}
if (this.drawingMode === "swap") {
await this.onHexSwapClick(x, y);
return;
}
if (this.drawingMode === "placeToken") {
await this.onHexTokenPlaceClick(x, y);
return;
}
this.openHexEditorModal(x, y);
}
private getBrushHexes(x: number, y: number): [number, number][] {
const center: [number, number] = [x, y];
if (this.paintBrushSize === 1) return [center];
const nb = hexNeighbors(x, y, this.plugin.settings.hexOrientation);
// nb[2] and nb[3] are always adjacent to each other AND to center in both
// orientations (verified from offset tables), forming a compact triangle.
if (this.paintBrushSize === 3) return [center, nb[2], nb[3]];
return [center, ...nb];
}
private updateBrushHighlight(x: number | null, y: number | null): void {
for (const [hx, hy] of this.brushHoverHexes) {
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${hx}"][data-y="${hy}"]`)
?.removeClass("duckmage-hex-brush-hover");
}
this.brushHoverHexes = [];
if (x === null || y === null) return;
this.brushHoverHexes = this.getBrushHexes(x, y);
for (const [hx, hy] of this.brushHoverHexes) {
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${hx}"][data-y="${hy}"]`)
?.addClass("duckmage-hex-brush-hover");
}
}
private onHexPaintClick(x: number, y: number): void {
if (this.drawingMode !== "terrain") return;
// Eyedropper pick mode: sample this hex's terrain and switch to painting it
if (this.terrainPickMode) {
const sampled = getTerrainFromFile(
this.app,
this.plugin.hexPath(x, y, this.activeMapName),
);
this.terrainPickMode = false;
this.paintTerrainName = sampled;
this.updateToolbarButtonStates();
return;
}
const terrain = this.paintTerrainName;
const palette = this.plugin.getMapPalette(this.activeMapName);
const entry =
terrain != null ? palette.find((p) => p.name === terrain) : undefined;
for (const [hx, hy] of this.getBrushHexes(x, y)) {
// ── Immediate visual update — no waiting for file I/O ───────────────
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${hx}"][data-y="${hy}"]`,
);
if (hexEl) {
hexEl.style.backgroundColor = entry?.color ?? "";
hexEl.querySelector(".duckmage-hex-icon")?.remove();
hexEl.querySelector(".duckmage-hex-dot")?.remove();
if (entry?.icon) {
const iconEl = createIconEl(
hexEl,
getIconUrl(this.plugin, entry.icon),
entry.name,
entry.iconColor,
"duckmage-hex-icon",
);
hexEl.insertBefore(
iconEl,
hexEl.querySelector(".duckmage-hex-label"),
);
}
if (terrain !== null) hexEl.addClass("duckmage-hex-exists");
}
// ── Queue background file write (coalescing per-hex) ────────────────
const path = this.plugin.hexPath(hx, hy, this.activeMapName);
if (this.currentTerrainStroke) {
if (!this.currentTerrainStroke.has(path)) {
const oldTerrain =
(
this.app.metadataCache.getCache(path)?.frontmatter as
| Frontmatter
| undefined
)?.terrain ?? null;
this.currentTerrainStroke.set(path, {
x: hx,
y: hy,
path,
oldTerrain,
newTerrain: terrain,
});
} else {
this.currentTerrainStroke.get(path)!.newTerrain = terrain;
}
}
this.scheduleTerrainWrite(hx, hy, path, terrain);
}
}
private onHexIconClick(x: number, y: number): void {
if (this.drawingMode !== "icon") return;
const icon = this.paintIconName;
const path = this.plugin.hexPath(x, y, this.activeMapName);
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${x}"][data-y="${y}"]`,
);
if (this.paintIconGmOnly) {
// ── GM icon — additive badge, terrain icon untouched ────────────────
if (hexEl) {
if (icon) {
hexEl.dataset.gmIcon = icon;
} else {
delete hexEl.dataset.gmIcon;
}
if (icon !== null) hexEl.addClass("duckmage-hex-exists");
}
this.updateGmIcons();
this.scheduleGmIconWrite(x, y, path, icon);
} else {
// ── Regular icon override — existing behaviour ───────────────────────
if (hexEl) {
hexEl.querySelector(".duckmage-hex-icon")?.remove();
if (icon) {
const img = hexEl.createEl("img", { cls: "duckmage-hex-icon" });
img.src = getIconUrl(this.plugin, icon);
img.alt = icon;
hexEl.insertBefore(img, hexEl.querySelector(".duckmage-hex-label"));
hexEl.dataset.iconOverride = icon;
} else {
delete hexEl.dataset.iconOverride;
}
if (icon !== null) hexEl.addClass("duckmage-hex-exists");
}
this.updatePathOverlay();
this.scheduleIconWrite(x, y, path, icon);
}
}
private async onHexTableLinkClick(x: number, y: number): Promise<void> {
if (this.drawingMode !== "tableLink" || !this.paintTablePath) return;
const hexPath = this.plugin.hexPath(x, y, this.activeMapName);
const tableFile = this.app.vault.getAbstractFileByPath(this.paintTablePath);
if (!(tableFile instanceof TFile)) return;
// Ensure the hex note exists
let hexFile = this.app.vault.getAbstractFileByPath(hexPath);
if (!(hexFile instanceof TFile)) {
hexFile = await this.plugin.createHexNote(x, y, this.activeMapName);
if (!(hexFile instanceof TFile)) return;
}
const target = this.app.metadataCache.fileToLinktext(tableFile, hexPath);
const linkText = `[[${target}]]`;
// Idempotent — only add if not already present
const existing = await getLinksInSection(
this.app,
hexPath,
"Encounters Table",
);
if (existing.includes(target)) {
new Notice(`Already linked on ${x},${y}`);
return;
}
await addLinkToSection(this.app, hexPath, "Encounters Table", linkText);
// Visual feedback: badge + ripple blip on the hex
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${x}"][data-y="${y}"]`,
);
if (hexEl) {
hexEl.addClass("duckmage-hex-table-linked");
hexEl.addClass("duckmage-hex-exists");
if (!hexEl.querySelector(".duckmage-hex-link-badge")) {
hexEl.createSpan({ cls: "duckmage-hex-link-badge", text: "📋" });
}
const blip = hexEl.createSpan({ cls: "duckmage-hex-blip" });
blip.addEventListener("animationend", () => blip.remove(), {
once: true,
});
}
}
private async onHexFactionPaintClick(x: number, y: number): Promise<void> {
if (this.drawingMode !== "factionLink" || !this.paintFactionPath) return;
const hexPath = this.plugin.hexPath(x, y, this.activeMapName);
const factionFile = this.app.vault.getAbstractFileByPath(this.paintFactionPath);
if (!(factionFile instanceof TFile)) return;
const factionBasename = factionFile.basename;
// Skip silently if already painted (pending or cache both use basenames now)
if (this.getFactionLinksFromCache(hexPath).includes(factionBasename)) return;
// Update the overlay IMMEDIATELY (synchronous) — no awaits before this point
// If this faction was previously erased (still in cache), un-erase it
this.erasedFactionLinks.get(hexPath)?.delete(factionBasename);
const pending = this.pendingFactionLinks.get(hexPath) ?? new Set<string>();
pending.add(factionBasename);
this.pendingFactionLinks.set(hexPath, pending);
if (this.getActiveMap().showFactionOverlay) this.updateFactionOverlay();
// Create hex note if it doesn't exist yet
let hexFile = this.app.vault.getAbstractFileByPath(hexPath);
if (!(hexFile instanceof TFile)) {
hexFile = await this.plugin.createHexNote(x, y, this.activeMapName);
if (!(hexFile instanceof TFile)) {
// Note creation failed — revert pending entry
this.pendingFactionLinks.get(hexPath)?.delete(factionBasename);
return;
}
}
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${x}"][data-y="${y}"]`,
);
if (hexEl) hexEl.addClass("duckmage-hex-exists");
// Write the link in the background — addLinkToSection deduplicates internally
const target = this.app.metadataCache.fileToLinktext(factionFile, hexPath);
await addLinkToSection(this.app, hexPath, "Factions", `[[${target}]]`);
}
private async onHexFactionEraseClick(x: number, y: number): Promise<void> {
if (!this.paintFactionPath) return;
const factionFile = this.app.vault.getAbstractFileByPath(this.paintFactionPath);
if (!(factionFile instanceof TFile)) return;
const factionBasename = factionFile.basename;
const hexPath = this.plugin.hexPath(x, y, this.activeMapName);
if (!this.getFactionLinksFromCache(hexPath).includes(factionBasename)) return;
// Update overlay immediately (synchronous): remove from pending, add to erased
this.pendingFactionLinks.get(hexPath)?.delete(factionBasename);
const erased = this.erasedFactionLinks.get(hexPath) ?? new Set<string>();
erased.add(factionBasename);
this.erasedFactionLinks.set(hexPath, erased);
if (this.getActiveMap().showFactionOverlay) this.updateFactionOverlay();
// Remove from file in the background
const target = this.app.metadataCache.fileToLinktext(factionFile, hexPath);
await removeLinkFromSection(this.app, hexPath, "Factions", target);
}
// ── Per-hex coalescing write queues ────────────────────────────────────────
//
// Only the *latest* painted value is ever queued per hex. If the user repaints
// hex A five times while the first write is in-flight, we perform exactly two
// writes: the in-flight one and then the final value. No writes are lost; no
// stale intermediate value can overwrite a newer one.
private applyTerrainToHexEl(
hexEl: HTMLElement,
terrain: string | null,
): void {
const palette = this.plugin.getMapPalette(this.activeMapName);
const entry =
terrain != null ? palette.find((p) => p.name === terrain) : undefined;
hexEl.style.backgroundColor = entry?.color ?? "";
hexEl.querySelector(".duckmage-hex-icon")?.remove();
hexEl.querySelector(".duckmage-hex-dot")?.remove();
if (entry?.icon) {
try {
const iconEl = createIconEl(
hexEl,
getIconUrl(this.plugin, entry.icon),
entry.name,
entry.iconColor,
"duckmage-hex-icon",
);
hexEl.insertBefore(iconEl, hexEl.querySelector(".duckmage-hex-label"));
} catch (err) {
console.warn(
`[hexmaker] failed to render icon for terrain "${terrain}":`,
err,
);
}
}
if (terrain !== null) hexEl.addClass("duckmage-hex-exists");
else hexEl.removeClass("duckmage-hex-exists");
}
private commitTerrainStroke(): void {
if (!this.currentTerrainStroke || this.currentTerrainStroke.size === 0) {
this.currentTerrainStroke = null;
return;
}
const entries = [...this.currentTerrainStroke.values()];
this.undoStack.push({ kind: "terrain", entries });
if (this.undoStack.length > this.UNDO_DEPTH) this.undoStack.shift();
this.redoStack = []; // new paint invalidates redo history
this.currentTerrainStroke = null;
this.updateUndoButton();
}
private async undo(): Promise<void> {
const item = this.undoStack.pop();
if (!item) return;
this.redoStack.push(item);
if (item.kind === "terrain") {
this.applyStroke(item.entries, "old");
} else if (item.kind === "swap") {
await this.executeHexSwap(item.x1, item.y1, item.x2, item.y2, true);
} else {
await this.applyPathSnapshot(item.mapName, item.before);
}
this.updateUndoButton();
}
private async redo(): Promise<void> {
const item = this.redoStack.pop();
if (!item) return;
this.undoStack.push(item);
if (item.kind === "terrain") {
this.applyStroke(item.entries, "new");
} else if (item.kind === "swap") {
await this.executeHexSwap(item.x1, item.y1, item.x2, item.y2, true);
} else {
await this.applyPathSnapshot(item.mapName, item.after);
}
this.updateUndoButton();
}
private async applyPathSnapshot(
mapName: string,
chains: PathChain[],
): Promise<void> {
const region = this.plugin.getMap(mapName);
if (!region) return;
region.pathChains = this.cloneChains(chains);
// Clear active chain tracking — the restored state may not match
this.exitPathMode();
await this.plugin.saveSettings();
this.updatePathOverlay();
}
private applyStroke(stroke: TerrainUndoEntry[], which: "old" | "new"): void {
for (const entry of stroke) {
const terrain = which === "old" ? entry.oldTerrain : entry.newTerrain;
try {
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${entry.x}"][data-y="${entry.y}"]`,
);
if (hexEl) this.applyTerrainToHexEl(hexEl, terrain);
} catch (err) {
console.warn(
`[hexmaker] stroke visual update failed for ${entry.path}:`,
err,
);
}
try {
this.scheduleTerrainWrite(entry.x, entry.y, entry.path, terrain);
} catch (err) {
console.warn(
`[hexmaker] stroke write scheduling failed for ${entry.path}:`,
err,
);
}
}
}
private updateUndoButton(): void {
if (this.undoBtn) this.undoBtn.disabled = this.undoStack.length === 0;
if (this.redoBtn) this.redoBtn.disabled = this.redoStack.length === 0;
}
private updateSavingIndicator(): void {
const count =
this.pendingTerrainWrites.size +
this.pendingIconWrites.size +
this.pendingGmIconWrites.size +
this.flushing.size;
if (this.savingIndicatorEl) {
if (count > 0) {
this.savingIndicatorEl.setText(`${count} updates remaining`);
this.savingIndicatorEl.addClass("is-active");
} else {
this.savingIndicatorEl.removeClass("is-active");
}
}
}
private scheduleTerrainWrite(
x: number,
y: number,
path: string,
terrain: string | null,
): void {
this.pendingTerrainWrites.set(path, { x, y, terrain });
this.updateSavingIndicator();
if (!this.flushing.has(`t:${path}`)) void this.flushTerrainWrites(path);
}
private async flushTerrainWrites(path: string): Promise<void> {
const key = `t:${path}`;
this.flushing.add(key);
this.updateSavingIndicator();
try {
while (this.pendingTerrainWrites.has(path)) {
const { x, y, terrain } = this.pendingTerrainWrites.get(path)!;
this.pendingTerrainWrites.delete(path);
let attempt = 0;
while (true) {
if (attempt > 0)
await new Promise<void>((r) =>
setTimeout(r, Math.min(200 * (1 << (attempt - 1)), 2000)),
);
try {
const onDisk = !!this.app.vault.getAbstractFileByPath(path);
if (terrain === null) {
if (onDisk) {
await setTerrainInFile(this.app, path, null);
void this.plugin.syncHexEncounterTableLink(path, null);
}
} else {
if (!onDisk) {
if (
!(await this.plugin.createHexNote(
x,
y,
this.activeMapName,
))
) {
this.renderGrid();
return;
}
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${x}"][data-y="${y}"]`)
?.addClass("duckmage-hex-exists");
}
await setTerrainInFile(this.app, path, terrain);
void this.plugin.syncHexEncounterTableLink(path, terrain);
}
break; // success
} catch (err) {
attempt++;
console.warn(
`[duckmage] terrain write attempt ${attempt} failed for ${path}:`,
err,
);
}
}
}
} finally {
this.flushing.delete(key);
this.updateSavingIndicator();
}
}
private scheduleIconWrite(
x: number,
y: number,
path: string,
icon: string | null,
): void {
this.pendingIconWrites.set(path, { x, y, icon });
this.updateSavingIndicator();
if (!this.flushing.has(`i:${path}`)) void this.flushIconWrites(path);
}
private async flushIconWrites(path: string): Promise<void> {
const key = `i:${path}`;
this.flushing.add(key);
this.updateSavingIndicator();
try {
while (this.pendingIconWrites.has(path)) {
const { x, y, icon } = this.pendingIconWrites.get(path)!;
this.pendingIconWrites.delete(path);
let attempt = 0;
while (true) {
if (attempt > 0)
await new Promise<void>((r) =>
setTimeout(r, Math.min(200 * (1 << (attempt - 1)), 2000)),
);
try {
const onDisk = !!this.app.vault.getAbstractFileByPath(path);
if (icon === null) {
if (onDisk) await setIconOverrideInFile(this.app, path, null);
} else {
if (!onDisk) {
if (
!(await this.plugin.createHexNote(
x,
y,
this.activeMapName,
))
) {
this.renderGrid();
return;
}
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${x}"][data-y="${y}"]`)
?.addClass("duckmage-hex-exists");
}
await setIconOverrideInFile(this.app, path, icon);
}
break; // success
} catch (err) {
attempt++;
console.warn(
`[duckmage] icon write attempt ${attempt} failed for ${path}:`,
err,
);
}
}
}
} finally {
this.flushing.delete(key);
this.updateSavingIndicator();
}
}
private scheduleGmIconWrite(
x: number,
y: number,
path: string,
icon: string | null,
): void {
this.pendingGmIconWrites.set(path, { x, y, icon });
this.updateSavingIndicator();
if (!this.flushing.has(`g:${path}`)) void this.flushGmIconWrites(path);
}
private async flushGmIconWrites(path: string): Promise<void> {
const key = `g:${path}`;
this.flushing.add(key);
this.updateSavingIndicator();
try {
while (this.pendingGmIconWrites.has(path)) {
const { x, y, icon } = this.pendingGmIconWrites.get(path)!;
this.pendingGmIconWrites.delete(path);
let attempt = 0;
while (true) {
if (attempt > 0)
await new Promise<void>((r) =>
setTimeout(r, Math.min(200 * (1 << (attempt - 1)), 2000)),
);
try {
const onDisk = !!this.app.vault.getAbstractFileByPath(path);
if (icon === null) {
if (onDisk) await setGmIconInFile(this.app, path, null);
} else {
if (!onDisk) {
if (
!(await this.plugin.createHexNote(
x,
y,
this.activeMapName,
))
) {
this.renderGrid();
return;
}
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${x}"][data-y="${y}"]`)
?.addClass("duckmage-hex-exists");
}
await setGmIconInFile(this.app, path, icon);
}
break; // success
} catch (err) {
attempt++;
console.warn(
`[duckmage] gm-icon write attempt ${attempt} failed for ${path}:`,
err,
);
}
}
}
} finally {
this.flushing.delete(key);
this.updateSavingIndicator();
}
}
private handlePathButton(): void {
if (this.drawingMode === "path") {
this.exitPathMode();
this.drawingMode = null;
this.updateToolbarButtonStates();
this.updatePathOverlay();
return;
}
new PathPickerModal(
this.app,
this.plugin,
this.activePathTypeName,
(typeName) => {
this.activePathTypeName = typeName;
this.drawingMode = "path";
this.updateToolbarButtonStates();
this.updatePathOverlay();
},
() => {
if (this.drawingMode !== "path") this.updateToolbarButtonStates();
},
).open();
}
/** Deep-clone a pathChains array for undo/redo snapshot. */
private cloneChains(chains: PathChain[]): PathChain[] {
return chains.map((c) => ({ typeName: c.typeName, hexes: [...c.hexes] }));
}
private pushPathUndo(
mapName: string,
before: PathChain[],
after: PathChain[],
): void {
this.undoStack.push({ kind: "path", mapName, before, after });
if (this.undoStack.length > this.UNDO_DEPTH) this.undoStack.shift();
this.redoStack = [];
this.updateUndoButton();
}
private async onHexPathDrawClick(x: number, y: number): Promise<void> {
if (!this.activePathTypeName) return;
const key = `${x}_${y}`;
const region = this.getActiveMap();
const chains = region.pathChains.filter(
(c) => c.typeName === this.activePathTypeName,
);
const before = this.cloneChains(region.pathChains);
// ── If adjacent to active end, extend that chain ─────────────────────
if (this.activePathEnd !== null) {
const [ax, ay] = this.activePathEnd.split("_").map(Number);
const isAdjacent = hexNeighbors(
ax,
ay,
this.plugin.settings.hexOrientation,
).some(([nx, ny]) => nx === x && ny === y);
if (isAdjacent) {
let target: PathChain | undefined;
if (
this.activePathChain !== null &&
this.activePathChain.hexes[this.activePathChain.hexes.length - 1] ===
this.activePathEnd
) {
target = this.activePathChain;
} else {
target = chains.find(
(c) => c.hexes[c.hexes.length - 1] === this.activePathEnd,
);
}
if (target) {
target.hexes.push(key);
this.activePathEnd = key;
this.activePathChain = target;
this.pushPathUndo(
region.name,
before,
this.cloneChains(region.pathChains),
);
await this.plugin.saveSettings();
this.updatePathOverlay();
return;
}
}
}
// ── Not adjacent (or no active chain) — start a new chain ────────────
const newChain: PathChain = {
typeName: this.activePathTypeName,
hexes: [key],
};
region.pathChains.push(newChain);
this.activePathEnd = key;
this.activePathChain = newChain;
this.pushPathUndo(region.name, before, this.cloneChains(region.pathChains));
await this.plugin.saveSettings();
this.updatePathOverlay();
}
private async onHexPathDeleteClick(x: number, y: number): Promise<void> {
const key = `${x}_${y}`;
const region = this.getActiveMap();
const chains = this.activePathTypeName
? region.pathChains.filter((c) => c.typeName === this.activePathTypeName)
: region.pathChains;
const before = this.cloneChains(region.pathChains);
for (let ci = 0; ci < chains.length; ci++) {
const pos = chains[ci].hexes.indexOf(key);
if (pos === -1) continue;
const chain = chains[ci];
const isActiveChain = chain === this.activePathChain;
if (chain.hexes.length === 1) {
// Remove entire chain from region.pathChains
const idx = region.pathChains.indexOf(chain);
if (idx !== -1) region.pathChains.splice(idx, 1);
if (isActiveChain) this.activePathChain = null;
} else if (pos === 0) {
chain.hexes.splice(0, 1);
} else if (pos === chain.hexes.length - 1) {
chain.hexes.splice(pos, 1);
} else {
// Split: replace with two chains
const left: PathChain = {
typeName: chain.typeName,
hexes: chain.hexes.slice(0, pos),
};
const right: PathChain = {
typeName: chain.typeName,
hexes: chain.hexes.slice(pos + 1),
};
const idx = region.pathChains.indexOf(chain);
if (idx !== -1) region.pathChains.splice(idx, 1, left, right);
if (isActiveChain) this.activePathChain = null;
}
if (this.activePathEnd === key) {
this.activePathEnd = null;
this.activePathChain = null;
}
this.pushPathUndo(
region.name,
before,
this.cloneChains(region.pathChains),
);
await this.plugin.saveSettings();
this.updatePathOverlay();
return;
}
}
private renderPathOverlay(gridContainer: HTMLElement): void {
this.viewportEl?.querySelector("svg.duckmage-path-svg")?.remove();
this.viewportEl?.removeClass("duckmage-svg-labels-active");
// Restore any icons that were hidden when the previous SVG elevated them
gridContainer
.querySelectorAll<HTMLElement>(".duckmage-hex-icon[data-svg-elevated]")
.forEach((img) => {
img.show();
img.removeAttribute("data-svg-elevated");
});
const region = this.getActiveMap();
const gmLayerActive = region.showGmLayer ?? true;
const hasContent =
region.pathChains.some((c) => c.hexes.length > 0) ||
this.activePathEnd !== null ||
(gmLayerActive && gridContainer.querySelector("[data-gm-icon]") !== null);
if (!hasContent) return;
// Build hex center map — offsetLeft/offsetTop are unaffected by CSS transform
const centerMap = new Map<string, { cx: number; cy: number }>();
let hexW = 0,
hexH = 0;
gridContainer
.querySelectorAll<HTMLElement>(".duckmage-hex")
.forEach((hexEl) => {
const x = Number(hexEl.dataset.x);
const y = Number(hexEl.dataset.y);
if (hexW === 0) {
hexW = hexEl.offsetWidth;
hexH = hexEl.offsetHeight;
}
let ox = hexEl.offsetWidth / 2;
let oy = hexEl.offsetHeight / 2;
let cur: HTMLElement | null = hexEl;
while (cur && cur !== this.viewportEl) {
ox += cur.offsetLeft;
oy += cur.offsetTop;
cur = cur.offsetParent as HTMLElement | null;
}
centerMap.set(`${x}_${y}`, { cx: ox, cy: oy });
});
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.classList.add("duckmage-path-svg");
const w = gridContainer.offsetLeft + gridContainer.offsetWidth + 20;
const h = gridContainer.offsetTop + gridContainer.offsetHeight + 20;
svg.setAttribute("width", String(w));
svg.setAttribute("height", String(h));
const DASH_ARRAYS: Record<string, string> = {
solid: "",
dashed: "8 4",
dotted: "2 4",
};
const appendPath = (
pts: { cx: number; cy: number }[],
color: string,
strokeWidth: number,
dashArray = "",
smooth = true,
) => {
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", smooth ? smoothPath(pts) : sharpPath(pts));
path.setAttribute("stroke", color);
path.setAttribute("stroke-width", String(strokeWidth));
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("fill", "none");
if (dashArray) path.setAttribute("stroke-dasharray", dashArray);
svg.appendChild(path);
};
const isFlat = this.plugin.settings.hexOrientation === "flat";
const hexRadius = isFlat ? hexW / 2 : hexH / 2;
// Draw all path types in definition order
for (const pt of this.plugin.settings.pathTypes) {
const typeChains = region.pathChains.filter(
(c) => c.typeName === pt.name,
);
const dash = DASH_ARRAYS[pt.lineStyle] ?? "";
for (const chain of typeChains) {
let pts: { cx: number; cy: number }[];
let smooth: boolean;
if (pt.routing === "meander") {
pts = buildMeanderPts(chain.hexes, centerMap);
smooth = true;
} else if (pt.routing === "edge") {
pts = buildEdgePts(chain.hexes, centerMap, isFlat, hexRadius);
smooth = false;
} else {
pts = chain.hexes
.map((k) => centerMap.get(k))
.filter((p): p is { cx: number; cy: number } => !!p);
smooth = true;
}
if (pts.length >= 2) appendPath(pts, pt.color, pt.width, dash, smooth);
}
}
// Small circle to mark the active endpoint (only visible in path mode)
if (this.drawingMode === "path" && this.activePathEnd) {
const activeType = this.activePathTypeName
? this.plugin.settings.pathTypes.find(
(p) => p.name === this.activePathTypeName,
)
: undefined;
const color = activeType?.color ?? "#888888";
const pos = centerMap.get(this.activePathEnd);
if (pos) {
const circle = document.createElementNS(svgNS, "circle");
circle.setAttribute("cx", String(pos.cx));
circle.setAttribute("cy", String(pos.cy));
circle.setAttribute("r", "5");
circle.setAttribute("fill", color);
circle.setAttribute("stroke", "white");
circle.setAttribute("stroke-width", "1.5");
circle.setAttribute("opacity", "0.9");
svg.appendChild(circle);
}
}
// Elevate override icons above roads/rivers by rendering them inside the SVG.
gridContainer
.querySelectorAll<HTMLElement>("[data-icon-override]")
.forEach((hexEl) => {
const iconName = hexEl.dataset.iconOverride!;
const key = `${hexEl.dataset.x!}_${hexEl.dataset.y!}`;
const pos = centerMap.get(key);
if (!pos) return;
const origImg = hexEl.querySelector<HTMLElement>(".duckmage-hex-icon");
if (origImg) {
origImg.hide();
origImg.setAttribute("data-svg-elevated", "1");
}
const imgEl = document.createElementNS(svgNS, "image");
const iconW = hexEl.offsetWidth * 0.78;
const iconH = hexEl.offsetHeight * 0.78;
imgEl.setAttribute("x", String(pos.cx - iconW / 2));
imgEl.setAttribute("y", String(pos.cy - iconH / 2));
imgEl.setAttribute("width", String(iconW));
imgEl.setAttribute("height", String(iconH));
imgEl.setAttribute("href", getIconUrl(this.plugin, iconName));
imgEl.setAttribute("opacity", "0.75");
imgEl.setAttribute("class", "duckmage-svg-icon-override");
svg.appendChild(imgEl);
});
// Render all hex coordinate labels as SVG text so they sit above roads,
// rivers, and icons regardless of CSS stacking context.
gridContainer
.querySelectorAll<HTMLElement>(".duckmage-hex")
.forEach((hexEl) => {
const x = hexEl.dataset.x!;
const y = hexEl.dataset.y!;
const pos = centerMap.get(`${x}_${y}`);
if (!pos) return;
const hasTerrain = !!hexEl.style.backgroundColor;
const textEl = document.createElementNS(svgNS, "text");
textEl.setAttribute("x", String(pos.cx));
// Nudge label toward bottom of hex (same visual position as the HTML label)
textEl.setAttribute("y", String(pos.cy + hexEl.offsetHeight * 0.28));
textEl.setAttribute("text-anchor", "middle");
textEl.setAttribute("dominant-baseline", "middle");
textEl.setAttribute("font-size", String(hexEl.offsetHeight * 0.12));
textEl.setAttribute("font-weight", "600");
if (hasTerrain) {
textEl.setAttribute("fill", "#ffffff");
textEl.setAttribute("paint-order", "stroke");
textEl.setAttribute("stroke", "rgba(0,0,0,0.85)");
textEl.setAttribute("stroke-width", "2");
textEl.setAttribute("stroke-linejoin", "round");
} else {
textEl.setAttribute("fill", "var(--text-muted)");
}
textEl.setAttribute("pointer-events", "none");
textEl.setAttribute("class", "duckmage-svg-coord-label");
textEl.textContent = `${x},${y}`;
svg.appendChild(textEl);
});
// GM layer icons — small badge in the top-right quadrant of the hex.
// Rendered above coord labels so they're always visible.
// The terrain icon is NOT hidden — gm-icon is purely additive.
if (gmLayerActive) {
gridContainer
.querySelectorAll<HTMLElement>("[data-gm-icon]")
.forEach((hexEl) => {
const gmIconName = hexEl.dataset.gmIcon!;
const key = `${hexEl.dataset.x!}_${hexEl.dataset.y!}`;
const pos = centerMap.get(key);
if (!pos) return;
const size = Math.round(hexEl.offsetWidth * 0.38);
const imgEl = document.createElementNS(svgNS, "image");
imgEl.setAttribute("x", String(pos.cx + hexEl.offsetWidth * 0.12));
imgEl.setAttribute("y", String(pos.cy - hexEl.offsetHeight * 0.46));
imgEl.setAttribute("width", String(size));
imgEl.setAttribute("height", String(size));
imgEl.setAttribute("href", getIconUrl(this.plugin, gmIconName));
imgEl.setAttribute("class", "duckmage-svg-gm-icon");
svg.appendChild(imgEl);
});
}
this.viewportEl?.addClass("duckmage-svg-labels-active");
this.viewportEl?.appendChild(svg);
}
private updatePathOverlay(): void {
const gridContainer = this.viewportEl?.querySelector<HTMLElement>(
".duckmage-hex-map-grid",
);
if (!gridContainer) {
this.renderGrid();
return;
}
this.renderPathOverlay(gridContainer);
}
// GM icons live inside the path SVG. Re-render that SVG only if the grid
// already exists — never fall back to renderGrid (would cause infinite loop
// when called from syncToRegion inside an in-progress renderGrid).
private updateGmIcons(): void {
const gridContainer = this.viewportEl?.querySelector<HTMLElement>(
".duckmage-hex-map-grid",
);
if (gridContainer) this.renderPathOverlay(gridContainer);
}
private updateFactionOverlay(): void {
const gridContainer = this.viewportEl?.querySelector<HTMLElement>(
".duckmage-hex-map-grid",
);
if (gridContainer) this.renderFactionOverlay(gridContainer);
}
private clearFactionOverlay(): void {
this.viewportEl?.querySelector("svg.duckmage-faction-svg")?.remove();
this.viewportEl?.querySelector(".duckmage-faction-legend")?.remove();
}
private renderFactionOverlay(gridContainer: HTMLElement): void {
this.viewportEl?.querySelector("svg.duckmage-faction-svg")?.remove();
this.viewportEl?.querySelector(".duckmage-faction-legend")?.remove();
if (!this.getActiveMap().showFactionOverlay) return;
const hexEls = Array.from(
gridContainer.querySelectorAll<HTMLElement>(".duckmage-hex"),
);
if (hexEls.length === 0) return;
// ── Build center map ──────────────────────────────────────────────────────
let hexW = 0, hexH = 0, gapPx = 0;
const centerMap = new Map<string, { cx: number; cy: number }>();
for (const hexEl of hexEls) {
if (hexW === 0) {
hexW = hexEl.offsetWidth;
hexH = hexEl.offsetHeight;
gapPx = parseFloat(window.getComputedStyle(hexEl).marginTop) || 0;
}
let ox = hexEl.offsetWidth / 2, oy = hexEl.offsetHeight / 2;
let cur: HTMLElement | null = hexEl;
while (cur && cur !== this.viewportEl) {
ox += cur.offsetLeft;
oy += cur.offsetTop;
cur = cur.offsetParent as HTMLElement | null;
}
centerMap.set(`${hexEl.dataset.x}_${hexEl.dataset.y}`, { cx: ox, cy: oy });
}
// ── Build faction color map ───────────────────────────────────────────────
const folder = normalizeFolder(this.plugin.settings.factionsFolder);
const factionColorMap = new Map<string, string>();
for (const f of this.app.vault.getMarkdownFiles()) {
if (folder && !f.path.startsWith(folder + "/")) continue;
const color = getFactionColorFromFile(this.app, f.path);
if (color) factionColorMap.set(f.basename, color);
}
// ── Group hexes by faction name ───────────────────────────────────────────
const factionHexKeys = new Map<string, string[]>();
for (const hexEl of hexEls) {
const gx = Number(hexEl.dataset.x);
const gy = Number(hexEl.dataset.y);
const hexPath = this.plugin.hexPath(gx, gy, this.activeMapName);
const links = this.getFactionLinksFromCache(hexPath);
const key = `${gx}_${gy}`;
for (const link of links) {
if (!factionHexKeys.has(link)) factionHexKeys.set(link, []);
factionHexKeys.get(link)!.push(key);
}
}
if (factionHexKeys.size === 0) return;
// ── SVG setup ─────────────────────────────────────────────────────────────
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.classList.add("duckmage-faction-svg");
const w = gridContainer.offsetLeft + gridContainer.offsetWidth + 20;
const h = gridContainer.offsetTop + gridContainer.offsetHeight + 20;
svg.setAttribute("width", String(w));
svg.setAttribute("height", String(h));
const isFlat = this.plugin.settings.hexOrientation === "flat";
const W = hexW, H = hexH;
const r = isFlat ? W / 2 : H / 2;
const scale = r > 0 ? (r + gapPx) / r : 1;
const hexVerts = (cx: number, cy: number): [number, number][] => {
const pts = isFlat
? [ [-W/4, -H/2], [W/4, -H/2], [W/2, 0], [W/4, H/2], [-W/4, H/2], [-W/2, 0] ]
: [ [0, -H/2], [W/2, -H/4], [W/2, H/4], [0, H/2], [-W/2, H/4], [-W/2, -H/4] ];
return pts.map(([dx, dy]) => [cx + dx * scale, cy + dy * scale]);
};
const vk = (x: number, y: number) => `${x.toFixed(1)},${y.toFixed(1)}`;
const ek = (v1: [number, number], v2: [number, number]) => {
const k1 = vk(v1[0], v1[1]), k2 = vk(v2[0], v2[1]);
return k1 < k2 ? `${k1}|${k2}` : `${k2}|${k1}`;
};
const activeFactions: { name: string; color: string }[] = [];
let hasElements = false;
for (const [factionName, hexKeys] of factionHexKeys) {
const color = factionColorMap.get(factionName);
if (!color) continue;
// ── Edge counting ─────────────────────────────────────────────────────
type EdgeEntry = { v1: [number, number]; v2: [number, number]; count: number };
const edgeCounts = new Map<string, EdgeEntry>();
for (const key of hexKeys) {
const pos = centerMap.get(key);
if (!pos) continue;
const verts = hexVerts(pos.cx, pos.cy);
for (let i = 0; i < 6; i++) {
const v1 = verts[i], v2 = verts[(i + 1) % 6];
const k = ek(v1, v2);
const ex = edgeCounts.get(k);
if (ex) { ex.count++; } else { edgeCounts.set(k, { v1, v2, count: 1 }); }
}
}
// ── Build vertex adjacency from boundary edges ─────────────────────────
const coordOf = new Map<string, [number, number]>();
type AdjEntry = { key: string; coord: [number, number]; edgeKey: string };
const vertAdj = new Map<string, AdjEntry[]>();
for (const { v1, v2, count } of edgeCounts.values()) {
if (count !== 1) continue;
const k1 = vk(v1[0], v1[1]), k2 = vk(v2[0], v2[1]);
const edgeKey = ek(v1, v2);
coordOf.set(k1, v1); coordOf.set(k2, v2);
if (!vertAdj.has(k1)) vertAdj.set(k1, []);
if (!vertAdj.has(k2)) vertAdj.set(k2, []);
vertAdj.get(k1)!.push({ key: k2, coord: v2, edgeKey });
vertAdj.get(k2)!.push({ key: k1, coord: v1, edgeKey });
}
if (coordOf.size === 0) continue;
// ── Walk all closed rings ──────────────────────────────────────────────
const usedEdges = new Set<string>();
const rings: [number, number][][] = [];
for (const { v1, v2, count } of edgeCounts.values()) {
if (count !== 1) continue;
const initKey = ek(v1, v2);
if (usedEdges.has(initKey)) continue;
usedEdges.add(initKey);
const ring: [number, number][] = [v1, v2];
const startK = vk(v1[0], v1[1]);
let curK = vk(v2[0], v2[1]);
let safety = 0;
while (curK !== startK && safety++ < 10_000) {
const next = (vertAdj.get(curK) ?? []).find(
(e) => !usedEdges.has(e.edgeKey),
);
if (!next) break;
usedEdges.add(next.edgeKey);
if (next.key === startK) break;
ring.push(next.coord);
curK = next.key;
}
if (ring.length >= 3) rings.push(ring);
}
if (rings.length === 0) continue;
// ── Render filled blob(s) ─────────────────────────────────────────────
const g = document.createElementNS(svgNS, "g");
g.setAttribute("opacity", "0.45");
svg.appendChild(g);
for (const ring of rings) {
const d =
ring
.map(
(c, i) =>
`${i === 0 ? "M" : "L"}${c[0].toFixed(1)},${c[1].toFixed(1)}`,
)
.join(" ") + " Z";
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", color);
path.setAttribute("stroke", color);
path.setAttribute("stroke-width", String(gapPx * 2 + 2));
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("paint-order", "stroke fill");
g.appendChild(path);
}
activeFactions.push({ name: factionName, color });
hasElements = true;
}
if (!hasElements) return;
// Insert before path SVG so roads/labels render on top
const pathSvg = this.viewportEl?.querySelector("svg.duckmage-path-svg");
if (pathSvg && this.viewportEl) {
this.viewportEl.insertBefore(svg, pathSvg);
} else if (this.viewportEl) {
this.viewportEl.appendChild(svg);
}
this.renderFactionLegend(activeFactions, gridContainer);
}
private renderFactionLegend(
factions: { name: string; color: string }[],
gridContainer: HTMLElement,
): void {
if (!this.viewportEl) return;
const legend = this.viewportEl.createDiv({ cls: "duckmage-faction-legend" });
// Position the card to the right of the hex grid in the viewport canvas
const gridRight = gridContainer.offsetLeft + gridContainer.offsetWidth;
legend.style.left = `${gridRight + 24}px`;
legend.style.top = `${gridContainer.offsetTop}px`;
for (const { name, color } of [...factions].sort((a, b) => a.name.localeCompare(b.name))) {
const row = legend.createDiv({ cls: "duckmage-faction-legend-row" });
const swatch = row.createDiv({ cls: "duckmage-faction-legend-swatch" });
swatch.style.backgroundColor = color;
row.createSpan({ text: name, cls: "duckmage-faction-legend-name" });
}
}
private getFactionLinksFromCache(hexFilePath: string): string[] {
const file = this.app.vault.getAbstractFileByPath(hexFilePath);
const fromCache: string[] = [];
if (file instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(file);
if (cache) {
const headings = cache.headings ?? [];
const factionHeading = headings.find(
(h) => h.heading === "Factions" && h.level === 3,
);
if (factionHeading) {
const factionStart = factionHeading.position.start.offset;
const nextHeading = headings.find(
(h) => h.position.start.offset > factionStart && h.level <= 3,
);
const factionEnd = nextHeading?.position.start.offset ?? Infinity;
fromCache.push(
...(cache.links ?? [])
.filter(
(lk) =>
lk.position.start.offset > factionStart &&
lk.position.start.offset < factionEnd,
)
.map((lk) => {
// Normalize to basename — fileToLinktext may return a path when names clash
const raw = lk.link.split("|")[0].split("#")[0].trim();
return raw.split("/").pop() ?? raw;
}),
);
}
}
}
const pending = this.pendingFactionLinks.get(hexFilePath);
const erased = this.erasedFactionLinks.get(hexFilePath);
const merged = new Set(fromCache);
if (pending) for (const name of pending) merged.add(name);
if (erased) for (const name of erased) merged.delete(name);
return [...merged];
}
// ── Region overlay ─────────────────────────────────────────────────────────
private async onHexRegionPaintClick(x: number, y: number): Promise<void> {
if (this.drawingMode !== "regionLink" || !this.paintRegionPath) return;
const regionFile = this.app.vault.getAbstractFileByPath(this.paintRegionPath);
if (!(regionFile instanceof TFile)) return;
const regionBasename = regionFile.basename;
const hexPath = this.plugin.hexPath(x, y, this.activeMapName);
if (this.getHexRegionFromCache(hexPath) === regionBasename) return;
// Update pending state before await so overlay refreshes immediately
this.erasedRegions.delete(hexPath);
this.pendingRegions.set(hexPath, regionBasename);
if (this.getActiveMap().showRegionOverlay) this.updateRegionOverlay();
await setHexRegionInFile(this.app, hexPath, regionBasename);
void this.plugin.syncHexRegionTableLink(hexPath, regionBasename);
}
private async onHexRegionEraseClick(x: number, y: number): Promise<void> {
if (!this.paintRegionPath) return;
const regionFile = this.app.vault.getAbstractFileByPath(this.paintRegionPath);
if (!(regionFile instanceof TFile)) return;
const regionBasename = regionFile.basename;
const hexPath = this.plugin.hexPath(x, y, this.activeMapName);
if (this.getHexRegionFromCache(hexPath) !== regionBasename) return;
this.pendingRegions.delete(hexPath);
this.erasedRegions.add(hexPath);
if (this.getActiveMap().showRegionOverlay) this.updateRegionOverlay();
await setHexRegionInFile(this.app, hexPath, null);
void this.plugin.syncHexRegionTableLink(hexPath, null);
}
private getHexRegionFromCache(hexFilePath: string): string | null {
if (this.erasedRegions.has(hexFilePath)) return null;
const pending = this.pendingRegions.get(hexFilePath);
if (pending !== undefined) return pending;
const file = this.app.vault.getAbstractFileByPath(hexFilePath);
if (!(file instanceof TFile)) return null;
const cache = this.app.metadataCache.getFileCache(file);
const region: unknown = cache?.frontmatter?.["region"];
return typeof region === "string" ? region : null;
}
private updateRegionOverlay(): void {
const gridContainer = this.viewportEl?.querySelector<HTMLElement>(
".duckmage-hex-map-grid",
);
if (gridContainer) this.renderRegionOverlay(gridContainer);
}
private clearRegionOverlay(): void {
this.viewportEl?.querySelector("svg.duckmage-region-svg")?.remove();
}
private renderRegionOverlay(gridContainer: HTMLElement): void {
this.viewportEl?.querySelector("svg.duckmage-region-svg")?.remove();
if (!this.getActiveMap().showRegionOverlay) return;
const hexEls = Array.from(
gridContainer.querySelectorAll<HTMLElement>(".duckmage-hex"),
);
if (hexEls.length === 0) return;
// ── Build center map ────────────────────────────────────────────────────
let hexW = 0, hexH = 0, gapPx = 0;
const centerMap = new Map<string, { cx: number; cy: number }>();
for (const hexEl of hexEls) {
if (hexW === 0) {
hexW = hexEl.offsetWidth;
hexH = hexEl.offsetHeight;
gapPx = parseFloat(window.getComputedStyle(hexEl).marginTop) || 0;
}
let ox = hexEl.offsetWidth / 2, oy = hexEl.offsetHeight / 2;
let cur: HTMLElement | null = hexEl;
while (cur && cur !== this.viewportEl) {
ox += cur.offsetLeft;
oy += cur.offsetTop;
cur = cur.offsetParent as HTMLElement | null;
}
centerMap.set(`${hexEl.dataset.x}_${hexEl.dataset.y}`, { cx: ox, cy: oy });
}
// ── Build region → hex keys map ─────────────────────────────────────────
const regionHexKeys = new Map<string, string[]>();
for (const hexEl of hexEls) {
const gx = Number(hexEl.dataset.x);
const gy = Number(hexEl.dataset.y);
const hexFilePath = this.plugin.hexPath(gx, gy, this.activeMapName);
const regionName = this.getHexRegionFromCache(hexFilePath);
if (!regionName) continue;
const key = `${gx}_${gy}`;
if (!regionHexKeys.has(regionName)) regionHexKeys.set(regionName, []);
regionHexKeys.get(regionName)!.push(key);
}
if (regionHexKeys.size === 0) return;
// ── Region color map ────────────────────────────────────────────────────
const folder = normalizeFolder(this.plugin.settings.regionsFolder);
const regionColorMap = new Map<string, string>();
for (const f of this.app.vault.getMarkdownFiles()) {
if (f.basename.startsWith("_")) continue;
if (folder && !f.path.startsWith(folder + "/")) continue;
const color = getRegionColorFromFile(this.app, f.path);
if (color) regionColorMap.set(f.basename, color);
}
// ── SVG setup ───────────────────────────────────────────────────────────
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.classList.add("duckmage-region-svg");
const svgW = gridContainer.offsetLeft + gridContainer.offsetWidth + 40;
const svgH = gridContainer.offsetTop + gridContainer.offsetHeight + 40;
svg.setAttribute("width", String(svgW));
svg.setAttribute("height", String(svgH));
const isFlat = this.plugin.settings.hexOrientation === "flat";
const W = hexW, H = hexH;
const r = isFlat ? W / 2 : H / 2;
// Expanded vertices: adjacent same-region hexes' shared vertices coincide exactly
const scale = r > 0 ? (r + gapPx) / r : 1;
const hexVerts = (cx: number, cy: number): [number, number][] => {
const pts = isFlat
? [ [-W/4, -H/2], [W/4, -H/2], [W/2, 0], [W/4, H/2], [-W/4, H/2], [-W/2, 0] ]
: [ [0, -H/2], [W/2, -H/4], [W/2, H/4], [0, H/2], [-W/2, H/4], [-W/2, -H/4] ];
return pts.map(([dx, dy]) => [cx + dx * scale, cy + dy * scale]);
};
const vk = (x: number, y: number) => `${x.toFixed(1)},${y.toFixed(1)}`;
const ek = (v1: [number, number], v2: [number, number]) => {
const k1 = vk(v1[0], v1[1]), k2 = vk(v2[0], v2[1]);
return k1 < k2 ? `${k1}|${k2}` : `${k2}|${k1}`;
};
let hasElements = false;
for (const [regionName, hexKeys] of regionHexKeys) {
const color = regionColorMap.get(regionName);
if (!color) continue;
// ── Edge counting ───────────────────────────────────────────────────
type EdgeEntry = { v1: [number, number]; v2: [number, number]; count: number };
const edgeCounts = new Map<string, EdgeEntry>();
const hexCenters: { cx: number; cy: number }[] = [];
for (const key of hexKeys) {
const pos = centerMap.get(key);
if (!pos) continue;
hexCenters.push(pos);
const verts = hexVerts(pos.cx, pos.cy);
for (let i = 0; i < 6; i++) {
const v1 = verts[i], v2 = verts[(i + 1) % 6];
const k = ek(v1, v2);
const ex = edgeCounts.get(k);
if (ex) { ex.count++; } else { edgeCounts.set(k, { v1, v2, count: 1 }); }
}
}
// ── Build vertex adjacency from boundary edges ──────────────────────
const coordOf = new Map<string, [number, number]>();
type AdjEntry = { key: string; coord: [number, number]; edgeKey: string };
const vertAdj = new Map<string, AdjEntry[]>();
for (const { v1, v2, count } of edgeCounts.values()) {
if (count !== 1) continue;
const k1 = vk(v1[0], v1[1]), k2 = vk(v2[0], v2[1]);
const edgeKey = ek(v1, v2);
coordOf.set(k1, v1); coordOf.set(k2, v2);
if (!vertAdj.has(k1)) vertAdj.set(k1, []);
if (!vertAdj.has(k2)) vertAdj.set(k2, []);
vertAdj.get(k1)!.push({ key: k2, coord: v2, edgeKey });
vertAdj.get(k2)!.push({ key: k1, coord: v1, edgeKey });
}
if (coordOf.size === 0) continue;
// ── Walk all closed rings (handles disconnected islands) ────────────
const usedEdges = new Set<string>();
const rings: [number, number][][] = [];
for (const { v1, v2, count } of edgeCounts.values()) {
if (count !== 1) continue;
const initKey = ek(v1, v2);
if (usedEdges.has(initKey)) continue;
usedEdges.add(initKey);
const ring: [number, number][] = [v1, v2];
const startK = vk(v1[0], v1[1]);
let curK = vk(v2[0], v2[1]);
let safety = 0;
while (curK !== startK && safety++ < 10_000) {
const next = (vertAdj.get(curK) ?? []).find(
(e) => !usedEdges.has(e.edgeKey),
);
if (!next) break;
usedEdges.add(next.edgeKey);
if (next.key === startK) break;
ring.push(next.coord);
curK = next.key;
}
if (ring.length >= 3) rings.push(ring);
}
if (rings.length === 0) continue;
// ── PCA label angle ─────────────────────────────────────────────────
const n = hexCenters.length;
const mx = hexCenters.reduce((s, p) => s + p.cx, 0) / n;
const my = hexCenters.reduce((s, p) => s + p.cy, 0) / n;
let labelAngle = 0;
if (n > 1) {
const sxx = hexCenters.reduce((s, p) => s + (p.cx - mx) ** 2, 0);
const syy = hexCenters.reduce((s, p) => s + (p.cy - my) ** 2, 0);
const sxy = hexCenters.reduce((s, p) => s + (p.cx - mx) * (p.cy - my), 0);
let ang = (Math.atan2(2 * sxy, sxx - syy) * 180) / Math.PI / 2;
if (ang > 90) ang -= 180;
if (ang < -90) ang += 180;
labelAngle = ang;
}
// ── Render filled blob(s) ───────────────────────────────────────────
const g = document.createElementNS(svgNS, "g");
g.setAttribute("opacity", "0.45");
svg.appendChild(g);
for (const ring of rings) {
const d =
ring
.map(
(c, i) =>
`${i === 0 ? "M" : "L"}${c[0].toFixed(1)},${c[1].toFixed(1)}`,
)
.join(" ") + " Z";
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", color);
// Stroke bridges the inter-hex gap; round joins/caps smooth blob edges
path.setAttribute("stroke", color);
path.setAttribute("stroke-width", String(gapPx * 2 + 2));
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("paint-order", "stroke fill");
g.appendChild(path);
}
// ── Region name label (full opacity, above fill) ────────────────────
// Font size scales with sqrt(hexCount) so larger regions get bigger labels
const fontSize = Math.round(Math.min(10 + 3 * Math.sqrt(n), 52));
const text = document.createElementNS(svgNS, "text");
text.setAttribute("x", mx.toFixed(1));
text.setAttribute("y", my.toFixed(1));
text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "middle");
text.setAttribute("font-size", String(fontSize));
text.setAttribute(
"transform",
`rotate(${labelAngle.toFixed(1)},${mx.toFixed(1)},${my.toFixed(1)})`,
);
text.setAttribute("class", "duckmage-region-label");
text.textContent = regionName;
svg.appendChild(text);
hasElements = true;
}
if (!hasElements) return;
// Regions render below factions and paths
const factionSvg = this.viewportEl?.querySelector("svg.duckmage-faction-svg");
const pathSvg = this.viewportEl?.querySelector("svg.duckmage-path-svg");
const insertBefore = factionSvg ?? pathSvg ?? null;
if (insertBefore && this.viewportEl) {
this.viewportEl.insertBefore(svg, insertBefore);
} else if (this.viewportEl) {
this.viewportEl.appendChild(svg);
}
}
// ── Token layer ───────────────────────────────────────────────────────────
private loadTokensForMap(): void {
this.tokenEntries = [];
for (const file of this.app.vault.getMarkdownFiles()) {
const entry = getTokenDataFromCache(this.app, file);
if (entry && entry.map === this.activeMapName) {
this.tokenEntries.push(entry);
}
}
}
private buildTokenCenterMap(gridContainer: HTMLElement): Map<string, { cx: number; cy: number }> {
const centerMap = new Map<string, { cx: number; cy: number }>();
gridContainer.querySelectorAll<HTMLElement>(".duckmage-hex").forEach((hexEl) => {
const x = Number(hexEl.dataset.x);
const y = Number(hexEl.dataset.y);
let ox = hexEl.offsetWidth / 2, oy = hexEl.offsetHeight / 2;
let cur: HTMLElement | null = hexEl;
while (cur && cur !== this.viewportEl) {
ox += cur.offsetLeft;
oy += cur.offsetTop;
cur = cur.offsetParent as HTMLElement | null;
}
centerMap.set(`${x}_${y}`, { cx: ox, cy: oy });
});
return centerMap;
}
private renderTokenLayer(gridContainer: HTMLElement): void {
this.viewportEl?.querySelector(".duckmage-token-layer")?.remove();
if (this.getActiveMap().showTokens === false) return;
this.loadTokensForMap();
if (this.tokenEntries.length === 0) return;
const centerMap = this.buildTokenCenterMap(gridContainer);
const layer = this.viewportEl!.createDiv({ cls: "duckmage-token-layer" });
for (const token of this.tokenEntries) {
if (!token.visible) continue;
const center = centerMap.get(token.hex);
if (!center) continue;
const size = token.size ?? "md";
const tokenEl = layer.createDiv({
cls: `duckmage-token duckmage-token-${token.shape} duckmage-token-size-${size}`,
});
tokenEl.style.left = `${center.cx}px`;
tokenEl.style.top = `${center.cy}px`;
tokenEl.title = token.title;
if (token.color) tokenEl.style.setProperty("--token-color", token.color);
if (token.border) tokenEl.style.setProperty("--token-border", token.border);
if (token.icon) {
const img = tokenEl.createEl("img", { cls: "duckmage-token-icon" });
img.src = getIconUrl(this.plugin, token.icon);
img.alt = token.title;
} else {
tokenEl.createSpan({
cls: "duckmage-token-label",
text: token.title.charAt(0).toUpperCase(),
});
}
const snapToken = { ...token }; // capture for closures
tokenEl.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
if (this.drawingMode !== null) return;
this.showTokenContextMenu(e, snapToken);
});
// Single mousedown handler — startTokenDrag calls onClickInstead if no drag occurs.
tokenEl.addEventListener("mousedown", (e) => {
if (e.button !== 0 || this.drawingMode !== null) return;
e.stopPropagation();
this.startTokenDrag(snapToken, tokenEl, e, centerMap, () => {
new TokenInfoModal(
this.app,
snapToken,
(x, y) => this.centerOnHex(x, y),
() => {
void removeTokenFrontmatter(this.app, snapToken.filePath)
.then(() => this.updateTokenLayer());
},
() => this.openTokenEditor(snapToken),
).open();
});
});
}
}
private updateTokenLayer(): void {
const gridContainer = this.viewportEl?.querySelector<HTMLElement>(
".duckmage-hex-map-grid",
);
if (!gridContainer) return;
this.renderTokenLayer(gridContainer);
}
private startTokenDrag(
token: TokenEntry,
tokenEl: HTMLElement,
startEvt: MouseEvent,
centerMap: Map<string, { cx: number; cy: number }>,
onClickInstead: () => void,
): void {
const startClientX = startEvt.clientX;
const startClientY = startEvt.clientY;
const origLeft = parseFloat(tokenEl.style.left);
const origTop = parseFloat(tokenEl.style.top);
let isDragging = false;
let closestHex: string | null = null;
const onMove = (e: MouseEvent) => {
if (!isDragging) {
if (Math.hypot(e.clientX - startClientX, e.clientY - startClientY) < 4) return;
isDragging = true;
tokenEl.addClass("duckmage-token-dragging");
}
const dx = (e.clientX - startClientX) / this.zoom;
const dy = (e.clientY - startClientY) / this.zoom;
tokenEl.style.left = `${origLeft + dx}px`;
tokenEl.style.top = `${origTop + dy}px`;
const cx = origLeft + dx;
const cy = origTop + dy;
let best: string | null = null;
let bestDist = Infinity;
for (const [key, center] of centerMap) {
const dist = Math.hypot(center.cx - cx, center.cy - cy);
if (dist < bestDist) { bestDist = dist; best = key; }
}
if (best !== closestHex) {
if (closestHex) {
const [px, py] = closestHex.split("_").map(Number);
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${px}"][data-y="${py}"]`)
?.removeClass("duckmage-token-drop-target");
}
closestHex = best;
if (best) {
const [px, py] = best.split("_").map(Number);
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${px}"][data-y="${py}"]`)
?.addClass("duckmage-token-drop-target");
}
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
tokenEl.removeClass("duckmage-token-dragging");
if (closestHex) {
const [px, py] = closestHex.split("_").map(Number);
this.viewportEl
?.querySelector<HTMLElement>(`[data-x="${px}"][data-y="${py}"]`)
?.removeClass("duckmage-token-drop-target");
}
if (!isDragging) {
onClickInstead();
return;
}
if (closestHex && closestHex !== token.hex) {
// Snap to new hex center immediately; the cache `changed` event re-renders the layer
// once the write lands. Calling updateTokenLayer() here would render stale cache
// data (old hex), then re-render again on the changed event — causing visible flicker.
const newCenter = centerMap.get(closestHex);
if (newCenter) {
tokenEl.style.left = `${newCenter.cx}px`;
tokenEl.style.top = `${newCenter.cy}px`;
}
void setTokenHex(this.app, token.filePath, closestHex, token.map);
} else {
const orig = centerMap.get(token.hex);
if (orig) {
tokenEl.style.left = `${orig.cx}px`;
tokenEl.style.top = `${orig.cy}px`;
}
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
private openTokenEditor(token: TokenEntry): void {
new TokenModal(
this.app,
this.plugin,
token.filePath,
token.title,
{ icon: token.icon, shape: token.shape, size: token.size, color: token.color, border: token.border, description: token.description },
(_notePath, data) => {
void applyTokenFrontmatter(this.app, token.filePath, data)
.then(() => this.updateTokenLayer());
},
() => {
void removeTokenFrontmatter(this.app, token.filePath)
.then(() => this.updateTokenLayer());
},
).open();
}
private showTokenContextMenu(evt: MouseEvent, token: TokenEntry): void {
const menu = new Menu();
menu.addItem((item) =>
item
.setTitle("Edit token")
.setIcon("pencil")
.onClick(() => this.openTokenEditor(token)),
);
menu.addItem((item) =>
item
.setTitle("Open note")
.setIcon("file-text")
.onClick(() => {
const file = this.app.vault.getAbstractFileByPath(token.filePath);
if (file instanceof TFile) {
void this.app.workspace.getLeaf("tab").openFile(file);
}
}),
);
menu.addSeparator();
menu.addItem((item) =>
item
.setTitle("Remove token")
.setIcon("trash")
.onClick(() => {
void removeTokenFrontmatter(this.app, token.filePath)
.then(() => this.updateTokenLayer());
}),
);
menu.showAtMouseEvent(evt);
}
}