const STYLE_VALUE_KEYS = [ "backgroundColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle", "synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight", "synopsisFontStyle", "submapBackgroundColor", "submapBorderColor" ]; /** Return a detached value at the model boundary. */ function copy(value) { return JSON.parse(JSON.stringify(value)); } /** Return a six-digit HTML colour or the supplied fallback. */ function cmapColorValue(value, fallback = "#f3f6f8") { return /^#[0-9a-f]{6}$/i.test(value || "") ? value.toLowerCase() : fallback; } /** Convert a stored CSS font size to typographic points. */ function cmapFontSizeInPoints(value, baseSize = 11) { const size = Number.parseFloat(value); if (!Number.isFinite(size)) return baseSize; if (/px$/i.test(value || "")) return size * 0.75; if (/em$/i.test(value || "")) return size * baseSize; if (/%$/i.test(value || "")) return (size / 100) * baseSize; return size; } /** Normalize an editable font size to the range accepted by the backend. */ function normalizedCmapFontSize(value, fallback) { const size = Number(value); const usableSize = Number.isFinite(size) ? size : fallback; return Math.max(6, Math.min(54, Math.round(usableSize * 2) / 2)); } /** Format a typographic point value for a form input. */ function displayCmapFontSize(value) { return String(Math.round(value * 2) / 2); } /** * Own the wiki-wide CMap styles and colour palette. * The backend supplies the default style; this model validates UI changes * against that style and exposes detached values to its consumers. */ export class CmapAppearance { constructor(data) { this.replace(data); } get styles() { return copy(this._styles); } get palette() { return [...this._palette]; } get defaultValues() { return copy(this._styles.find((style) => style.id === "default").values); } /** Replace the aggregate with a complete backend representation. */ replace(data) { const styles = Array.isArray(data?.styles) ? data.styles : []; const defaultStyle = styles.find((style) => style?.id === "default"); const palette = Array.isArray(data?.palette) ? data.palette : []; const completeDefault = defaultStyle?.values && STYLE_VALUE_KEYS.every((key) => Object.hasOwn(defaultStyle.values, key)); if (!completeDefault || palette.length === 0) { throw new TypeError("CMap appearance requires a default style and colour palette"); } const seenIds = new Set(); this._styles = styles.map((style) => { const id = typeof style?.id === "string" ? style.id.trim() : ""; const hasName = typeof style?.name === "string" && style.name.trim(); const hasNameKey = typeof style?.nameKey === "string" && style.nameKey.trim(); if (!/^[A-Za-z0-9_-]+$/.test(id) || seenIds.has(id) || (!hasName && !hasNameKey) || !style.values) { throw new TypeError("CMap appearance contains an invalid style"); } seenIds.add(id); return { id, ...(hasNameKey ? { nameKey: style.nameKey.trim() } : { name: style.name.trim() }), protected: id === "default", values: this.normalizeValues(style.values, defaultStyle.values) }; }); this._palette = palette.map((color) => { if (!/^#[0-9a-f]{6}$/i.test(color || "")) { throw new TypeError("CMap appearance contains an invalid palette colour"); } return color.toLowerCase(); }); return this; } /** Return the complete detached representation for backend storage. */ toData() { return { styles: this.styles, palette: this.palette }; } /** Normalize editable values using the backend-provided default style. */ normalizeValues(values, fallbackValues = null) { if (!values || typeof values !== "object") return null; const fallback = fallbackValues || this.defaultValues; const fontSize = normalizedCmapFontSize(values.fontSize, fallback.fontSize); return { backgroundColor: cmapColorValue(values.backgroundColor, fallback.backgroundColor), textColor: cmapColorValue(values.textColor, fallback.textColor), fontFamily: typeof values.fontFamily === "string" && values.fontFamily.trim() ? values.fontFamily.trim() : fallback.fontFamily, fontSize, fontWeight: String(values.fontWeight) === "400" ? "400" : "700", fontStyle: values.fontStyle === "italic" ? "italic" : "normal", synopsisTextColor: cmapColorValue(values.synopsisTextColor, fallback.synopsisTextColor), synopsisFontFamily: typeof values.synopsisFontFamily === "string" && values.synopsisFontFamily.trim() ? values.synopsisFontFamily.trim() : fallback.synopsisFontFamily, synopsisFontSize: normalizedCmapFontSize( values.synopsisFontSize, Math.max(6, fontSize - 2)), synopsisFontWeight: String(values.synopsisFontWeight) === "700" ? "700" : "400", synopsisFontStyle: values.synopsisFontStyle === "italic" ? "italic" : "normal", submapBackgroundColor: cmapColorValue( values.submapBackgroundColor, fallback.submapBackgroundColor), submapBorderColor: cmapColorValue(values.submapBorderColor, fallback.submapBorderColor) }; } style(id) { const style = this._styles.find((candidate) => candidate.id === id); return style ? copy(style) : null; } matchingStyleId(values) { const normalized = this.normalizeValues(values); if (!normalized) return ""; const style = this._styles.find((candidate) => STYLE_VALUE_KEYS.every((key) => candidate.values[key] === normalized[key])); return style?.id || ""; } /** Insert or replace one non-default named style. */ putStyle(style) { const name = typeof style?.name === "string" ? style.name.trim() : ""; const id = typeof style?.id === "string" ? style.id.trim() : ""; if (!/^[A-Za-z0-9_-]+$/.test(id) || id === "default" || !name) { throw new TypeError("A custom CMap style requires an id and name"); } const normalized = { id, name, protected: false, values: this.normalizeValues(style.values) }; const existingIndex = this._styles.findIndex((candidate) => candidate.id === normalized.id); if (existingIndex < 0) this._styles.push(normalized); else this._styles.splice(existingIndex, 1, normalized); return copy(normalized); } /** Delete a custom style and refuse deletion of protected styles. */ deleteStyle(id) { const style = this._styles.find((candidate) => candidate.id === id); if (!style || style.protected) return false; this._styles = this._styles.filter((candidate) => candidate.id !== id); return true; } setPaletteColor(index, color) { if (!Number.isInteger(index) || index < 0 || index >= this._palette.length || !/^#[0-9a-f]{6}$/i.test(color || "")) return false; this._palette[index] = color.toLowerCase(); return true; } } export { cmapColorValue, cmapFontSizeInPoints, displayCmapFontSize, normalizedCmapFontSize };