add and enhance faction overlay.

This commit is contained in:
isaprettycoolguy@protonmail.com 2026-05-09 20:49:23 -04:00
parent efc9d637cf
commit 90d2496fc3
8 changed files with 690 additions and 52 deletions

View file

@ -31,7 +31,7 @@ The clip/controls separation ensures toolbar buttons are never hidden by the vie
| Terrain | `"terrain"` | `TerrainPickerModal` | frontmatter `terrain:` |
| Icon | `"icon"` | `IconPickerModal` | frontmatter `icon:` |
| Link Table | `"tableLink"` | `TablePickerModal` | `### Encounters Table` |
| Link Faction | `"factionLink"` | `FactionPickerModal` | `### Factions` |
| Factions | `"factionLink"` | `FactionPickerModal` | `### Factions` |
**Tool lifecycle** (standard pattern):
1. Button click → `handle*Button()` opens picker modal

View file

@ -43,6 +43,28 @@ export async function setTerrainInFile(
return true;
}
export function getFactionColorFromFile(app: App, path: string): string | null {
const color = getFrontMatter(app, path)?.["faction-color"];
return typeof color === "string" ? color : null;
}
export async function setFactionColorInFile(
app: App,
path: string,
color: string | null,
): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
await app.fileManager.processFrontMatter(file, (fm: Frontmatter) => {
if (color === null) {
delete fm["faction-color"];
} else {
fm["faction-color"] = color;
}
});
return true;
}
export function getIconOverrideFromFile(app: App, path: string): string | null {
const icon = getFrontMatter(app, path)?.icon;
return typeof icon === "string" ? icon : null;

View file

@ -0,0 +1,218 @@
import { App, Setting, TFile } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import { normalizeFolder } from "../utils";
import { getFactionColorFromFile, setFactionColorInFile } from "../frontmatter";
// ── Faction editor sub-modal ─────────────────────────────────────────────────
class FactionEditorModal extends HexmakerModal {
constructor(
app: App,
private file: TFile,
private initialColor: string | null,
/** Called on save with the (possibly updated) color and basename. */
private onSaved: (color: string | null, newBasename: string) => void,
) {
super(app);
}
onOpen(): void {
this.titleEl.setText(this.file.basename);
this.makeDraggable();
const { contentEl } = this;
let pendingColor = this.initialColor ?? "#888888";
let pendingName = this.file.basename;
// ── Open-note link ────────────────────────────────────────────────────────
const headerRow = contentEl.createDiv({ cls: "duckmage-faction-editor-header" });
const openBtn = headerRow.createEl("button", {
text: "Open note →",
cls: "duckmage-faction-editor-open-btn",
});
openBtn.addEventListener("click", () => {
void this.app.workspace.getLeaf().openFile(this.file);
this.close();
});
// ── Description (async read from note body) ───────────────────────────────
const descWrap = contentEl.createDiv({ cls: "duckmage-faction-editor-desc" });
descWrap.createSpan({ text: "Description", cls: "duckmage-faction-editor-desc-label" });
const descText = descWrap.createEl("p", {
text: "Loading…",
cls: "duckmage-faction-editor-desc-text",
});
void (async () => {
const raw = await this.app.vault.read(this.file);
// Strip YAML frontmatter
const body = raw.replace(/^---[\s\S]*?---\n?/, "").trim();
// First non-empty paragraph
const para = (body.split(/\n{2,}/).find((p) => p.trim()) ?? "").trim();
const preview = para.length > 400 ? para.slice(0, 400) + "…" : para;
descText.setText(preview || "(no description)");
})();
// ── Name ──────────────────────────────────────────────────────────────────
new Setting(contentEl).setName("Name").addText((text) =>
text.setValue(pendingName).onChange((v) => {
pendingName = v.trim() || pendingName;
}),
);
// ── Color ─────────────────────────────────────────────────────────────────
new Setting(contentEl)
.setName("Color")
.addColorPicker((cp) =>
cp.setValue(pendingColor).onChange((v) => {
pendingColor = v;
}),
);
// ── Buttons ───────────────────────────────────────────────────────────────
const btnRow = contentEl.createDiv({ cls: "duckmage-faction-editor-btns" });
const saveBtn = btnRow.createEl("button", {
text: "Save",
cls: "mod-cta duckmage-faction-editor-save",
});
saveBtn.addEventListener("click", () => {
void (async () => {
// Rename first so this.file.path is updated before the color write
let newBasename = this.file.basename;
if (pendingName && pendingName !== this.file.basename) {
const folder = this.file.parent?.path ?? "";
const newPath = folder
? `${folder}/${pendingName}.md`
: `${pendingName}.md`;
await this.app.fileManager.renameFile(this.file, newPath);
newBasename = pendingName;
}
// this.file.path is updated in-place by Obsidian after rename
await setFactionColorInFile(this.app, this.file.path, pendingColor);
this.onSaved(pendingColor, newBasename);
this.close();
})();
});
const clearBtn = btnRow.createEl("button", {
text: "Clear color",
cls: "duckmage-faction-editor-clear",
});
if (!this.initialColor) clearBtn.disabled = true;
clearBtn.addEventListener("click", () => {
void (async () => {
await setFactionColorInFile(this.app, this.file.path, null);
this.onSaved(null, this.file.basename);
this.close();
})();
});
}
onClose(): void {
this.contentEl.empty();
}
}
// ── Faction palette picker ───────────────────────────────────────────────────
export class FactionPickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private onPicked: (filePath: string) => void,
) {
super(app);
}
onOpen(): void {
this.titleEl.setText("Factions");
this.makeDraggable();
this.buildPalette();
}
private buildPalette(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-faction-picker");
const folder = normalizeFolder(this.plugin.settings.factionsFolder);
const files = this.app.vault
.getMarkdownFiles()
.filter((f) => {
if (f.basename.startsWith("_")) return false;
if (!folder) return true;
return f.path.startsWith(folder + "/");
})
.sort((a, b) => a.basename.localeCompare(b.basename));
if (files.length === 0) {
contentEl.createEl("p", {
text: folder
? `No faction notes found in "${folder}".`
: "No factions folder configured.",
cls: "duckmage-faction-picker-empty",
});
return;
}
const grid = contentEl.createDiv({ cls: "duckmage-faction-palette" });
for (const file of files) {
let color = getFactionColorFromFile(this.app, file.path);
const tile = grid.createDiv({ cls: "duckmage-faction-tile" });
const preview = tile.createDiv({ cls: "duckmage-faction-tile-preview" });
if (color) {
preview.style.backgroundColor = color;
} else {
preview.addClass("duckmage-faction-tile-preview-empty");
}
// Edit button — top-right corner, shown on hover via CSS
const editBtn = tile.createEl("button", {
cls: "duckmage-faction-tile-edit",
attr: { title: "Edit faction" },
});
editBtn.setText("✏");
// Name label — kept as a reference so rename can update it
const nameEl = tile.createSpan({
text: file.basename,
cls: "duckmage-faction-tile-name",
});
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
new FactionEditorModal(
this.app,
file, // TFile mutated in-place by Obsidian on rename
color,
(newColor, newBasename) => {
color = newColor;
if (newColor) {
preview.style.backgroundColor = newColor;
preview.removeClass("duckmage-faction-tile-preview-empty");
} else {
preview.style.backgroundColor = "";
preview.addClass("duckmage-faction-tile-preview-empty");
}
nameEl.setText(newBasename);
},
).open();
});
// Main tile click → select faction for painting (file.path reflects renames)
tile.addEventListener("click", () => {
this.onPicked(file.path);
this.close();
});
}
}
onClose(): void {
this.contentEl.empty();
}
}

