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 } 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 } 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"; 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[]; }; export class HexMapView extends ItemView { plugin: HexmakerPlugin; private zoom = 1; private panX = 0; private panY = 0; private viewportEl: HTMLElement | null = null; private drawingMode: | "path" | "terrain" | "icon" | "tableLink" | "factionLink" | "regionLink" | "swap" | 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 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(); // "t:", "i:", or "g:" // Faction links painted but not yet reflected in the metadata cache private pendingFactionLinks = new Map>(); // Faction links erased but cache may still reflect them — excluded from overlay private erasedFactionLinks = new Map>(); // Region painted/erased but not yet in metadata cache private pendingRegions = new Map(); // hexPath → regionBasename private erasedRegions = new Set(); // 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 | null = null; private undoBtn: HTMLButtonElement | null = null; private redoBtn: HTMLButtonElement | null = null; activeMapName = "default"; private mapBtn: 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} ▾`); } onOpen(): Promise { // 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(); }, { 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( ".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(".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(".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); }); this.mapBtn = controlsEl.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.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 const toolsPanel = new DrawingToolPanel(controlsEl, (panel) => this.buildDrawingToolbarContent(panel), ); 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(); }, ); 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(); } }), ); this.renderGrid(); return Promise.resolve(); } private createExpandButtons(container: HTMLElement): void { const dirs = [ { cls: "duckmage-expand-top", action: async () => { this.getActiveMap().gridOffset.y--; this.getActiveMap().gridSize.rows++; await this.plugin.saveSettings(); this.renderGrid(); const r = this.getActiveMap(); const xs = Array.from( { length: r.gridSize.cols }, (_, i) => r.gridOffset.x + i, ); void this.plugin.generateHexNotes(this.activeMapName, xs, [ r.gridOffset.y, ]); }, }, { cls: "duckmage-expand-bottom", action: async () => { this.getActiveMap().gridSize.rows++; await this.plugin.saveSettings(); this.renderGrid(); 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, ); void this.plugin.generateHexNotes(this.activeMapName, xs, [newY]); }, }, { cls: "duckmage-expand-left", action: async () => { this.getActiveMap().gridOffset.x--; this.getActiveMap().gridSize.cols++; await this.plugin.saveSettings(); this.renderGrid(); const r = this.getActiveMap(); const ys = Array.from( { length: r.gridSize.rows }, (_, i) => r.gridOffset.y + i, ); void this.plugin.generateHexNotes( this.activeMapName, [r.gridOffset.x], ys, ); }, }, { cls: "duckmage-expand-right", action: async () => { this.getActiveMap().gridSize.cols++; await this.plugin.saveSettings(); this.renderGrid(); 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, ); void this.plugin.generateHexNotes(this.activeMapName, [newX], ys); }, }, ]; for (const { cls, action } of dirs) { const btn = container.createEl("button", { cls: `duckmage-expand-btn ${cls}`, text: "+", }); btn.addEventListener("click", () => { void action(); }); } } 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 === "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(), ); } private exitPathMode(): void { this.activePathEnd = null; this.activePathChain = null; } private handleTerrainButton(): void { if (this.drawingMode === "terrain") { this.exitTerrainMode(); return; } // 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.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 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( `[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 { 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 performSwap(pathA: string, pathB: string): Promise { 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 { 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((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((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( `[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.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})`; } } setSelectedHex(x: number, y: number): void { if (this.selectedHex) { this.viewportEl ?.querySelector( `[data-x="${this.selectedHex.x}"][data-y="${this.selectedHex.y}"]`, ) ?.removeClass("is-selected"); } this.selectedHex = { x, y }; this.viewportEl ?.querySelector(`[data-x="${x}"][data-y="${y}"]`) ?.addClass("is-selected"); } centerOnHex(x: number, y: number): void { const hexEl = this.viewportEl?.querySelector( `[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(); } renderGrid( terrainOverrides?: Map, iconOverrides?: Map, ): 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); } 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); } }, (nx, ny) => this.setSelectedHex(nx, ny), () => { if (this.selectedHex) { this.viewportEl ?.querySelector( `[data-x="${this.selectedHex.x}"][data-y="${this.selectedHex.y}"]`, ) ?.removeClass("is-selected"); this.selectedHex = null; } }, { gmLayerActive: this.getActiveMap().showGmLayer ?? true }, ); 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; } 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("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 { 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; } 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(`[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(`[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( `[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( `[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 { 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( `[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 { 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(); 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( `[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 { 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(); 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 { 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 { 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 { 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( `[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 { 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((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(`[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 { 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((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(`[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 { 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((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(`[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 { 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 { 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(".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(); let hexW = 0, hexH = 0; gridContainer .querySelectorAll(".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 = { 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("[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(".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(".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("[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( ".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( ".duckmage-hex-map-grid", ); if (gridContainer) this.renderPathOverlay(gridContainer); } private updateFactionOverlay(): void { const gridContainer = this.viewportEl?.querySelector( ".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(".duckmage-hex"), ); if (hexEls.length === 0) return; // ── Build center map ────────────────────────────────────────────────────── let hexW = 0, hexH = 0, gapPx = 0; const centerMap = new Map(); 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(); 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(); 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(); 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(); type AdjEntry = { key: string; coord: [number, number]; edgeKey: string }; const vertAdj = new Map(); 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(); 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 { 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 { 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 = cache?.frontmatter?.["region"]; return typeof region === "string" ? region : null; } private updateRegionOverlay(): void { const gridContainer = this.viewportEl?.querySelector( ".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(".duckmage-hex"), ); if (hexEls.length === 0) return; // ── Build center map ──────────────────────────────────────────────────── let hexW = 0, hexH = 0, gapPx = 0; const centerMap = new Map(); 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(); 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(); 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(); 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(); type AdjEntry = { key: string; coord: [number, number]; edgeKey: string }; const vertAdj = new Map(); 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(); 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); } } }