mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
3020 lines
107 KiB
TypeScript
3020 lines
107 KiB
TypeScript
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<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 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<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();
|
|
},
|
|
{ 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);
|
|
});
|
|
|
|
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<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 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.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<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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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<HTMLElement>(
|
|
`[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<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;
|
|
}
|
|
|
|
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 = 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);
|
|
}
|
|
}
|
|
}
|