View file

@ -11,7 +11,8 @@ import {
import { HexEditorModal } from "./HexEditorModal";
import { TerrainPickerModal } from "./TerrainPickerModal";
import { IconPickerModal } from "./IconPickerModal";
import { addLinkToSection, getLinksInSection } from "../sections";
import { addLinkToSection, getLinksInSection, removeLinkFromSection } from "../sections";
import { getFactionColorFromFile } from "../frontmatter";
import {
VIEW_TYPE_HEX_MAP,
VIEW_TYPE_HEX_TABLE,
@ -30,6 +31,7 @@ import {
import { GotoHexModal } from "./GotoHexModal";
import { HexHelpModal } from "./HexHelpModal";
import { FolderTreePickerModal } from "./FolderTreePickerModal";
import { FactionPickerModal } from "./FactionPickerModal";
import { DrawingToolPanel, OverlayPanel } from "./HexSidePanel";
type TerrainUndoEntry = {
@ -101,6 +103,10 @@ export class HexMapView extends ItemView {
{ x: number; y: number; icon: string | null }
>();
private flushing = new Set<string>(); // "t:<path>" or "i:<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>>();
private savingIndicatorEl: HTMLElement | null = null;
// Undo / redo
private readonly UNDO_DEPTH = 20;
@ -213,7 +219,7 @@ export class HexMapView extends ItemView {
return;
}
if (e.button !== 0) return;
if (this.drawingMode === "terrain" || this.drawingMode === "icon") {
if (this.drawingMode === "terrain" || this.drawingMode === "icon" || this.drawingMode === "factionLink") {
isTerrainPainting = true;
lastPaintedKey = null;
if (this.drawingMode === "terrain")
@ -227,12 +233,13 @@ export class HexMapView extends ItemView {
const y = Number(hexEl.dataset.y);
lastPaintedKey = `${x}_${y}`;
if (this.drawingMode === "terrain") this.onHexPaintClick(x, y);
else this.onHexIconClick(x, y);
else if (this.drawingMode === "icon") this.onHexIconClick(x, y);
else void this.onHexFactionPaintClick(x, y);
hasDragged = true; // suppress the subsequent click event
}
return; // skip pan setup
}
// Any other active tool (road, river, tableLink, factionLink, swap):
// 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;
@ -260,6 +267,7 @@ export class HexMapView extends ItemView {
lastPaintedKey = key;
if (this.drawingMode === "terrain") this.onHexPaintClick(x, y);
else if (this.drawingMode === "icon") this.onHexIconClick(x, y);
else void this.onHexFactionPaintClick(x, y);
}
this.updateBrushHighlight(x, y);
} else {
@ -334,7 +342,7 @@ export class HexMapView extends ItemView {
}
if (this.drawingMode === null) return;
const onHex = (e.target as HTMLElement).closest(".duckmage-hex");
if (onHex && this.drawingMode === "path") return;
if (onHex && (this.drawingMode === "path" || this.drawingMode === "factionLink")) return;
e.preventDefault();
e.stopPropagation();
const now = Date.now();
@ -482,6 +490,7 @@ export class HexMapView extends ItemView {
this.plugin,
() => this.viewportEl,
() => this.getActiveMap(),
(show) => { if (show) this.updateFactionOverlay(); else this.clearFactionOverlay(); },
);
toolsPanel.onBeforeOpen = () => this.overlayPanel?.close();
this.overlayPanel.onBeforeOpen = () => toolsPanel.close();
@ -492,6 +501,24 @@ export class HexMapView extends ItemView {
text: "Saving…",
});
this.registerEvent(
this.app.metadataCache.on("changed", (file) => {
// Clear stale pending/erased entries for hex files
this.pendingFactionLinks.delete(file.path);
this.erasedFactionLinks.delete(file.path);
// If a faction file's metadata changed (e.g. color edited), refresh the
// overlay now that the cache is guaranteed to have the new frontmatter.
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();
}
}),
);
this.renderGrid();
return Promise.resolve();
}
@ -652,7 +679,7 @@ export class HexMapView extends ItemView {
cls: "duckmage-draw-btn duckmage-draw-btn-tablelink",
});
this.factionLinkBtnLabel = this.factionLinkBtn.createSpan({
text: "Link faction",
text: "Factions",
});
this.factionLinkBtn.addEventListener("click", () =>
this.handleFactionLinkButton(),
@ -760,20 +787,11 @@ export class HexMapView extends ItemView {
}
private handleFactionLinkButton(): void {
if (this.drawingMode === "factionLink") { this.exitFactionLinkMode(); return; }
new FolderTreePickerModal(
this.app,
this.plugin,
this.plugin.settings.factionsFolder,
"Select faction",
"Filter factions…",
"No factions found.",
(file) => {
this.drawingMode = "factionLink";
this.paintFactionPath = file.path;
this.updateToolbarButtonStates();
},
).open();
new FactionPickerModal(this.app, this.plugin, (filePath) => {
this.drawingMode = "factionLink";
this.paintFactionPath = filePath;
this.updateToolbarButtonStates();
}).open();
}
private exitFactionLinkMode(): void {
@ -1094,7 +1112,7 @@ export class HexMapView extends ItemView {
"Faction";
this.factionLinkBtnLabel.setText("Link: " + name);
} else {
this.factionLinkBtnLabel.setText("Link faction");
this.factionLinkBtnLabel.setText("Factions");
}
}
}
@ -1275,6 +1293,7 @@ export class HexMapView extends ItemView {
}
this.renderPathOverlay(gridContainer);
this.renderFactionOverlay(gridContainer);
}
private openHexEditorModal(x: number, y: number): void {
@ -1313,6 +1332,10 @@ export class HexMapView extends ItemView {
void this.onHexPathDeleteClick(x, y);
return;
}
if (this.drawingMode === "factionLink") {
void this.onHexFactionEraseClick(x, y);
return;
}
if (this.drawingMode === "swap") {
this.exitSwapMode();
return;
@ -1374,10 +1397,6 @@ export class HexMapView extends ItemView {
await this.onHexTableLinkClick(x, y);
return;
}
if (this.drawingMode === "factionLink") {
await this.onHexFactionLinkClick(x, y);
return;
}
if (this.drawingMode === "swap") {
await this.onHexSwapClick(x, y);
return;
@ -1556,40 +1575,66 @@ export class HexMapView extends ItemView {
}
}
private async onHexFactionLinkClick(x: number, y: number): Promise<void> {
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,
);
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)) return;
if (!(hexFile instanceof TFile)) {
// Note creation failed — revert pending entry
this.pendingFactionLinks.get(hexPath)?.delete(factionBasename);
return;
}
}
const target = this.app.metadataCache.fileToLinktext(factionFile, hexPath);
const linkText = `[[${target}]]`;
const existing = await getLinksInSection(this.app, hexPath, "Factions");
if (existing.includes(target)) {
new Notice(`Already linked on ${x},${y}`);
return;
}
await addLinkToSection(this.app, hexPath, "Factions", linkText);
const hexEl = this.viewportEl?.querySelector<HTMLElement>(
`[data-x="${x}"][data-y="${y}"]`,
);
if (hexEl) {
hexEl.addClass("duckmage-hex-exists");
const blip = hexEl.createSpan({ cls: "duckmage-hex-blip" });
blip.addEventListener("animationend", () => blip.remove(), {
once: true,
});
}
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 ────────────────────────────────────────
@ -2209,4 +2254,165 @@ export class HexMapView extends ItemView {
}
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();
}
private renderFactionOverlay(gridContainer: HTMLElement): void {
this.viewportEl?.querySelector("svg.duckmage-faction-svg")?.remove();
if (!this.getActiveMap().showFactionOverlay) return;
// Gather hex centers (same pattern as renderPathOverlay)
const hexEls = Array.from(
gridContainer.querySelectorAll<HTMLElement>(".duckmage-hex"),
);
if (hexEls.length === 0) return;
let hexW = 0, hexH = 0;
let 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;
// Read the actual computed margin so polygons expand to cover the gap
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 });
}
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;
// Circumradius: for flat-top = W/2, for pointy-top = H/2
const r = isFlat ? W / 2 : H / 2;
// Expand polygons by gapPx so adjacent same-faction hexes merge seamlessly
const scale = r > 0 ? (r + gapPx) / r : 1;
const hexPolygonPoints = (cx: number, cy: number): string => {
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}`)
.join(" ");
};
// Build faction color map from all faction notes (metadata cache — no file reads)
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 polygons by faction so group-level opacity composites them together —
// overlapping expanded polygons from adjacent hexes don't double-darken.
const factionGroups = new Map<string, SVGGElement>();
const getFactionGroup = (color: string): SVGGElement => {
if (factionGroups.has(color)) return factionGroups.get(color)!;
const g = document.createElementNS(svgNS, "g") as SVGGElement;
g.setAttribute("opacity", "0.45");
svg.appendChild(g);
factionGroups.set(color, g);
return g;
};
let hasPolygons = false;
for (const hexEl of hexEls) {
const x = hexEl.dataset.x!;
const y = hexEl.dataset.y!;
const hexPath = this.plugin.hexPath(Number(x), Number(y), this.activeMapName);
const factionLinks = this.getFactionLinksFromCache(hexPath);
const pos = centerMap.get(`${x}_${y}`);
if (!pos) continue;
for (const link of factionLinks) {
const color = factionColorMap.get(link);
if (!color) continue;
const g = getFactionGroup(color);
const polygon = document.createElementNS(svgNS, "polygon");
polygon.setAttribute("points", hexPolygonPoints(pos.cx, pos.cy));
polygon.setAttribute("fill", color);
g.appendChild(polygon);
hasPolygons = true;
}
}
if (!hasPolygons) 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);
}
}
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];
}
}

View file

@ -96,18 +96,22 @@ export class OverlayPanel extends HexSidePanel {
private plugin: HexmakerPlugin;
private getViewportEl: () => HTMLElement | null;
private getActiveMap: () => MapData;
private onFactionOverlayChange: (show: boolean) => void;
private checkboxes = new Map<OverlayKey, HTMLInputElement>();
private factionOverlayCb: HTMLInputElement | null = null;
constructor(
container: HTMLElement,
plugin: HexmakerPlugin,
getViewportEl: () => HTMLElement | null,
getActiveMap: () => MapData,
onFactionOverlayChange: (show: boolean) => void,
) {
super(container, "layers", 44, "Map overlays");
this.plugin = plugin;
this.getViewportEl = getViewportEl;
this.getActiveMap = getActiveMap;
this.onFactionOverlayChange = onFactionOverlayChange;
this.buildPanel(this.panelEl);
}
@ -136,19 +140,51 @@ export class OverlayPanel extends HexSidePanel {
apply();
});
}
// Faction overlay — triggers a re-render rather than a CSS class toggle
const factionRow = panel.createDiv({ cls: "duckmage-overlay-row" });
const factionCb = document.createElement("input");
factionCb.type = "checkbox";
factionCb.checked = false; // default — refreshed in syncToRegion()
factionRow.appendChild(factionCb);
this.factionOverlayCb = factionCb;
const factionLabel = factionRow.createSpan({
text: "Faction overlay",
cls: "duckmage-overlay-label",
});
const applyFaction = () => {
const map = this.getActiveMap();
map.showFactionOverlay = factionCb.checked;
void this.plugin.saveSettings();
this.onFactionOverlayChange(factionCb.checked);
};
factionCb.addEventListener("change", applyFaction);
factionLabel.addEventListener("click", () => {
factionCb.checked = !factionCb.checked;
applyFaction();
});
}
/** Read the current region's saved state and apply it to the viewport + checkboxes. */
/** Read the current map's saved state and apply it to the viewport + checkboxes. */
syncToRegion(): void {
const region = this.getActiveMap();
const map = this.getActiveMap();
for (const opt of OVERLAY_OPTIONS) {
// undefined → true (backwards compat)
const value = region[opt.key];
const value = map[opt.key];
const show = value === undefined ? true : Boolean(value);
const cb = this.checkboxes.get(opt.key);
if (cb) cb.checked = show;
this.applyClass(opt, show);
}
// Faction overlay — undefined → false (opt-in)
if (this.factionOverlayCb) {
const show = map.showFactionOverlay ?? false;
this.factionOverlayCb.checked = show;
this.onFactionOverlayChange(show);
}
}
private applyClass(opt: OverlayOption, show: boolean): void {

View file

@ -30,7 +30,7 @@ Opens the icon palette. Select an icon to enter paint mode, then left-click hexe
### Link table
Opens a folder-tree picker scoped to your Tables folder. Select a random-encounter table, then left-click hexes to link that table into each hex's **Encounters Table** section.
### Link faction
### Factions
Opens a folder-tree picker scoped to your Factions folder. Select a faction note, then left-click hexes to link it into each hex's **Factions** section.
### Swap

View file

@ -23,6 +23,7 @@ export interface MapData {
showCoords?: boolean; // undefined = true (backwards-compatible)
showTerrainIcons?: boolean; // undefined = true
showIconOverrides?: boolean; // undefined = true
showFactionOverlay?: boolean; // undefined = false (opt-in)
}
export interface TerrainPalette {

View file

@ -143,6 +143,15 @@
z-index: 5;
}
/* SVG overlay for faction color fills — sits below paths */
.duckmage-faction-svg {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 4;
}
/* ── Pointy-top row layout ────────────────────────────────────────────────── */
.duckmage-hex-row {
@ -3196,3 +3205,149 @@ tr:hover .duckmage-rt-entry-copy-btn:hover {
flex-shrink: 0;
border: 1px solid var(--background-modifier-border);
}
/* ── Faction picker modal ──────────────────────────────────────────────────── */
.duckmage-faction-picker {
min-width: 320px;
}
.duckmage-faction-picker-empty {
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
/* Hidden native color picker — triggered via .click() */
.duckmage-faction-color-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Palette grid */
.duckmage-faction-palette {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 6px;
padding: 4px 0;
}
.duckmage-faction-tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 4px 4px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.1s, background 0.1s;
}
.duckmage-faction-tile:hover {
background: var(--background-modifier-hover);
border-color: var(--color-accent);
}
.duckmage-faction-tile-preview {
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
}
/* Empty / no-color state: diagonal stripe */
.duckmage-faction-tile-preview-empty {
background:
linear-gradient(
to bottom right,
transparent calc(50% - 1px),
var(--background-modifier-border) calc(50% - 1px),
var(--background-modifier-border) calc(50% + 1px),
transparent calc(50% + 1px)
),
var(--background-secondary);
}
.duckmage-faction-tile-name {
font-size: 0.75em;
text-align: center;
word-break: break-word;
max-width: 100%;
color: var(--text-normal);
line-height: 1.2;
}
/* Edit button — top-right corner, appears on hover */
.duckmage-faction-tile-edit {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
padding: 0;
font-size: 0.7em;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.1s;
z-index: 1;
}
.duckmage-faction-tile:hover .duckmage-faction-tile-edit {
opacity: 1;
}
/* Faction editor sub-modal */
.duckmage-faction-editor-header {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.duckmage-faction-editor-open-btn {
font-size: 0.85em;
padding: 2px 8px;
cursor: pointer;
}
.duckmage-faction-editor-desc {
margin-bottom: 12px;
padding: 8px 10px;
background: var(--background-secondary);
border-radius: 4px;
}
.duckmage-faction-editor-desc-label {
display: block;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 4px;
}
.duckmage-faction-editor-desc-text {
margin: 0;
font-size: 0.88em;
line-height: 1.5;
color: var(--text-normal);
white-space: pre-wrap;
}
.duckmage-faction-editor-btns {
display: flex;
gap: 8px;
margin-top: 12px;
justify-content: flex-end;
}