refactoring van de cmap structuren bijna compleet

This commit is contained in:
2026-09-03 16:21:21 +02:00
parent 1abc84489f
commit bd1ef6bed0
75 changed files with 2547 additions and 1967 deletions
+181
View File
@@ -0,0 +1,181 @@
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
};