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 @@ - - + + diff --git a/src/modules/burgs-generator.ts b/src/modules/burgs-generator.ts index 6fccd517f..29794222b 100644 --- a/src/modules/burgs-generator.ts +++ b/src/modules/burgs-generator.ts @@ -1,5 +1,6 @@ import { quadtree } from "d3-quadtree"; import { byId, each, gauss, minmax, normalize, P, rn } from "../utils"; +import { drawBurgLabel } from "../renderers/draw-burg-labels"; declare global { var Burgs: BurgModule; @@ -728,7 +729,7 @@ class BurgModule { } removeBurgIcon(burg.i!); - removeBurgLabel(burg.i!); + Labels.removeBurgLabel(burg.i!); } } window.Burgs = new BurgModule(); diff --git a/src/modules/index.ts b/src/modules/index.ts index ad5c2de30..253b047bb 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -12,6 +12,7 @@ import "./routes-generator"; import "./states-generator"; import "./zones-generator"; import "./religions-generator"; +import "./labels"; import "./provinces-generator"; import "./emblem"; import "./ice"; diff --git a/src/modules/labels.ts b/src/modules/labels.ts new file mode 100644 index 000000000..94ef689f9 --- /dev/null +++ b/src/modules/labels.ts @@ -0,0 +1,212 @@ + +declare global { + var Labels: LabelsModule; +} + +export interface StateLabelData { + i: number; + type: "state"; + stateId: number; + text: string; + fontSize?: number; +} + +export interface BurgLabelData { + i: number; + type: "burg"; + burgId: number; + group: string; + text: string; + x: number; + y: number; +} + +export interface CustomLabelData { + i: number; + type: "custom"; + group: string; + text: string; + pathPoints: [number, number][]; + startOffset?: number; + fontSize?: number; + letterSpacing?: number; + transform?: string; +} + +export type LabelData = StateLabelData | BurgLabelData | CustomLabelData; + +class LabelsModule { + private getNextId(): number { + const labels = pack.labels; + if (labels.length === 0) return 0; + + const existingIds = labels.map((l) => l.i).sort((a, b) => a - b); + for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { + if (!existingIds.includes(id)) return id; + } + return existingIds[existingIds.length - 1] + 1; + } + + generate(): void { + this.clear(); + this.generateStateLabels(); + this.generateBurgLabels(); + } + + getAll(): LabelData[] { + return pack.labels; + } + + get(id: number): LabelData | undefined { + return pack.labels.find((l) => l.i === id); + } + + getByType(type: LabelData["type"]): LabelData[] { + return pack.labels.filter((l) => l.type === type); + } + + getByGroup(group: string): LabelData[] { + return pack.labels.filter( + (l) => (l.type === "burg" || l.type === "custom") && l.group === group, + ); + } + + getStateLabel(stateId: number): StateLabelData | undefined { + return pack.labels.find( + (l) => l.type === "state" && l.stateId === stateId, + ) as StateLabelData | undefined; + } + + getBurgLabel(burgId: number): BurgLabelData | undefined { + return pack.labels.find((l) => l.type === "burg" && l.burgId === burgId) as + | BurgLabelData + | undefined; + } + + addStateLabel(data: Omit): StateLabelData { + const label: StateLabelData = { + i: this.getNextId(), + type: "state", + ...data, + }; + pack.labels.push(label); + return label; + } + + addBurgLabel(data: Omit): BurgLabelData { + const label: BurgLabelData = { i: this.getNextId(), type: "burg", ...data }; + pack.labels.push(label); + return label; + } + + addCustomLabel(data: Omit): CustomLabelData { + const label: CustomLabelData = { + i: this.getNextId(), + type: "custom", + ...data, + }; + pack.labels.push(label); + return label; + } + + updateLabel(id: number, updates: Partial): void { + const label = pack.labels.find((l) => l.i === id); + if (!label) return; + Object.assign(label, updates, { i: label.i, type: label.type }); + } + + removeLabel(id: number): void { + const index = pack.labels.findIndex((l) => l.i === id); + if (index !== -1) pack.labels.splice(index, 1); + } + + removeByType(type: LabelData["type"]): void { + pack.labels = pack.labels.filter((l) => l.type !== type); + } + + removeByGroup(group: string): void { + pack.labels = pack.labels.filter( + (l) => !((l.type === "burg" || l.type === "custom") && l.group === group), + ); + } + + removeStateLabel(stateId: number): void { + const index = pack.labels.findIndex( + (l) => l.type === "state" && l.stateId === stateId, + ); + if (index !== -1) pack.labels.splice(index, 1); + } + + removeBurgLabel(burgId: number): void { + const index = pack.labels.findIndex( + (l) => l.type === "burg" && l.burgId === burgId, + ); + if (index !== -1) pack.labels.splice(index, 1); + } + + clear(): void { + pack.labels = []; + } + + /** + * Generate state labels data entries for each state. + * Only stores essential label data; raycast path calculation happens during rendering. + * @param list - Optional array of stateIds to regenerate only those + */ + generateStateLabels(list?: number[]): void { + if (TIME) console.time("generateStateLabels"); + + const { states } = pack; + + // Remove existing state labels that need regeneration + if (list) { + list.forEach((stateId) => this.removeStateLabel(stateId)); + } else { + this.removeByType("state"); + } + + // Generate new label entries + for (const state of states) { + if (!state.i || state.removed || state.lock) continue; + if (list && !list.includes(state.i)) continue; + + this.addStateLabel({ + stateId: state.i, + text: state.name!, + fontSize: 100, + }); + } + + if (TIME) console.timeEnd("generateStateLabels"); + } + + /** + * Generate burg labels data from burgs. + * Populates pack.labels with BurgLabelData for each burg. + */ + generateBurgLabels(): void { + if (TIME) console.time("generateBurgLabels"); + + // Remove existing burg labels + this.removeByType("burg"); + + // Generate new labels for all active burgs + for (const burg of pack.burgs) { + if (!burg.i || burg.removed) continue; + + const group = burg.group || "unmarked"; + + this.addBurgLabel({ + burgId: burg.i, + group, + text: burg.name!, + x: burg.x, + y: burg.y + }); + } + + if (TIME) console.timeEnd("generateBurgLabels"); + } +} + +window.Labels = new LabelsModule(); diff --git a/src/renderers/draw-burg-labels.ts b/src/renderers/draw-burg-labels.ts index 5dc6cc713..6fcd0e4bf 100644 --- a/src/renderers/draw-burg-labels.ts +++ b/src/renderers/draw-burg-labels.ts @@ -1,61 +1,76 @@ import type { Burg } from "../modules/burgs-generator"; - -declare global { - var drawBurgLabels: () => void; - var drawBurgLabel: (burg: Burg) => void; - var removeBurgLabel: (burgId: number) => void; -} +import type { BurgLabelData } from "../modules/labels"; interface BurgGroup { name: string; order: number; } -const burgLabelsRenderer = (): void => { +// remove this section once layer.js is refactored-------------------------------- +declare global { + var drawBurgLabels: () => void; +} + +window.drawBurgLabels = drawBurgLabels; +// section end ------------------------------------------------------------------- + +export function drawBurgLabels(): void { TIME && console.time("drawBurgLabels"); createLabelGroups(); - for (const { name } of options.burgs.groups as BurgGroup[]) { - const burgsInGroup = pack.burgs.filter( - (b) => b.group === name && !b.removed, - ); - if (!burgsInGroup.length) continue; + // Get all burg labels grouped by group name + const burgLabelsByGroup = new Map(); + for (const label of Labels.getByType("burg").map((l) => l as BurgLabelData)) { + if (!burgLabelsByGroup.has(label.group)) { + burgLabelsByGroup.set(label.group, []); + } + burgLabelsByGroup.get(label.group)!.push(label); + } - const labelGroup = burgLabels.select(`#${name}`); + // Render each group and update label offsets from SVG attributes + for (const [groupName, labels] of burgLabelsByGroup) { + const labelGroup = burgLabels.select(`#${groupName}`); if (labelGroup.empty()) continue; - const dx = labelGroup.attr("data-dx") || 0; - const dy = labelGroup.attr("data-dy") || 0; - - labelGroup - .selectAll("text") - .data(burgsInGroup) - .enter() - .append("text") - .attr("text-rendering", "optimizeSpeed") - .attr("id", (d) => `burgLabel${d.i}`) - .attr("data-id", (d) => d.i!) - .attr("x", (d) => d.x) - .attr("y", (d) => d.y) - .attr("dx", `${dx}em`) - .attr("dy", `${dy}em`) - .text((d) => d.name!); + const dxAttr = style.burgLabels?.[groupName]?.["data-dx"]; + const dyAttr = style.burgLabels?.[groupName]?.["data-dy"]; + const dx = dxAttr ? parseFloat(dxAttr) : 0; + const dy = dyAttr ? parseFloat(dyAttr) : 0; + + const labelsHTML: string[] = []; + for (const labelData of labels) { + labelsHTML.push( + `${labelData.text}`, + ); + } + + // Set all labels at once + const groupNode = labelGroup.node(); + if (groupNode) { + groupNode.innerHTML = labelsHTML.join(""); + } } TIME && console.timeEnd("drawBurgLabels"); }; -const drawBurgLabelRenderer = (burg: Burg): void => { +export function drawBurgLabel(burg: Burg): void { + // TODO: remove label group dependency - for now, if group is missing, redraw all labels to recreate the group const labelGroup = burgLabels.select(`#${burg.group}`); if (labelGroup.empty()) { drawBurgLabels(); return; // redraw all labels if group is missing } - const dx = labelGroup.attr("data-dx") || 0; - const dy = labelGroup.attr("data-dy") || 0; + const dxAttr = labelGroup.attr("data-dx"); + const dyAttr = labelGroup.attr("data-dy"); + const dx = dxAttr ? parseFloat(dxAttr) : 0; + const dy = dyAttr ? parseFloat(dyAttr) : 0; - removeBurgLabelRenderer(burg.i!); + const existingLabel = document.getElementById(`burgLabel${burg.i}`); + if (existingLabel) existingLabel.remove(); + + // Render to SVG labelGroup .append("text") .attr("text-rendering", "optimizeSpeed") @@ -68,7 +83,7 @@ const drawBurgLabelRenderer = (burg: Burg): void => { .text(burg.name!); }; -const removeBurgLabelRenderer = (burgId: number): void => { +export function removeBurgLabel(burgId: number): void { const existingLabel = document.getElementById(`burgLabel${burgId}`); if (existingLabel) existingLabel.remove(); }; @@ -100,8 +115,4 @@ function createLabelGroups(): void { }); group.attr("id", name); } -} - -window.drawBurgLabels = burgLabelsRenderer; -window.drawBurgLabel = drawBurgLabelRenderer; -window.removeBurgLabel = removeBurgLabelRenderer; +} \ No newline at end of file diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts index 24528d450..7015abaeb 100644 --- a/src/renderers/draw-state-labels.ts +++ b/src/renderers/draw-state-labels.ts @@ -1,34 +1,40 @@ import { curveNatural, line, max, select } from "d3"; -import { - drawPath, - drawPoint, - findClosestCell, - minmax, - rn, - round, - splitInTwo, -} from "../utils"; +import type { StateLabelData } from "../modules/labels"; +import { findClosestCell, minmax, rn, round, splitInTwo } from "../utils"; +import { ANGLES, findBestRayPair, raycast } from "./label-raycast"; declare global { var drawStateLabels: (list?: number[]) => void; } -interface Ray { - angle: number; - length: number; - x: number; - y: number; +/** + * Helper function to calculate offset width for raycast based on state size + */ +function getOffsetWidth(cellsNumber: number): number { + if (cellsNumber < 40) return 0; + if (cellsNumber < 200) return 5; + return 10; } -interface AngleData { - angle: number; - dx: number; - dy: number; +function checkExampleLetterLength(): number { + const textGroup = select("g#labels > g#states"); + const testLabel = textGroup + .append("text") + .attr("x", 0) + .attr("y", 0) + .text("Example"); + const letterLength = + (testLabel.node() as SVGTextElement).getComputedTextLength() / 7; // approximate length of 1 letter + testLabel.remove(); + + return letterLength; } -type PathPoints = [number, number][]; - -// list - an optional array of stateIds to regenerate +/** + * Render state labels from pack.labels data to SVG. + * Adjusts and fits labels based on layout constraints. + * list - optional array of stateIds to re-render + */ const stateLabelsRenderer = (list?: number[]): void => { TIME && console.time("drawStateLabels"); @@ -36,37 +42,46 @@ const stateLabelsRenderer = (list?: number[]): void => { const layerDisplay = labels.style("display"); labels.style("display", null); - const { cells, states, features } = pack; - const stateIds = cells.state; - - // increase step to 15 or 30 to make it faster and more horyzontal - // decrease step to 5 to improve accuracy - const ANGLE_STEP = 9; - const angles = precalculateAngles(ANGLE_STEP); + const { states } = pack; - const LENGTH_START = 5; - const LENGTH_STEP = 5; - const LENGTH_MAX = 300; + // Get labels to render + const labelsToRender = list + ? Labels.getAll() + .filter( + (l) => + l.type === "state" && list.includes((l as StateLabelData).stateId), + ) + .map((l) => l as StateLabelData) + : Labels.getByType("state").map((l) => l as StateLabelData); - const labelPaths = getLabelPaths(); const letterLength = checkExampleLetterLength(); - drawLabelPath(letterLength); + drawLabelPath(letterLength, labelsToRender); // restore labels visibility labels.style("display", layerDisplay); - function getLabelPaths(): [number, PathPoints][] { - const labelPaths: [number, PathPoints][] = []; + function drawLabelPath( + letterLength: number, + labelDataList: StateLabelData[], + ): void { + const mode = options.stateLabelsMode || "auto"; + const lineGen = line<[number, number]>().curve(curveNatural); + + const textGroup = select("g#labels > g#states"); + const pathGroup = select( + "defs > g#deftemp > g#textPaths", + ); - for (const state of states) { - if (!state.i || state.removed || state.lock) continue; - if (list && !list.includes(state.i)) continue; + for (const labelData of labelDataList) { + const state = states[labelData.stateId]; + if (!state.i || state.removed) continue; + // Calculate pathPoints using raycast algorithm (recalculated on each draw) const offset = getOffsetWidth(state.cells!); const maxLakeSize = state.cells! / 20; const [x0, y0] = state.pole!; - const rays: Ray[] = angles.map(({ angle, dx, dy }) => { + const rays = ANGLES.map(({ angle, dx, dy }) => { const { length, x, y } = raycast({ stateId: state.i, x0, @@ -80,61 +95,20 @@ const stateLabelsRenderer = (list?: number[]): void => { }); const [ray1, ray2] = findBestRayPair(rays); - const pathPoints: PathPoints = [ + const pathPoints: [number, number][] = [ [ray1.x, ray1.y], state.pole!, [ray2.x, ray2.y], ]; if (ray1.x > ray2.x) pathPoints.reverse(); - if (DEBUG.stateLabels) { - drawPoint(state.pole!, { color: "black", radius: 1 }); - drawPath(pathPoints, { color: "black", width: 0.2 }); - } - - labelPaths.push([state.i, pathPoints]); - } - - return labelPaths; - } - - function checkExampleLetterLength(): number { - const textGroup = select("g#labels > g#states"); - const testLabel = textGroup - .append("text") - .attr("x", 0) - .attr("y", 0) - .text("Example"); - const letterLength = - (testLabel.node() as SVGTextElement).getComputedTextLength() / 7; // approximate length of 1 letter - testLabel.remove(); - - return letterLength; - } - - function drawLabelPath(letterLength: number): void { - const mode = options.stateLabelsMode || "auto"; - const lineGen = line<[number, number]>().curve(curveNatural); - - const textGroup = select("g#labels > g#states"); - const pathGroup = select( - "defs > g#deftemp > g#textPaths", - ); - - for (const [stateId, pathPoints] of labelPaths) { - const state = states[stateId]; - if (!state.i || state.removed) - throw new Error("State must not be neutral or removed"); - if (pathPoints.length < 2) - throw new Error("Label path must have at least 2 points"); - - textGroup.select(`#stateLabel${stateId}`).remove(); - pathGroup.select(`#textPath_stateLabel${stateId}`).remove(); + textGroup.select(`#stateLabel${labelData.stateId}`).remove(); + pathGroup.select(`#textPath_stateLabel${labelData.stateId}`).remove(); const textPath = pathGroup .append("path") .attr("d", round(lineGen(pathPoints) || "")) - .attr("id", `textPath_stateLabel${stateId}`); + .attr("id", `textPath_stateLabel${labelData.stateId}`); const pathLength = (textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters @@ -145,6 +119,9 @@ const stateLabelsRenderer = (list?: number[]): void => { pathLength, ); + // Update label data with font size + Labels.updateLabel(labelData.i, { fontSize: ratio }); + // prolongate path if it's too short const longestLineLength = max(lines.map((line) => line.length)) || 0; if (pathLength && pathLength < longestLineLength) { @@ -165,7 +142,7 @@ const stateLabelsRenderer = (list?: number[]): void => { const textElement = textGroup .append("text") .attr("text-rendering", "optimizeSpeed") - .attr("id", `stateLabel${stateId}`) + .attr("id", `stateLabel${labelData.stateId}`) .append("textPath") .attr("startOffset", "50%") .attr("font-size", `${ratio}%`) @@ -179,8 +156,12 @@ const stateLabelsRenderer = (list?: number[]): void => { textElement.insertAdjacentHTML("afterbegin", spans.join("")); const { width, height } = textElement.getBBox(); - textElement.setAttribute("href", `#textPath_stateLabel${stateId}`); + textElement.setAttribute( + "href", + `#textPath_stateLabel${labelData.stateId}`, + ); + const stateIds = pack.cells.state; if (mode === "full" || lines.length === 1) continue; // check if label fits state boundaries. If no, replace it with short name @@ -193,7 +174,7 @@ const stateLabelsRenderer = (list?: number[]): void => { width / 2, height / 2, stateIds, - stateId, + labelData.stateId, ); if (isInsideState) continue; @@ -203,6 +184,7 @@ const stateLabelsRenderer = (list?: number[]): void => { ? state.fullName! : state.name!; textElement.innerHTML = `${text}`; + Labels.updateLabel(labelData.i, { text }); const correctedRatio = minmax( rn((pathLength / text.length) * 50), @@ -210,162 +192,10 @@ const stateLabelsRenderer = (list?: number[]): void => { 130, ); textElement.setAttribute("font-size", `${correctedRatio}%`); + Labels.updateLabel(labelData.i, { fontSize: correctedRatio }); } } - function getOffsetWidth(cellsNumber: number): number { - if (cellsNumber < 40) return 0; - if (cellsNumber < 200) return 5; - return 10; - } - - function precalculateAngles(step: number): AngleData[] { - const angles: AngleData[] = []; - const RAD = Math.PI / 180; - - for (let angle = 0; angle < 360; angle += step) { - const dx = Math.cos(angle * RAD); - const dy = Math.sin(angle * RAD); - angles.push({ angle, dx, dy }); - } - - return angles; - } - - function raycast({ - stateId, - x0, - y0, - dx, - dy, - maxLakeSize, - offset, - }: { - stateId: number; - x0: number; - y0: number; - dx: number; - dy: number; - maxLakeSize: number; - offset: number; - }): { length: number; x: number; y: number } { - let ray = { length: 0, x: x0, y: y0 }; - - for ( - let length = LENGTH_START; - length < LENGTH_MAX; - length += LENGTH_STEP - ) { - const [x, y] = [x0 + length * dx, y0 + length * dy]; - // offset points are perpendicular to the ray - const offset1: [number, number] = [x + -dy * offset, y + dx * offset]; - const offset2: [number, number] = [x + dy * offset, y + -dx * offset]; - - if (DEBUG.stateLabels) { - drawPoint([x, y], { - color: isInsideState(x, y) ? "blue" : "red", - radius: 0.8, - }); - drawPoint(offset1, { - color: isInsideState(...offset1) ? "blue" : "red", - radius: 0.4, - }); - drawPoint(offset2, { - color: isInsideState(...offset2) ? "blue" : "red", - radius: 0.4, - }); - } - - const inState = - isInsideState(x, y) && - isInsideState(...offset1) && - isInsideState(...offset2); - if (!inState) break; - ray = { length, x, y }; - } - - return ray; - - function isInsideState(x: number, y: number): boolean { - if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false; - const cellId = findClosestCell(x, y, undefined, pack) as number; - - const feature = features[cells.f[cellId]]; - if (feature.type === "lake") - return isInnerLake(feature) || isSmallLake(feature); - - return stateIds[cellId] === stateId; - } - - function isInnerLake(feature: { shoreline: number[] }): boolean { - return feature.shoreline.every((cellId) => stateIds[cellId] === stateId); - } - - function isSmallLake(feature: { cells: number }): boolean { - return feature.cells <= maxLakeSize; - } - } - - function findBestRayPair(rays: Ray[]): [Ray, Ray] { - let bestPair: [Ray, Ray] | null = null; - let bestScore = -Infinity; - - for (let i = 0; i < rays.length; i++) { - const score1 = rays[i].length * scoreRayAngle(rays[i].angle); - - for (let j = i + 1; j < rays.length; j++) { - const score2 = rays[j].length * scoreRayAngle(rays[j].angle); - const pairScore = - (score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle); - - if (pairScore > bestScore) { - bestScore = pairScore; - bestPair = [rays[i], rays[j]]; - } - } - } - - return bestPair!; - } - - function scoreRayAngle(angle: number): number { - const normalizedAngle = Math.abs(angle % 180); // [0, 180] - const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1] - - if (horizontality === 1) return 1; // Best: horizontal - if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted - if (horizontality >= 0.5) return 0.6; // Good: moderate slant - if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted - if (horizontality >= 0.15) return 0.2; // Poor: almost vertical - return 0.1; // Very poor: almost vertical - } - - function scoreCurvature(angle1: number, angle2: number): number { - const delta = getAngleDelta(angle1, angle2); - const similarity = evaluateArc(angle1, angle2); - - if (delta === 180) return 1; // straight line: best - if (delta < 90) return 0; // acute: not allowed - if (delta < 120) return 0.6 * similarity; - if (delta < 140) return 0.7 * similarity; - if (delta < 160) return 0.8 * similarity; - - return similarity; - } - - function getAngleDelta(angle1: number, angle2: number): number { - let delta = Math.abs(angle1 - angle2) % 360; - if (delta > 180) delta = 360 - delta; // [0, 180] - return delta; - } - - // compute arc similarity towards x-axis - function evaluateArc(angle1: number, angle2: number): number { - const proximity1 = Math.abs((angle1 % 180) - 90); - const proximity2 = Math.abs((angle2 % 180) - 90); - return 1 - Math.abs(proximity1 - proximity2) / 90; - } - function getLinesAndRatio( mode: string, name: string, diff --git a/src/renderers/label-raycast.ts b/src/renderers/label-raycast.ts new file mode 100644 index 000000000..41b394142 --- /dev/null +++ b/src/renderers/label-raycast.ts @@ -0,0 +1,181 @@ +import { findClosestCell } from "../utils/graphUtils"; + +export interface Ray { + angle: number; + length: number; + x: number; + y: number; +} + +interface AngleData { + angle: number; + dx: number; + dy: number; +} + +interface RaycastParams { + stateId: number; + x0: number; + y0: number; + dx: number; + dy: number; + maxLakeSize: number; + offset: number; +} + +// increase step to 15 or 30 to make it faster and more horizontal +// decrease step to 5 to improve accuracy +const ANGLE_STEP = 9; +export const ANGLES = precalculateAngles(ANGLE_STEP); + +const LENGTH_START = 5; +const LENGTH_STEP = 5; +const LENGTH_MAX = 300; + +/** + * Cast a ray from a point in a given direction until it exits a state. + * Checks both the ray point and offset points perpendicular to it. + */ +export function raycast({ + stateId, + x0, + y0, + dx, + dy, + maxLakeSize, + offset, +}: RaycastParams): { length: number; x: number; y: number } { + const { cells, features } = pack; + const stateIds = cells.state; + let ray = { length: 0, x: x0, y: y0 }; + + for (let length = LENGTH_START; length < LENGTH_MAX; length += LENGTH_STEP) { + const [x, y] = [x0 + length * dx, y0 + length * dy]; + // offset points are perpendicular to the ray + const offset1: [number, number] = [x + -dy * offset, y + dx * offset]; + const offset2: [number, number] = [x + dy * offset, y + -dx * offset]; + + const inState = + isInsideState(x, y, stateId) && + isInsideState(...offset1, stateId) && + isInsideState(...offset2, stateId); + if (!inState) break; + ray = { length, x, y }; + } + + return ray; + + function isInsideState(x: number, y: number, stateId: number): boolean { + if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false; + const cellId = findClosestCell(x, y, undefined, pack) as number; + + const feature = features[cells.f[cellId]]; + if (feature.type === "lake") + return isInnerLake(feature) || isSmallLake(feature); + + return stateIds[cellId] === stateId; + } + + function isInnerLake(feature: { shoreline: number[] }): boolean { + return feature.shoreline.every((cellId) => stateIds[cellId] === stateId); + } + + function isSmallLake(feature: { cells: number }): boolean { + return feature.cells <= maxLakeSize; + } +} + +/** + * Score a ray angle based on how horizontal it is. + * Horizontal rays (0° or 180°) are preferred for label placement. + */ +function scoreRayAngle(angle: number): number { + const normalizedAngle = Math.abs(angle % 180); // [0, 180] + const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1] + + if (horizontality === 1) return 1; // Best: horizontal + if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted + if (horizontality >= 0.5) return 0.6; // Good: moderate slant + if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted + if (horizontality >= 0.15) return 0.2; // Poor: almost vertical + return 0.1; // Very poor: almost vertical +} + +/** + * Calculate the angle delta between two angles (0-180 degrees). + */ +function getAngleDelta(angle1: number, angle2: number): number { + let delta = Math.abs(angle1 - angle2) % 360; + if (delta > 180) delta = 360 - delta; // [0, 180] + return delta; +} + +/** + * Evaluate how similar the arc between two angles is. + * Computes proximity of both angles towards the x-axis. + */ +function evaluateArc(angle1: number, angle2: number): number { + const proximity1 = Math.abs((angle1 % 180) - 90); + const proximity2 = Math.abs((angle2 % 180) - 90); + return 1 - Math.abs(proximity1 - proximity2) / 90; +} + +/** + * Score a ray pair based on the delta angle between them and their arc similarity. + * Penalizes acute angles (<90°), favors straight lines (180°). + */ +function scoreCurvature(angle1: number, angle2: number): number { + const delta = getAngleDelta(angle1, angle2); + const similarity = evaluateArc(angle1, angle2); + + if (delta === 180) return 1; // straight line: best + if (delta < 90) return 0; // acute: not allowed + if (delta < 120) return 0.6 * similarity; + if (delta < 140) return 0.7 * similarity; + if (delta < 160) return 0.8 * similarity; + + return similarity; +} + +/** + * Precompute angles and their vector components for raycast directions. + * Used to sample rays around a point at regular angular intervals. + */ +function precalculateAngles(step: number): AngleData[] { + const angles: AngleData[] = []; + const RAD = Math.PI / 180; + + for (let angle = 0; angle < 360; angle += step) { + const dx = Math.cos(angle * RAD); + const dy = Math.sin(angle * RAD); + angles.push({ angle, dx, dy }); + } + + return angles; +} + +/** + * Find the best pair of rays for label placement along a curved path. + * Prefers horizontal rays and well-separated angles. + */ +export function findBestRayPair(rays: Ray[]): [Ray, Ray] { + let bestPair: [Ray, Ray] | null = null; + let bestScore = -Infinity; + + for (let i = 0; i < rays.length; i++) { + const score1 = rays[i].length * scoreRayAngle(rays[i].angle); + + for (let j = i + 1; j < rays.length; j++) { + const score2 = rays[j].length * scoreRayAngle(rays[j].angle); + const pairScore = + (score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle); + + if (pairScore > bestScore) { + bestScore = pairScore; + bestPair = [rays[i], rays[j]]; + } + } + } + + return bestPair!; +} diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index b8749f0a5..8c1fed100 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -1,6 +1,7 @@ import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; +import type { LabelData } from "../modules/labels"; import type { Province } from "../modules/provinces-generator"; import type { River } from "../modules/river-generator"; import type { Route } from "../modules/routes-generator"; @@ -62,5 +63,6 @@ export interface PackedGraph { zones: Zone[]; markers: any[]; ice: any[]; + labels: LabelData[]; provinces: Province[]; }