diff --git a/public/main.js b/public/main.js
index c0ac9d110..2f055942e 100644
--- a/public/main.js
+++ b/public/main.js
@@ -650,6 +650,8 @@ async function generate(options) {
Provinces.generate();
Provinces.getPoles();
+ Labels.generate();
+
Rivers.specify();
Lakes.defineNames();
diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js
index 0b1cd227c..aaf635396 100644
--- a/public/modules/dynamic/auto-update.js
+++ b/public/modules/dynamic/auto-update.js
@@ -1106,4 +1106,161 @@ export function resolveVersionConflicts(mapVersion) {
}
}
+
+ if (isOlderThan("1.114.0")) {
+ // v1.114.0 moved labels data from SVG to data model
+ // Migrate old SVG labels to pack.labels structure
+ if (!pack.labels || !pack.labels.length) {
+ pack.labels = [];
+ let labelId = 0;
+
+ // Migrate state labels
+ const stateLabelsGroup = document.querySelector("#labels > #states");
+ if (stateLabelsGroup) {
+ stateLabelsGroup.querySelectorAll("text").forEach(textElement => {
+ const id = textElement.getAttribute("id");
+ if (!id || !id.startsWith("stateLabel")) return;
+
+ const stateIdMatch = id.match(/stateLabel(\d+)/);
+ if (!stateIdMatch) return;
+
+ const stateId = +stateIdMatch[1];
+ const state = pack.states[stateId];
+ if (!state || state.removed) return;
+
+ const textPath = textElement.querySelector("textPath");
+ if (!textPath) return;
+
+ const text = textPath.textContent.trim();
+ const fontSizeAttr = textPath.getAttribute("font-size");
+ const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : 100;
+
+ pack.labels.push({
+ i: labelId++,
+ type: "state",
+ stateId: stateId,
+ text: text,
+ fontSize: fontSize
+ });
+ });
+ }
+
+ // Migrate burg labels
+ const burgLabelsGroup = document.querySelector("#burgLabels");
+ if (burgLabelsGroup) {
+ burgLabelsGroup.querySelectorAll("g").forEach(groupElement => {
+ const group = groupElement.getAttribute("id");
+ if (!group) return;
+
+ const dxAttr = groupElement.getAttribute("data-dx");
+ const dyAttr = groupElement.getAttribute("data-dy");
+ const dx = dxAttr ? parseFloat(dxAttr) : 0;
+ const dy = dyAttr ? parseFloat(dyAttr) : 0;
+
+ groupElement.querySelectorAll("text").forEach(textElement => {
+ const burgId = +textElement.getAttribute("data-id");
+ if (!burgId) return;
+
+ const burg = pack.burgs[burgId];
+ if (!burg || burg.removed) return;
+
+ const text = textElement.textContent.trim();
+ // Use burg coordinates, not SVG text coordinates
+ // SVG coordinates may be affected by viewbox transforms
+ const x = burg.x;
+ const y = burg.y;
+
+ pack.labels.push({
+ i: labelId++,
+ type: "burg",
+ burgId: burgId,
+ group: group,
+ text: text,
+ x: x,
+ y: y,
+ dx: dx,
+ dy: dy
+ });
+ });
+ });
+ }
+
+ // Migrate custom labels
+ const customLabelsGroup = document.querySelector("#labels > #addedLabels");
+ if (customLabelsGroup) {
+ customLabelsGroup.querySelectorAll("text").forEach(textElement => {
+ const id = textElement.getAttribute("id");
+ if (!id) return;
+
+ const group = "custom";
+ const textPath = textElement.querySelector("textPath");
+ if (!textPath) return;
+
+ const text = textPath.textContent.trim();
+ const fontSizeAttr = textPath.getAttribute("font-size");
+ const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : 100;
+ const letterSpacingAttr = textPath.getAttribute("letter-spacing");
+ const letterSpacing = letterSpacingAttr ? parseFloat(letterSpacingAttr) : 0;
+ const startOffsetAttr = textPath.getAttribute("startOffset");
+ const startOffset = startOffsetAttr ? parseFloat(startOffsetAttr) : 50;
+ const transform = textPath.getAttribute("transform");
+
+ // Get path points from the referenced path
+ const href = textPath.getAttribute("xlink:href") || textPath.getAttribute("href");
+ if (!href) return;
+
+ const pathId = href.replace("#", "");
+ const pathElement = document.getElementById(pathId);
+ if (!pathElement) return;
+
+ const d = pathElement.getAttribute("d");
+ if (!d) return;
+
+ // Parse path data to extract points(M, L and C commands)
+ const pathPoints = [];
+ const commands = d.match(/[MLC][^MLC]*/g);
+ if (commands) {
+ commands.forEach(cmd => {
+ const type = cmd[0];
+ if (type === "M" || type === "L") {
+ const coords = cmd.slice(1).trim().split(/[\s,]+/).map(Number);
+ if (coords.length >= 2) {
+ pathPoints.push([coords[0], coords[1]]);
+ }
+ } else if (type === "C") {
+ const coords = cmd.slice(1).trim().split(/[\s,]+/).map(Number);
+ if (coords.length >= 6) {
+ pathPoints.push([coords[4], coords[5]]);
+ }
+ }
+ });
+ }
+
+ if (pathPoints.length > 0) {
+ pack.labels.push({
+ i: labelId++,
+ type: "custom",
+ group: group,
+ text: text,
+ pathPoints: pathPoints,
+ startOffset: startOffset,
+ fontSize: fontSize,
+ letterSpacing: letterSpacing,
+ transform: transform || undefined
+ });
+ }
+ });
+ }
+
+ // Clear old SVG labels and redraw from data
+ if (stateLabelsGroup) stateLabelsGroup.querySelectorAll("*").forEach(el => el.remove());
+ if (burgLabelsGroup) burgLabelsGroup.querySelectorAll("text").forEach(el => el.remove());
+
+ // Regenerate labels from data
+ if (layerIsOn("toggleLabels")) {
+ drawStateLabels();
+ drawBurgLabels();
+ }
+ }
+ }
}
diff --git a/public/modules/io/load.js b/public/modules/io/load.js
index 9b401733e..441e25fe0 100644
--- a/public/modules/io/load.js
+++ b/public/modules/io/load.js
@@ -407,6 +407,7 @@ async function parseLoadedData(data, mapVersion) {
// data[28] had deprecated cells.crossroad
pack.cells.routes = data[36] ? JSON.parse(data[36]) : {};
pack.ice = data[39] ? JSON.parse(data[39]) : [];
+ pack.labels = data[40] ? JSON.parse(data[40]) : [];
if (data[31]) {
const namesDL = data[31].split("/");
@@ -473,7 +474,7 @@ async function parseLoadedData(data, mapVersion) {
{
// dynamically import and run auto-update script
- const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.109.4");
+ const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.113.0");
resolveVersionConflicts(mapVersion);
}
diff --git a/public/modules/io/save.js b/public/modules/io/save.js
index 25cd7493c..4d6c40ea1 100644
--- a/public/modules/io/save.js
+++ b/public/modules/io/save.js
@@ -104,6 +104,7 @@ function prepareMapData() {
const routes = JSON.stringify(pack.routes);
const zones = JSON.stringify(pack.zones);
const ice = JSON.stringify(pack.ice);
+ const labels = JSON.stringify(pack.labels || []);
// store name array only if not the same as default
const defaultNB = Names.getNameBases();
@@ -158,7 +159,8 @@ function prepareMapData() {
cellRoutes,
routes,
zones,
- ice
+ ice,
+ labels
].join("\r\n");
return mapData;
}
diff --git a/public/modules/ui/burg-editor.js b/public/modules/ui/burg-editor.js
index 4232d2391..3cdfa3fd1 100644
--- a/public/modules/ui/burg-editor.js
+++ b/public/modules/ui/burg-editor.js
@@ -118,6 +118,9 @@ function editBurg(id) {
const id = +elSelected.attr("data-id");
pack.burgs[id].name = burgName.value;
elSelected.text(burgName.value);
+ // Sync to Labels data model
+ const labelData = Labels.getBurgLabel(id);
+ if (labelData) Labels.updateLabel(labelData.i, {text: burgName.value});
}
function generateNameRandom() {
@@ -382,6 +385,10 @@ function editBurg(id) {
burg.y = y;
if (burg.capital) pack.states[newState].center = burg.cell;
+ // Sync position to Labels data model
+ const labelData = Labels.getBurgLabel(id);
+ if (labelData) Labels.updateLabel(labelData.i, {x, y});
+
if (d3.event.shiftKey === false) toggleRelocateBurg();
}
diff --git a/public/modules/ui/labels-editor.js b/public/modules/ui/labels-editor.js
index 8c47ec99e..647eb7bff 100644
--- a/public/modules/ui/labels-editor.js
+++ b/public/modules/ui/labels-editor.js
@@ -1,4 +1,54 @@
"use strict";
+
+// Helper: extract control points from an SVG path element
+function extractPathPoints(pathElement) {
+ if (!pathElement) return [];
+ const l = pathElement.getTotalLength();
+ if (!l) return [];
+ const points = [];
+ const increment = l / Math.max(Math.ceil(l / 200), 2);
+ for (let i = 0; i <= l; i += increment) {
+ const point = pathElement.getPointAtLength(i);
+ points.push([point.x, point.y]);
+ }
+ return points;
+}
+
+// Helper: find label data from the Labels data model for an SVG text element
+function getLabelData(textElement) {
+ const id = textElement.id || "";
+ if (id.startsWith("stateLabel")) {
+ return Labels.getStateLabel(+id.slice(10));
+ }
+ // Custom labels: check for existing data-label-id attribute
+ const dataLabelId = textElement.getAttribute("data-label-id");
+ if (dataLabelId != null) {
+ const existing = Labels.get(+dataLabelId);
+ if (existing) return existing;
+ // Data was cleared (e.g., map regenerated) — recreate
+ textElement.removeAttribute("data-label-id");
+ }
+ // No data entry found — create one from SVG state (migration path)
+ return createCustomLabelDataFromSvg(textElement);
+}
+
+// Helper: create a CustomLabelData entry from existing SVG elements
+function createCustomLabelDataFromSvg(textElement) {
+ const textPathEl = textElement.querySelector("textPath");
+ if (!textPathEl) return null;
+ const group = textElement.parentNode.id;
+ const text = [...textPathEl.querySelectorAll("tspan")].map(t => t.textContent).join("|");
+ const pathEl = byId("textPath_" + textElement.id);
+ const pathPoints = extractPathPoints(pathEl);
+ const startOffset = parseFloat(textPathEl.getAttribute("startOffset")) || 50;
+ const fontSize = parseFloat(textPathEl.getAttribute("font-size")) || 100;
+ const letterSpacing = parseFloat(textPathEl.getAttribute("letter-spacing") || "0");
+ const transform = textElement.getAttribute("transform") || undefined;
+ const label = Labels.addCustomLabel({ group, text, pathPoints, startOffset, fontSize, letterSpacing, transform });
+ textElement.setAttribute("data-label-id", String(label.i));
+ return label;
+}
+
function editLabel() {
if (customization) return;
closeDialogs();
@@ -14,7 +64,7 @@ function editLabel() {
title: "Edit Label",
resizable: false,
width: fitContent(),
- position: {my: "center top+10", at: "bottom", of: text, collision: "fit"},
+ position: { my: "center top+10", at: "bottom", of: text, collision: "fit" },
close: closeLabelEditor
});
@@ -82,11 +132,21 @@ function editLabel() {
}
function updateValues(textPath) {
- byId("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|");
- byId("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset"));
- byId("labelRelativeSize").value = parseFloat(textPath.getAttribute("font-size"));
- let letterSpacingSize = textPath.getAttribute("letter-spacing") ? textPath.getAttribute("letter-spacing") : 0;
- byId("labelLetterSpacingSize").value = parseFloat(letterSpacingSize);
+ const labelData = getLabelData(elSelected.node());
+ if (labelData && labelData.type === "custom") {
+ // Custom labels: read all values from data model
+ byId("labelText").value = labelData.text || "";
+ byId("labelStartOffset").value = labelData.startOffset || 50;
+ byId("labelRelativeSize").value = labelData.fontSize || 100;
+ byId("labelLetterSpacingSize").value = labelData.letterSpacing || 0;
+ } else {
+ // State labels and fallback: read from SVG, use data model fontSize if available
+ byId("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|");
+ byId("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset")) || 50;
+ byId("labelRelativeSize").value = (labelData && labelData.fontSize) || parseFloat(textPath.getAttribute("font-size")) || 100;
+ let letterSpacingSize = textPath.getAttribute("letter-spacing") ? textPath.getAttribute("letter-spacing") : 0;
+ byId("labelLetterSpacingSize").value = parseFloat(letterSpacingSize);
+ }
}
function drawControlPointsAndLine() {
@@ -128,11 +188,14 @@ function editLabel() {
.select("#controlPoints")
.selectAll("circle")
.each(function () {
- points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
+ points.push([+this.getAttribute("cx"), +this.getAttribute("cy")]);
});
const d = round(lineGen(points));
path.setAttribute("d", d);
debug.select("#controlPoints > path").attr("d", d);
+ // Sync path control points back to data model
+ const labelData = getLabelData(elSelected.node());
+ if (labelData) Labels.updateLabel(labelData.i, { pathPoints: points });
}
function clickControlPoint() {
@@ -187,6 +250,8 @@ function editLabel() {
const transform = `translate(${dx + x},${dy + y})`;
elSelected.attr("transform", transform);
debug.select("#controlPoints").attr("transform", transform);
+ const labelData = getLabelData(elSelected.node());
+ if (labelData) Labels.updateLabel(labelData.i, { transform });
});
}
@@ -205,6 +270,10 @@ function editLabel() {
function changeGroup() {
byId(this.value).appendChild(elSelected.node());
+ const labelData = getLabelData(elSelected.node());
+ if (labelData && labelData.type === "custom") {
+ Labels.updateLabel(labelData.i, { group: this.value });
+ }
}
function toggleNewGroupInput() {
@@ -243,6 +312,9 @@ function editLabel() {
if (oldGroup !== "states" && oldGroup !== "addedLabels" && oldGroup.childElementCount === 1) {
byId("labelGroupSelect").selectedOptions[0].remove();
byId("labelGroupSelect").options.add(new Option(group, group, false, true));
+ // Update data model for labels in the old group
+ const oldGroupName = oldGroup.id;
+ Labels.getByGroup(oldGroupName).forEach(l => Labels.updateLabel(l.i, { group }));
oldGroup.id = group;
toggleNewGroupInput();
byId("labelGroupInput").value = "";
@@ -254,6 +326,11 @@ function editLabel() {
newGroup.id = group;
byId("labelGroupSelect").options.add(new Option(group, group, false, true));
byId(group).appendChild(elSelected.node());
+ // Update data model group for the moved label
+ const labelData = getLabelData(elSelected.node());
+ if (labelData && labelData.type === "custom") {
+ Labels.updateLabel(labelData.i, { group });
+ }
toggleNewGroupInput();
byId("labelGroupInput").value = "";
@@ -263,9 +340,8 @@ function editLabel() {
const group = elSelected.node().parentNode.id;
const basic = group === "states" || group === "addedLabels";
const count = elSelected.node().parentNode.childElementCount;
- alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${
- basic ? "all elements in the group" : "the entire label group"
- }?
Labels to be
+ alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${basic ? "all elements in the group" : "the entire label group"
+ }?
Labels to be
removed: ${count}`;
$("#alert").dialog({
resizable: false,
@@ -275,6 +351,12 @@ function editLabel() {
$(this).dialog("close");
$("#labelEditor").dialog("close");
hideGroupSection();
+ // Remove from data model
+ if (basic && group === "states") {
+ Labels.removeByType("state");
+ } else {
+ Labels.removeByGroup(group);
+ }
labels
.select("#" + group)
.selectAll("text")
@@ -311,15 +393,19 @@ function editLabel() {
el.innerHTML = lines.map((line, index) => `${line}`).join("");
} else el.innerHTML = `${lines}`;
+ // Update data model
+ const labelData = getLabelData(elSelected.node());
+ if (labelData) Labels.updateLabel(labelData.i, { text: input });
+
if (elSelected.attr("id").slice(0, 10) === "stateLabel")
tip("Use States Editor to change an actual state name, not just a label", false, "warning");
}
function generateRandomName() {
let name = "";
- if (elSelected.attr("id").slice(0, 10) === "stateLabel") {
- const id = +elSelected.attr("id").slice(10);
- const culture = pack.states[id].culture;
+ const labelData = getLabelData(elSelected.node());
+ if (labelData && labelData.type === "state") {
+ const culture = pack.states[labelData.stateId].culture;
name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture);
} else {
const box = elSelected.node().getBBox();
@@ -358,17 +444,23 @@ function editLabel() {
function changeStartOffset() {
elSelected.select("textPath").attr("startOffset", this.value + "%");
+ const labelData = getLabelData(elSelected.node());
+ if (labelData) Labels.updateLabel(labelData.i, { startOffset: +this.value });
tip("Label offset: " + this.value + "%");
}
function changeRelativeSize() {
elSelected.select("textPath").attr("font-size", this.value + "%");
+ const labelData = getLabelData(elSelected.node());
+ if (labelData) Labels.updateLabel(labelData.i, { fontSize: +this.value });
tip("Label relative size: " + this.value + "%");
changeText();
}
function changeLetterSpacingSize() {
elSelected.select("textPath").attr("letter-spacing", this.value + "px");
+ const labelData = getLabelData(elSelected.node());
+ if (labelData) Labels.updateLabel(labelData.i, { letterSpacing: +this.value });
tip("Label letter-spacing size: " + this.value + "px");
changeText();
}
@@ -379,6 +471,12 @@ function editLabel() {
const path = defs.select("#textPath_" + elSelected.attr("id"));
path.attr("d", `M${c[0] - bbox.width},${c[1]}h${bbox.width * 2}`);
drawControlPointsAndLine();
+ // Sync aligned path to data model
+ const labelData = getLabelData(elSelected.node());
+ if (labelData) {
+ const pathEl = byId("textPath_" + elSelected.attr("id"));
+ Labels.updateLabel(labelData.i, { pathPoints: extractPathPoints(pathEl) });
+ }
}
function editLabelLegend() {
@@ -395,6 +493,8 @@ function editLabel() {
buttons: {
Remove: function () {
$(this).dialog("close");
+ const labelData = getLabelData(elSelected.node());
+ if (labelData) Labels.removeLabel(labelData.i);
defs.select("#textPath_" + elSelected.attr("id")).remove();
elSelected.remove();
$("#labelEditor").dialog("close");
diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js
index eade993f4..b4734c4bf 100644
--- a/public/modules/ui/tools.js
+++ b/public/modules/ui/tools.js
@@ -77,6 +77,7 @@ toolsContent.addEventListener("click", function (event) {
function processFeatureRegeneration(event, button) {
if (button === "regenerateStateLabels") {
$("#labels").fadeIn();
+ Labels.generateStateLabels();
drawStateLabels();
} else if (button === "regenerateReliefIcons") {
drawReliefIcons();
diff --git a/public/versioning.js b/public/versioning.js
index 7861158df..ba2b4e45c 100644
--- a/public/versioning.js
+++ b/public/versioning.js
@@ -13,7 +13,7 @@
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
*/
-const VERSION = "1.112.4";
+const VERSION = "1.114.0";
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
{
diff --git a/src/index.html b/src/index.html
index 9414a182d..878f3947b 100644
--- a/src/index.html
+++ b/src/index.html
@@ -8506,7 +8506,7 @@
-
+
@@ -8524,12 +8524,12 @@
-
+
-
+
@@ -8551,8 +8551,8 @@
-
-
+
+