diff --git a/package-lock.json b/package-lock.json index 3a028bd9c4..32c26f5aa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fantasy-map-generator", - "version": "1.135.2", + "version": "1.139.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fantasy-map-generator", - "version": "1.135.2", + "version": "1.139.0", "license": "MIT", "dependencies": { "alea": "^1.0.1", diff --git a/package.json b/package.json index 0d1c417846..37eb855cc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fantasy-map-generator", - "version": "1.135.2", + "version": "1.139.0", "description": "Azgaar's _Fantasy Map Generator_ is a free web application that helps fantasy writers, game masters, and cartographers create and edit fantasy maps.", "homepage": "https://github.com/Azgaar/Fantasy-Map-Generator#readme", "bugs": { diff --git a/public/main.js b/public/main.js index a9259aa8c5..db9b32554e 100644 --- a/public/main.js +++ b/public/main.js @@ -702,6 +702,8 @@ async function generate(options) { Provinces.generate(); Provinces.getPoles(); + Labels.generate(); + Rivers.specify(); Lakes.defineNames(); diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 16352875cb..da2d219ebd 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -909,12 +909,12 @@ function toggleLabels(event) { if (!layerIsOn("toggleLabels")) { turnButtonOn("toggleLabels"); $("#labels").fadeIn(); - // don't redraw labels as they are not stored in data yet if (labels.selectAll("text").size() === 0) drawLabels(); if (event && isCtrlClick(event)) editStyle("labels"); } else { if (event && isCtrlClick(event)) return editStyle("labels"); turnButtonOff("toggleLabels"); + labels.selectAll("text").remove(); $("#labels").fadeOut(); } } @@ -922,6 +922,7 @@ function toggleLabels(event) { function drawLabels() { drawStateLabels(); drawBurgLabels(); + drawCustomLabels(); invokeActiveZooming(); } diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index 8b4022f744..549b7c0721 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -83,6 +83,7 @@ toolsContent.addEventListener("click", function (event) { function processFeatureRegeneration(event, button) { if (button === "regenerateStateLabels") { $("#labels").fadeIn(); + Labels.generateStateLabels(); drawStateLabels(); } else if (button === "regenerateReliefIcons") { drawReliefIcons(); @@ -185,6 +186,7 @@ function regenerateStates() { layerIsOn("toggleBorders") ? drawBorders() : toggleBorders(); if (layerIsOn("toggleProvinces")) drawProvinces(); + Labels.generateStateLabels(); drawStateLabels(); Military.generate(); if (layerIsOn("toggleEmblems")) drawEmblems(); @@ -239,8 +241,12 @@ function recreateStates() { if (!state.i || state.removed || state.lock) continue; // remove state labels - document.getElementById(`stateLabel${state.i}`)?.remove(); - document.getElementById(`textPath_stateLabel${state.i}`)?.remove(); + const label = Labels.getStateLabel(state.i); + if (label) { + document.getElementById(`pathLabel${label.i}`)?.remove(); + document.getElementById(`textPath_pathLabel${label.i}`)?.remove(); + Labels.remove(label.i); + } // remove state emblems document.getElementById(`stateCOA${state.i}`)?.remove(); @@ -271,23 +277,18 @@ function recreateStates() { const newStates = [{ i: 0, name: pack.states[0].name }]; + // collect locked state labels before renumbering to avoid stateId collisions + const lockedStateLabels = new Map(lockedStates.map(s => [s.i, Labels.getStateLabel(s.i)])); + // restore locked states lockedStates.forEach(state => { const newId = newStates.length; const { x, y } = pack.burgs[state.capital]; capitalsTree.add([x, y]); - // update label id reference - document.getElementById(`textPath_stateLabel${state.i}`)?.setAttribute("id", `textPath_stateLabel${newId}`); - const $label = document.getElementById(`stateLabel${state.i}`); - if ($label) { - $label.setAttribute("id", `stateLabel${newId}`); - const $textPath = $label.querySelector("textPath"); - if ($textPath) { - $textPath.removeAttribute("href"); - $textPath.setAttribute("href", `#textPath_stateLabel${newId}`); - } - } + // point the label to the renumbered state (element ids are label-based and stay valid) + const lockedLabel = lockedStateLabels.get(state.i); + if (lockedLabel) Labels.update(lockedLabel, { stateId: newId }); // update emblem id reference document.getElementById(`stateCOA${state.i}`)?.setAttribute("id", `stateCOA${newId}`); @@ -681,49 +682,26 @@ async function addLabelOnClick() { const cell = findCell(point[0], point[1]); const culture = pack.cells.culture[cell]; const name = Names.getCulture(culture); - const id = getNextId("label"); // use most recently selected label group const lastSelected = await window.Controllers.LabelsEditor.getLastSelectedGroup(); - const groupId = ["", "states", "burgLabels"].includes(lastSelected) ? "#addedLabels" : "#" + lastSelected; - - let group = labels.select(groupId); - if (!group.size()) - group = labels - .append("g") - .attr("id", "addedLabels") - .attr("fill", "#3e3e4b") - .attr("opacity", 1) - .attr("stroke", "#3a3a3a") - .attr("stroke-width", 0) - .attr("font-family", "Almendra SC") - .attr("font-size", 18) - .attr("data-size", 18) - .attr("filter", null); + const groupId = ["", "states", "burgLabels"].includes(lastSelected) ? "addedLabels" : lastSelected; + + const group = d3.select(ensureLabelGroup(groupId)); const example = group.append("text").attr("x", 0).attr("y", 0).text(name); const width = example.node().getBBox().width; example.remove(); - - group.classed("hidden", false); - group - .append("text") - .attr("text-rendering", "optimizeSpeed") - .attr("id", id) - .append("textPath") - .attr("text-rendering", "optimizeSpeed") - .attr("xlink:href", "#textPath_" + id) - .attr("startOffset", "50%") - .attr("font-size", "100%") - .append("tspan") - .attr("x", 0) - .text(name); - - defs - .select("#textPaths") - .append("path") - .attr("id", "textPath_" + id) - .attr("d", `M${point[0] - width},${point[1]} h${width * 2}`); + const newLabel = Labels.addCustomLabel({ + group: groupId, + text: name, + pathPoints: [ + [rn(point[0] - width), point[1]], + [rn(point[0] + width), point[1]] + ] + }) + + drawCustomLabel(newLabel); if (shiftKey === false) unpressClickToAddButton(); } diff --git a/src/controllers/burg-editor.ts b/src/controllers/burg-editor.ts index 2b72a4bf4f..791d50fb5e 100644 --- a/src/controllers/burg-editor.ts +++ b/src/controllers/burg-editor.ts @@ -1,6 +1,7 @@ import { drag, type Selection, select } from "d3"; import { Controllers } from "@/controllers"; import type { Burg } from "../generators/burgs-generator"; +import { Labels } from "../generators/labels"; import { convertTemperature, destroyDialogIfExists, @@ -331,8 +332,10 @@ function dragBurgLabel(this: SVGTextElement, event: any): void { const dy = +tr[1] - event.y; event.on("drag", function (this: SVGTextElement, dragEvent: any) { - const { x, y } = dragEvent; - this.setAttribute("transform", `translate(${dx + x},${dy + y})`); + const [effectiveDx, effectiveDy] = [dx + dragEvent.x, dy + dragEvent.y]; + this.setAttribute("transform", `translate(${effectiveDx},${effectiveDy})`); + const label = Labels.getBurgLabel(+this.dataset.id!); + if (label) Labels.update(label, { dx: effectiveDx, dy: effectiveDy }); tip('Use dragging for fine-tuning only, to actually move burg use "Relocate" button', false, "warn"); }); } @@ -342,6 +345,9 @@ function changeName(): void { const value = ensureEl("burgName").value; pack.burgs[id].name = value; selected!.text(value); + + const label = Labels.getBurgLabel(id); + if (label) Labels.update(label, { text: value }); } function generateNameRandom(): void { @@ -621,8 +627,9 @@ function relocateBurgOnClick(this: SVGGElement, event: any): void { const x = rn(point[0], 2); const y = rn(point[1], 2); + const label = Labels.getBurgLabel(id); select("#burgIcons").select(`#burg${id}`).attr("x", x).attr("y", y); - select("#burgLabels").select(`#burgLabel${id}`).attr("transform", null).attr("x", x).attr("y", y); + if (label) select("#burgLabels").select(`#burgLabel${label.i}`).attr("transform", null).attr("x", x).attr("y", y); const anchor = select("#anchors").select(`use[data-id='${id}']`); if (anchor.size()) { @@ -641,6 +648,8 @@ function relocateBurgOnClick(this: SVGGElement, event: any): void { burg.y = y; if (burg.capital) pack.states[newState].center = burg.cell; + if (label) Labels.update(label, { x, y, dx: 0, dy: 0 }); + if (event.shiftKey === false) toggleRelocateBurg(); } diff --git a/src/controllers/heightmap-editor.ts b/src/controllers/heightmap-editor.ts index 799cb9723a..0f853142b2 100644 --- a/src/controllers/heightmap-editor.ts +++ b/src/controllers/heightmap-editor.ts @@ -1,6 +1,7 @@ import { drag, easeSinInOut, hsl, interpolateRound, lab, leastIndex, max, mean, range, select } from "d3"; import { Controllers } from "@/controllers"; import { heightmapTemplates } from "@/data/heightmap-templates"; +import { Labels } from "@/generators/labels"; import { destroyDialogIfExists, ensureEl, @@ -532,6 +533,7 @@ function regenerateErasedData(): void { Provinces.generate(); Provinces.getPoles(); + Labels.generate(); Rivers.specify(); Lakes.defineNames(); diff --git a/src/controllers/labels-editor.ts b/src/controllers/labels-editor.ts index 7c11318cbc..e339560862 100644 --- a/src/controllers/labels-editor.ts +++ b/src/controllers/labels-editor.ts @@ -1,12 +1,23 @@ import { curveNatural, drag, line, select } from "d3"; import { Controllers } from "@/controllers"; +import { type CustomLabel, isPathLabel, Labels, type StateLabel } from "../generators/labels"; +import { removeLabel as removeLabelElements } from "../renderers/draw-labels"; import { destroyDialogIfExists, ensureEl, findEl, getPointer, parseTransform, round } from "../utils"; +import { extractPathPoints } from "../utils/pathUtils"; const lineGen = line<[number, number]>().curve(curveNatural); // group selected in the editor most recently; used as the default group for newly added labels let lastSelectedGroup = ""; +// find label data in the Labels data model for the selected SVG text element +function getLabelData(): StateLabel | CustomLabel | undefined { + const match = (elSelected.attr("id") || "").match(/^pathLabel(\d+)$/); + if (!match) return undefined; + const label = Labels.get(+match[1]); + return label && isPathLabel(label) ? label : undefined; +} + function open(tspan: SVGTSpanElement): void { if (customization) return; closeDialogs(".stable"); @@ -211,28 +222,24 @@ function selectLabelGroup(text: SVGTextElement): void { } function updateValues(textPath: SVGTextPathElement): void { - ensureEl("labelText").value = [...textPath.querySelectorAll("tspan")] - .map(tspan => tspan.textContent) - .join("|"); - const startOffset = Number.parseFloat(textPath.getAttribute("startOffset")!); - ensureEl("labelStartOffset").value = String(startOffset); - ensureEl("labelStartOffsetValue").value = String(startOffset); - ensureEl("labelRelativeSize").value = String( - Number.parseFloat(textPath.getAttribute("font-size")!) - ); - const letterSpacingSize = textPath.getAttribute("letter-spacing") || "0"; - ensureEl("labelLetterSpacingSize").value = String(Number.parseFloat(letterSpacingSize)); + const labelData = getLabelData(); + const domText = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|"); + const domStartOffset = Number.parseFloat(textPath.getAttribute("startOffset") || "50"); + const domFontSize = Number.parseFloat(textPath.getAttribute("font-size") || "100"); + const domLetterSpacing = Number.parseFloat(textPath.getAttribute("letter-spacing") || "0"); + + ensureEl("labelText").value = labelData?.text ?? domText; + ensureEl("labelStartOffset").value = String(labelData?.startOffset ?? domStartOffset); + ensureEl("labelStartOffsetValue").value = String(labelData?.startOffset ?? domStartOffset); + ensureEl("labelRelativeSize").value = String(labelData?.fontSize ?? domFontSize); + ensureEl("labelLetterSpacingSize").value = String(labelData?.letterSpacing ?? domLetterSpacing); } function drawControlPointsAndLine(): void { - select("#debug").select("#controlPoints").remove(); + select("#controlPoints").remove(); select("#debug").append("g").attr("id", "controlPoints").attr("transform", elSelected.attr("transform")); const path = ensureEl(`textPath_${elSelected.attr("id")}`) as unknown as SVGPathElement; - select("#debug") - .select("#controlPoints") - .append("path") - .attr("d", path.getAttribute("d")) - .on("click", addInterimControlPoint); + select("#controlPoints").append("path").attr("d", path.getAttribute("d")).on("click", addInterimControlPoint); const l = path.getTotalLength(); if (!l) return; const increment = l / Math.max(Math.ceil(l / 200), 2); @@ -242,8 +249,7 @@ function drawControlPointsAndLine(): void { } function addControlPoint(point: DOMPoint): void { - select("#debug") - .select("#controlPoints") + select("#controlPoints") .append("circle") .attr("cx", point.x) .attr("cy", point.y) @@ -262,15 +268,17 @@ function dragControlPoint(this: SVGCircleElement, event: any): void { function redrawLabelPath(): void { const path = ensureEl(`textPath_${elSelected.attr("id")}`) as unknown as SVGPathElement; const points: [number, number][] = []; - select("#debug") - .select("#controlPoints") + select("#controlPoints") .selectAll("circle") .each(function () { points.push([+this.getAttribute("cx")!, +this.getAttribute("cy")!]); }); const d = round(lineGen(points) || ""); path.setAttribute("d", d); - select("#debug").select("#controlPoints > path").attr("d", d); + select("#controlPoints > path").attr("d", d); + + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { pathPoints: points }); } function clickControlPoint(this: SVGCircleElement): void { @@ -282,8 +290,7 @@ function addInterimControlPoint(this: SVGPathElement, event: any): void { const point = getPointer(event, this); const dists: number[] = []; - select("#debug") - .select("#controlPoints") + select("#controlPoints") .selectAll("circle") .each(function () { const x = +this.getAttribute("cx")!; @@ -300,8 +307,7 @@ function addInterimControlPoint(this: SVGPathElement, event: any): void { } const before = `:nth-child(${index + 2})`; - select("#debug") - .select("#controlPoints") + select("#controlPoints") .insert("circle", before) .attr("cx", point[0]) .attr("cy", point[1]) @@ -319,9 +325,13 @@ function dragLabel(event: any): void { const dy = +tr[1] - event.y; event.on("drag", (dragEvent: any) => { - const transform = `translate(${dx + dragEvent.x},${dy + dragEvent.y})`; + const [effectiveDx, effectiveDy] = [dx + dragEvent.x, dy + dragEvent.y]; + const transform = `translate(${effectiveDx},${effectiveDy})`; elSelected.attr("transform", transform); - select("#debug").select("#controlPoints").attr("transform", transform); + select("#controlPoints").attr("transform", transform); + + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { dx: effectiveDx, dy: effectiveDy }); }); } @@ -341,6 +351,8 @@ function hideGroupSection(): void { function changeGroup(this: HTMLSelectElement): void { lastSelectedGroup = this.value; ensureEl(this.value).appendChild(elSelected.node()!); + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { group: this.value }); } function toggleNewGroupInput(): void { @@ -383,6 +395,7 @@ function createNewGroup(this: HTMLInputElement): void { if (oldGroup.id !== "states" && oldGroup.id !== "addedLabels" && oldGroup.childElementCount === 1) { ensureEl("labelGroupSelect").selectedOptions[0].remove(); ensureEl("labelGroupSelect").options.add(new Option(group, group, false, true)); + for (const label of Labels.getByGroup(oldGroup.id)) Labels.update(label, { group }); oldGroup.id = group; toggleNewGroupInput(); ensureEl("labelGroupInput").value = ""; @@ -395,6 +408,9 @@ function createNewGroup(this: HTMLInputElement): void { ensureEl("labelGroupSelect").options.add(new Option(group, group, false, true)); ensureEl(group).appendChild(elSelected.node()!); + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { group }); + toggleNewGroupInput(); ensureEl("labelGroupInput").value = ""; } @@ -415,6 +431,7 @@ function removeLabelsGroup(): void { $(this).dialog("close"); $("#labelEditor").dialog("close"); hideGroupSection(); + Labels.removeByGroup(group); select("#labels") .select(`#${group}`) .selectAll("text") @@ -451,15 +468,18 @@ function changeText(): void { el.innerHTML = lines.map((line, index) => `${line}`).join(""); } else el.innerHTML = `${lines}`; - if (elSelected.attr("id").slice(0, 10) === "stateLabel") + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { text: input }); + + if (labelData?.type === "state") tip("Use States Editor to change an actual state name, not just a label", false, "warn"); } function generateRandomName(): void { 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(); + if (labelData?.type === "state") { + const culture = pack.states[labelData.stateId].culture; name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture); } else { const box = (elSelected.node() as SVGGraphicsElement).getBBox(); @@ -509,26 +529,35 @@ function hideLetterSpacingSection(): void { function changeStartOffset(this: HTMLInputElement): void { const value = this.value; ensureEl("labelStartOffsetValue").value = value; - elSelected.select("textPath").attr("startOffset", `${value}%`); - tip(`Label offset: ${value}%`); + setStartOffset(+value); } function changeStartOffsetFromValue(this: HTMLInputElement): void { const value = Math.min(80, Math.max(20, +this.value)); ensureEl("labelStartOffset").value = String(value); this.value = String(value); + setStartOffset(value); +} + +function setStartOffset(value: number): void { elSelected.select("textPath").attr("startOffset", `${value}%`); + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { startOffset: value }); tip(`Label offset: ${value}%`); } function changeRelativeSize(this: HTMLInputElement): void { elSelected.select("textPath").attr("font-size", `${this.value}%`); + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { fontSize: +this.value }); tip(`Label relative size: ${this.value}%`); changeText(); } function changeLetterSpacingSize(this: HTMLInputElement): void { elSelected.select("textPath").attr("letter-spacing", `${this.value}px`); + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { letterSpacing: +this.value }); tip(`Label letter-spacing size: ${this.value}px`); changeText(); } @@ -539,6 +568,9 @@ function editLabelAlign(): void { const path = select("#deftemp").select(`#textPath_${elSelected.attr("id")}`); path.attr("d", `M${c[0] - bbox.width},${c[1]}h${bbox.width * 2}`); drawControlPointsAndLine(); + + const labelData = getLabelData(); + if (labelData) Labels.update(labelData, { pathPoints: extractPathPoints(path.node() as SVGPathElement) }); } function editLabelLegend(): void { @@ -555,10 +587,16 @@ function removeLabel(): void { buttons: { Remove: function (this: HTMLElement) { $(this).dialog("close"); - select("#deftemp") - .select(`#textPath_${elSelected.attr("id")}`) - .remove(); - elSelected.remove(); + const labelData = getLabelData(); + if (labelData) { + Labels.remove(labelData); + removeLabelElements(labelData); + } else { + select("#deftemp") + .select(`#textPath_${elSelected.attr("id")}`) + .remove(); + elSelected.remove(); + } $("#labelEditor").dialog("close"); }, Cancel: function (this: HTMLElement) { @@ -569,7 +607,7 @@ function removeLabel(): void { } function closeLabelEditor(): void { - select("#debug").select("#controlPoints").remove(); + select("#controlPoints").remove(); unselect(); $("#labelEditor").dialog("destroy"); ensureEl("labelEditor").remove(); diff --git a/src/controllers/provinces-editor.ts b/src/controllers/provinces-editor.ts index 45c80a9372..6e93dd4a98 100644 --- a/src/controllers/provinces-editor.ts +++ b/src/controllers/provinces-editor.ts @@ -513,6 +513,7 @@ function updateStatesPostRelease(oldStates: number[], newStates: number[]): void States.findNeighbors(); States.collectStatistics(); States.defineStateForms(newStates); + fitStateLabels(allStates); drawStateLabels(allStates); // redraw emblems diff --git a/src/controllers/states-editor.ts b/src/controllers/states-editor.ts index ba2e043269..9f565115c1 100644 --- a/src/controllers/states-editor.ts +++ b/src/controllers/states-editor.ts @@ -1,7 +1,9 @@ import { color, drag, interpolateString, max, pack as packLayout, select, stratify } from "d3"; import { Controllers } from "@/controllers"; +import { Labels } from "@/generators/labels"; import type { Province } from "@/generators/provinces-generator"; import type { State } from "@/generators/states-generator"; +import { drawLabel, removeLabel } from "@/renderers/draw-labels"; import { destroyDialogIfExists, ensureEl, @@ -514,7 +516,10 @@ function editStateName(state: number): void { s.name = nameInput.value; s.formName = formSelect.value; s.fullName = fullNameInput.value; - if (changed && ensureEl("stateNameEditorUpdateLabel").checked) drawStateLabels([s.i]); + if (changed && ensureEl("stateNameEditorUpdateLabel").checked) { + fitStateLabels([s.i]); + drawStateLabels([s.i]); + } refreshStatesEditor(); } } @@ -665,7 +670,11 @@ function stateChangeCapitalName(state: number, line: HTMLElement, value: string) const capital = pack.states[state].capital; if (!capital) return; pack.burgs[capital].name = value; - (document.querySelector(`#burgLabel${capital}`) as HTMLElement).textContent = value; + const label = Labels.getBurgLabel(capital); + if (label) { + Labels.update(label, { text: value }); + drawLabel(label); + } } function changePopulation(stateId: number): void { @@ -857,12 +866,18 @@ function stateRemovePrompt(state: number): void { }); } +function removeStateLabel(stateId: number): void { + const label = Labels.getStateLabel(stateId); + if (!label) return; + Labels.remove(label); + removeLabel(label); +} + function stateRemove(stateId: number): void { select("#statesBody").select(`#state${stateId}`).remove(); select("#statesBody").select(`#state-gap${stateId}`).remove(); select("#statesHalo").select(`#state-border${stateId}`).remove(); - select("#labels").select(`#stateLabel${stateId}`).remove(); - select("#deftemp").select(`#textPath_stateLabel${stateId}`).remove(); + removeStateLabel(stateId); unfog(`focusState${stateId}`); @@ -1134,7 +1149,10 @@ function recalculateStates(must?: boolean): void { if (layerIsOn("toggleStates")) drawStates(); if (layerIsOn("toggleBorders")) drawBorders(); if (layerIsOn("toggleProvinces")) drawProvinces(); - if (ensureEl("adjustLabels").checked) drawStateLabels(); + if (ensureEl("adjustLabels").checked) { + fitStateLabels(); + drawStateLabels(); + } refreshStatesEditor(); } @@ -1291,7 +1309,11 @@ function applyStatesManualAssignent(): void { refreshStatesEditor(); States.getPoles(); layerIsOn("toggleStates") ? drawStates() : toggleStates(); - if (ensureEl("adjustLabels").checked) drawStateLabels([...new Set(affectedStates)]); + if (ensureEl("adjustLabels").checked) { + const statesToRefit = [...new Set(affectedStates)]; + fitStateLabels(statesToRefit); + drawStateLabels(statesToRefit); + } adjustProvinces([...new Set(affectedProvinces)]); layerIsOn("toggleBorders") ? drawBorders() : toggleBorders(); if (layerIsOn("toggleProvinces")) drawProvinces(); @@ -1600,6 +1622,8 @@ function addState(this: SVGElement, event: MouseEvent): void { States.defineStateForms([newState]); adjustProvinces([cells.province[center]]); + Labels.ensureStateLabel(newState); + fitStateLabels([newState]); drawStateLabels([newState]); COArenderer.add("state", newState, coa as any, states[newState].pole[0], states[newState].pole[1]); @@ -1744,8 +1768,7 @@ function openStateMergeDialog(): void { select("#statesBody").select(`#state${stateId}`).remove(); select("#statesBody").select(`#state-gap${stateId}`).remove(); select("#statesHalo").select(`#state-border${stateId}`).remove(); - select("#labels").select(`#stateLabel${stateId}`).remove(); - select("#deftemp").select(`#textPath_stateLabel${stateId}`).remove(); + removeStateLabel(stateId); ensureEl(`stateCOA${stateId}`).remove(); select("#emblems").select(`#stateEmblems > use[data-i='${stateId}']`).remove(); @@ -1800,6 +1823,7 @@ function openStateMergeDialog(): void { layerIsOn("toggleStates") ? drawStates() : toggleStates(); layerIsOn("toggleBorders") ? drawBorders() : toggleBorders(); layerIsOn("toggleProvinces") && drawProvinces(); + fitStateLabels([rulingStateId]); drawStateLabels([rulingStateId]); refreshStatesEditor(); diff --git a/src/generators/burgs-generator.ts b/src/generators/burgs-generator.ts index ceeab7b528..0fe6cbe958 100644 --- a/src/generators/burgs-generator.ts +++ b/src/generators/burgs-generator.ts @@ -1,8 +1,11 @@ import { select } from "d3"; import { quadtree } from "d3-quadtree"; +import { removeBurgLabel } from "../renderers/draw-burg-labels"; +import { drawLabel } from "../renderers/draw-labels"; import { each, ensureEl, gauss, minmax, normalize, P, rn } from "../utils"; import { type CultureType, DEFAULT_CULTURE_TYPE } from "./cultures-generator"; import { NON_NAVIGABLE_LAKE_GROUPS } from "./features"; +import { Labels } from "./labels"; import type { ProductionRecord } from "./production-generator"; import type { River } from "./river-generator"; import type { Point } from "./voronoi"; @@ -744,7 +747,14 @@ class BurgModule { if (newRoute && layerIsOn("toggleRoutes")) drawRoute(newRoute); drawBurgIcon(burg); - drawBurgLabel(burg); + const label = Labels.addBurgLabel({ + burgId, + group: burg.group!, + text: burg.name!, + x, + y + }); + drawLabel(label); return burgId; } @@ -759,7 +769,11 @@ class BurgModule { } drawBurgIcon(burg); - drawBurgLabel(burg); + const label = Labels.getBurgLabel(burg.i!); + if (label) { + Labels.update(label, { group: burg.group }); + drawLabel(label); + } } remove(burgId: number) { @@ -779,7 +793,9 @@ class BurgModule { } removeBurgIcon(burg.i!); - removeBurgLabel(burg.i!); + const label = Labels.getBurgLabel(burgId); + if (label) Labels.remove(label); + removeBurgLabel(burgId); // by burgId: also cleans up if the label data was missing } } diff --git a/src/generators/index.ts b/src/generators/index.ts index 95d1684ac7..8530bfa726 100644 --- a/src/generators/index.ts +++ b/src/generators/index.ts @@ -11,6 +11,7 @@ import "./routes-generator"; import "./states-generator"; import "./zones-generator"; import "./religions-generator"; +import "./labels"; import "./provinces-generator"; import "./emblems"; import "./ice-generator"; diff --git a/src/generators/labels.ts b/src/generators/labels.ts new file mode 100644 index 0000000000..aa0bf87ef9 --- /dev/null +++ b/src/generators/labels.ts @@ -0,0 +1,245 @@ +// SVG group id state labels are rendered into +export const STATE_LABELS_GROUP = "states"; + +// attributes every label shares, regardless of how it is rendered +export interface BaseLabel { + i: number; + text: string; + group: string; + dx?: number; + dy?: number; +} + +// label rendered along an SVG textPath; pathPoints may be absent until a fitting pass stores them +export interface PathLabel extends BaseLabel { + pathPoints?: [number, number][]; + startOffset?: number; + fontSize?: number; + letterSpacing?: number; +} + +// label anchored to a single map point +export interface PointLabel extends BaseLabel { + x: number; + y: number; +} + +export interface StateLabel extends PathLabel { + type: "state"; + stateId: number; +} + +export interface BurgLabel extends PointLabel { + type: "burg"; + burgId: number; +} + +export interface CustomLabel extends PathLabel { + type: "custom"; + pathPoints: [number, number][]; +} + +export type LabelData = StateLabel | BurgLabel | CustomLabel; + +export const isPathLabel = (label: LabelData): label is StateLabel | CustomLabel => + label.type === "state" || label.type === "custom"; + +class LabelsModule { + private freeIds: Set = new Set(); + private maxId: number = 0; + // initialization flag as the constructor version doesn't blocks other modules from beeing initialized. + private initialized: boolean = false; + + private getNextId(): number { + if (!this.initialized) { + this.initialized = true; + this.freeIds.clear(); + const existingIds = pack.labels.map(l => l.i).sort((a, b) => a - b); + + for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { + if (!existingIds.includes(id)) this.freeIds.add(id); + } + + this.maxId = existingIds.length > 0 ? existingIds[existingIds.length - 1] + 1 : 0; + } + + if (this.freeIds.size > 0) { + // Get and remove the next available ID from the freeIds set + const id = this.freeIds.values().next().value!; + this.freeIds.delete(id); + return id; + } + + // maxId is always 1 greater than the current highest ID, so we can return it and then increment for the next call + const nextId = this.maxId; + this.maxId++; + return nextId; + } + + 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); + } + + getByGroup(group: string): LabelData[] { + return pack.labels.filter(l => l.group === group); + } + + getByType(type: "state"): StateLabel[]; + getByType(type: "burg"): BurgLabel[]; + getByType(type: "custom"): CustomLabel[]; + getByType(type: LabelData["type"]): LabelData[] { + return pack.labels.filter(l => l.type === type); + } + + getBurgLabel(burgId: number): BurgLabel | undefined { + return pack.labels.find((l): l is BurgLabel => l.type === "burg" && l.burgId === burgId); + } + + getStateLabel(stateId: number): StateLabel | undefined { + return pack.labels.find((l): l is StateLabel => l.type === "state" && l.stateId === stateId); + } + + // get the label for a state, creating it if missing (e.g. for a newly created state) + ensureStateLabel(stateId: number): StateLabel { + return ( + this.getStateLabel(stateId) ?? + this.addStateLabel({ stateId, group: STATE_LABELS_GROUP, text: pack.states[stateId].name!, fontSize: 100 }) + ); + } + + addStateLabel(data: Omit): StateLabel { + const label: StateLabel = { + ...data, + i: this.getNextId(), + type: "state" + }; + pack.labels.push(label); + return label; + } + + addBurgLabel(data: Omit): BurgLabel { + const label: BurgLabel = { ...data, i: this.getNextId(), type: "burg" }; + pack.labels.push(label); + return label; + } + + addCustomLabel(data: Omit): CustomLabel { + const label: CustomLabel = { + ...data, + i: this.getNextId(), + type: "custom" + }; + pack.labels.push(label); + return label; + } + + update(label: T, updates: Partial): T; + update(id: number, updates: Partial): LabelData | undefined; + update(target: number | LabelData, updates: Partial): LabelData | undefined { + const label = typeof target === "number" ? pack.labels.find(l => l.i === target) : target; + if (!label) { + ERROR && console.error(`Label with id ${target} was not found for update.`); + return undefined; + } + Object.assign(label, updates, { i: label.i, type: label.type }); + return label; + } + + remove(target: number | LabelData): void { + const id = typeof target === "number" ? target : target.i; + const index = pack.labels.findIndex(l => l.i === id); + if (index === -1) return; + this.freeIds.add(id); + pack.labels.splice(index, 1); + } + + removeByType(type: LabelData["type"]): void { + this.initialized = false; + pack.labels = pack.labels.filter(l => l.type !== type); + } + + removeByGroup(group: string): void { + this.initialized = false; + pack.labels = pack.labels.filter(l => l.group !== group); + } + + clear(): void { + pack.labels = []; + this.initialized = false; + } + + // replace all labels from deserialized data and reset id bookkeeping + load(labels: LabelData[]): void { + pack.labels = labels; + this.freeIds.clear(); + this.maxId = 0; + this.initialized = false; + } + + /** + * Generate state labels data entries for each non-locked state. + * Only stores essential label data; raycast path calculation happens during the fitting pass. + * Labels of locked states are kept as they are. + */ + generateStateLabels(): void { + if (TIME) console.time("generateStateLabels"); + + const { states } = pack; + + // keep labels of locked states — they are not regenerated below + this.initialized = false; + pack.labels = pack.labels.filter(l => l.type !== "state" || states[l.stateId]?.lock); + + for (const state of states) { + if (!state.i || state.removed || state.lock) continue; + + this.addStateLabel({ + stateId: state.i, + group: STATE_LABELS_GROUP, + 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"); + + this.removeByType("burg"); + + 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"); + } +} + +export const Labels = new LabelsModule(); +window.Labels = Labels; diff --git a/src/index.html b/src/index.html index 1297a76398..7b6e33e2b7 100644 --- a/src/index.html +++ b/src/index.html @@ -5507,14 +5507,14 @@ - + - + - + diff --git a/src/renderers/draw-burg-labels.ts b/src/renderers/draw-burg-labels.ts index d64d034268..81540f8cef 100644 --- a/src/renderers/draw-burg-labels.ts +++ b/src/renderers/draw-burg-labels.ts @@ -1,71 +1,98 @@ import { select } from "d3"; -import type { Burg } from "../generators/burgs-generator"; +import { type BurgLabel, Labels } from "@/generators/labels"; +// remove this section once layer.js is refactored-------------------------------- declare global { var drawBurgLabels: () => void; - var drawBurgLabel: (burg: Burg) => void; - var removeBurgLabel: (burgId: number) => void; } -const burgLabelsRenderer = (): void => { +window.drawBurgLabels = drawBurgLabelsRenderer; +// section end ------------------------------------------------------------------- + +export function drawBurgLabelsRenderer(): void { TIME && console.time("drawBurgLabels"); createLabelGroups(); - for (const { name } of options.burgs.groups) { - 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")) { + if (!burgLabelsByGroup.has(label.group)) { + burgLabelsByGroup.set(label.group, []); + } + burgLabelsByGroup.get(label.group)!.push(label); + } - const labelGroup = select("#burgLabels").select(`#${name}`); + // Render each group and update label offsets from SVG attributes + for (const [groupName, labels] of burgLabelsByGroup) { + const labelGroup = select("#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: SVGTextElement[] = []; + for (const labelData of labels) { + const textElement = document.createElementNS("http://www.w3.org/2000/svg", "text"); + textElement.setAttribute("text-rendering", "optimizeSpeed"); + textElement.setAttribute("id", `burgLabel${labelData.i}`); + textElement.setAttribute("data-id", labelData.burgId.toString()); + textElement.setAttribute("x", labelData.x.toString()); + textElement.setAttribute("y", labelData.y.toString()); + textElement.setAttribute("dx", `${dx}em`); + textElement.setAttribute("dy", `${dy}em`); + if (labelData.dx || labelData.dy) { + textElement.setAttribute("transform", `translate(${labelData.dx || 0},${labelData.dy || 0})`); + } + textElement.textContent = labelData.text; + labelsHTML.push(textElement); + } + + // Set all labels at once + const groupNode = labelGroup.node(); + if (groupNode) { + groupNode.replaceChildren(...labelsHTML); + } } TIME && console.timeEnd("drawBurgLabels"); -}; +} -const drawBurgLabelRenderer = (burg: Burg): void => { - const labelGroup = select("#burgLabels").select(`#${burg.group}`); +export function drawBurgLabel(burgLabel: BurgLabel): void { + // TODO: remove label group dependency - for now, if group is missing, redraw all labels to recreate the group + const labelGroup = select("#burgLabels").select(`#${burgLabel.group}`); if (labelGroup.empty()) { - drawBurgLabels(); + drawBurgLabelsRenderer(); 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${burgLabel.i}`); + if (existingLabel) existingLabel.remove(); + + // Render to SVG labelGroup .append("text") .attr("text-rendering", "optimizeSpeed") - .attr("id", `burgLabel${burg.i}`) - .attr("data-id", burg.i!) - .attr("x", burg.x) - .attr("y", burg.y) + .attr("id", `burgLabel${burgLabel.i}`) + .attr("data-id", burgLabel.burgId) + .attr("x", burgLabel.x) + .attr("y", burgLabel.y) .attr("dx", `${dx}em`) .attr("dy", `${dy}em`) - .text(burg.name!); -}; + .attr("transform", burgLabel.dx || burgLabel.dy ? `translate(${burgLabel.dx || 0},${burgLabel.dy || 0})` : null) + .text(burgLabel.text); +} -const removeBurgLabelRenderer = (burgId: number): void => { - const existingLabel = document.getElementById(`burgLabel${burgId}`); +export function removeBurgLabel(burgId: number): void { + const existingLabel = document.querySelector(`#burgLabels [data-id='${burgId}']`); if (existingLabel) existingLabel.remove(); -}; +} function createLabelGroups(): void { // save existing styles and remove all groups @@ -89,7 +116,3 @@ function createLabelGroups(): void { group.attr("id", name); } } - -window.drawBurgLabels = burgLabelsRenderer; -window.drawBurgLabel = drawBurgLabelRenderer; -window.removeBurgLabel = removeBurgLabelRenderer; diff --git a/src/renderers/draw-labels.ts b/src/renderers/draw-labels.ts new file mode 100644 index 0000000000..315188d5e7 --- /dev/null +++ b/src/renderers/draw-labels.ts @@ -0,0 +1,95 @@ +import { type CustomLabel, isPathLabel, type LabelData, Labels, type StateLabel } from "../generators/labels"; +import { drawBurgLabel, removeBurgLabel } from "./draw-burg-labels"; +import { + buildPathLabelElements, + drawPathLabel, + ensureLabelGroup, + getPathLabelElementId, + removePathLabel +} from "./draw-path-label"; +import { fitLabels } from "./fit-state-labels"; + +// remove this section once layer.js is refactored-------------------------------- +window.drawCustomLabels = drawCustomLabels; +window.drawCustomLabel = drawPathLabel; +window.drawStateLabels = drawStateLabels; +window.ensureLabelGroup = ensureLabelGroup; +// ------------------------------------------------------------------------------- + +// render a single label based on its shape: along a path or at a point +export function drawLabel(label: LabelData): void { + if (isPathLabel(label)) drawPathLabel(label); + else drawBurgLabel(label); +} + +// remove a label's rendered elements based on its shape +export function removeLabel(label: LabelData): void { + if (isPathLabel(label)) removePathLabel(label); + else removeBurgLabel(label.burgId); +} + +export function getStateLabels(list?: number[]): StateLabel[] { + const stateLabels = Labels.getByType("state"); + if (list && list.length > 0) return stateLabels.filter(label => list.includes(label.stateId)); + return stateLabels; +} + +/** + * Render state labels from pack.labels data to SVG. + * Labels without stored pathPoints (not fitted yet) are fitted (data-only) first; + * already fitted labels are drawn as-is, preserving user edits. + * list - optional array of stateIds to re-render + */ +export function drawStateLabels(list?: number[]): void { + TIME && console.time("drawStateLabels"); + const { states } = pack; + + const stateLabels = getStateLabels(list); + const unfitted = stateLabels.filter(label => !label.pathPoints?.length); + if (unfitted.length) fitLabels(unfitted); + + const drawable = stateLabels.filter(label => { + if (!label.pathPoints?.length) return false; // fitting skipped it (invalid state) + const state = states[label.stateId]; + return Boolean(state?.i) && !state.removed; + }); + + drawPathLabelsBatch(drawable); + TIME && console.timeEnd("drawStateLabels"); +} + +export function drawCustomLabels(): void { + TIME && console.time("drawCustomLabels"); + const customLabels = Labels.getByType("custom"); + + // clear rendered groups first so removed labels don't linger + for (const group of new Set(customLabels.map(label => label.group))) { + document.querySelector(`g#labels > g#${group}`)?.replaceChildren(); + } + + drawPathLabelsBatch(customLabels); + TIME && console.timeEnd("drawCustomLabels"); +} + +// collect all elements first, then insert with one DOM operation per container +function drawPathLabelsBatch(labelList: (StateLabel | CustomLabel)[]): void { + const pathGroup = document.querySelector("defs > g#deftemp > g#textPaths")!; + const textsByGroup = new Map(); + const paths: SVGPathElement[] = []; + + for (const label of labelList) { + const { text, path } = buildPathLabelElements(label); + if (!textsByGroup.has(label.group)) textsByGroup.set(label.group, []); + textsByGroup.get(label.group)!.push(text); + paths.push(path); + + // drop stale elements; the batch append below replaces them + document.getElementById(getPathLabelElementId(label))?.remove(); + document.getElementById(path.id)?.remove(); + } + + pathGroup.append(...paths); + for (const [group, texts] of textsByGroup) { + ensureLabelGroup(group).append(...texts); + } +} diff --git a/src/renderers/draw-path-label.ts b/src/renderers/draw-path-label.ts new file mode 100644 index 0000000000..aa76b9c1e1 --- /dev/null +++ b/src/renderers/draw-path-label.ts @@ -0,0 +1,113 @@ +import { curveNatural, line } from "d3"; +import type { CustomLabel, StateLabel } from "../generators/labels"; + +// any label of the PathLabel family; rendered into g#labels > g#{label.group} +type RenderablePathLabel = StateLabel | CustomLabel; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const lineGen = line<[number, number]>().curve(curveNatural); + +export const getPathLabelElementId = (label: { i: number }): string => `pathLabel${label.i}`; +const getPathId = (label: { i: number }): string => `textPath_${getPathLabelElementId(label)}`; + +// get a label group container, creating it with the default label style if missing, +// so labels render even when their group is not part of the loaded SVG +export function ensureLabelGroup(group: string): SVGGElement { + const labels = document.querySelector("g#labels")!; + const existing = labels.querySelector(`:scope > g#${group}`); + if (existing) return existing; + + const container = document.createElementNS(SVG_NS, "g"); + container.id = group; + container.setAttribute("fill", "#3e3e4b"); + container.setAttribute("opacity", "1"); + container.setAttribute("stroke", "#3a3a3a"); + container.setAttribute("stroke-width", "0"); + container.setAttribute("font-family", "Almendra SC"); + container.setAttribute("font-size", "18"); + container.setAttribute("data-size", "18"); + labels.appendChild(container); + return container; +} + +// build a detached defs path element the label text follows; +// pathId overrides the default id so measurement copies don't collide with rendered elements +function buildLabelPath(label: RenderablePathLabel, pathId?: string): SVGPathElement { + const pathElement = document.createElementNS(SVG_NS, "path"); + pathElement.setAttribute("id", pathId ?? getPathId(label)); + pathElement.setAttribute("d", lineGen(label.pathPoints || []) || ""); + return pathElement; +} + +// build a detached text element referencing the label's path +function buildLabelText(label: RenderablePathLabel, pathId?: string): SVGTextElement { + const lines = label.text.split("|"); + const tspans = lines.map((lineText, index) => { + const tspan = document.createElementNS(SVG_NS, "tspan"); + tspan.setAttribute("x", "0"); + tspan.setAttribute("dy", index ? "1em" : `${(lines.length - 1) / -2}em`); + tspan.textContent = lineText; + return tspan; + }); + + const textPath = document.createElementNS(SVG_NS, "textPath"); + textPath.setAttribute("href", `#${pathId ?? getPathId(label)}`); + textPath.setAttribute("startOffset", `${label.startOffset ?? 50}%`); + textPath.setAttribute("font-size", `${label.fontSize ?? 100}%`); + if (label.letterSpacing) textPath.setAttribute("letter-spacing", `${label.letterSpacing}px`); + textPath.append(...tspans); + + const textElement = document.createElementNS(SVG_NS, "text"); + textElement.setAttribute("text-rendering", "optimizeSpeed"); + textElement.setAttribute("id", pathId ? `${pathId}_text` : getPathLabelElementId(label)); + if (label.dx || label.dy) { + textElement.setAttribute("transform", `translate(${label.dx || 0}, ${label.dy || 0})`); + } + textElement.appendChild(textPath); + + return textElement; +} + +// build both detached elements for batched insertion by bulk renderers; +// pass pathId to get a measurement copy whose ids don't collide with rendered elements +export function buildPathLabelElements( + label: RenderablePathLabel, + pathId?: string +): { text: SVGTextElement; path: SVGPathElement } { + return { text: buildLabelText(label, pathId), path: buildLabelPath(label, pathId) }; +} + +// create or update the defs path in the DOM; returns the attached path element +export function upsertLabelPath(label: RenderablePathLabel): SVGPathElement { + const pathGroup = document.querySelector("defs > g#deftemp > g#textPaths")!; + const pathElement = buildLabelPath(label); + + const existing = pathGroup.querySelector(`#${pathElement.id}`); + if (existing) existing.replaceWith(pathElement); + else pathGroup.appendChild(pathElement); + + return pathElement; +} + +// render a single path-following label from its data; replaces an existing element with the same id +export function drawPathLabel(label: RenderablePathLabel): SVGTextElement { + const container = ensureLabelGroup(label.group); + + upsertLabelPath(label); + const textElement = buildLabelText(label); + + const existing = document.getElementById(getPathLabelElementId(label)); + if (existing?.parentNode === container) existing.replaceWith(textElement); + else { + existing?.remove(); + container.appendChild(textElement); + } + + return textElement; +} + +// remove a path label's text element and its defs path +export function removePathLabel(label: { i: number }): void { + document.getElementById(getPathLabelElementId(label))?.remove(); + document.getElementById(getPathId(label))?.remove(); +} diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts deleted file mode 100644 index f39da1c3f4..0000000000 --- a/src/renderers/draw-state-labels.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { curveNatural, line, max, select } from "d3"; -import type { TypedArray } from "../types/PackedGraph"; -import { drawPath, drawPoint, findClosestCell, minmax, rn, round, splitInTwo } from "../utils"; - -declare global { - var drawStateLabels: (list?: number[]) => void; -} - -interface Ray { - angle: number; - length: number; - x: number; - y: number; -} - -interface AngleData { - angle: number; - dx: number; - dy: number; -} - -type PathPoints = [number, number][]; - -// list - an optional array of stateIds to regenerate -const stateLabelsRenderer = (list?: number[]): void => { - TIME && console.time("drawStateLabels"); - - // temporary make the labels visible - const layerDisplay = select("#labels").style("display"); - select("#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 LENGTH_START = 5; - const LENGTH_STEP = 5; - const LENGTH_MAX = 300; - - const labelPaths = getLabelPaths(); - const letterLength = checkExampleLetterLength(); - drawLabelPath(letterLength); - - // restore labels visibility - select("#labels").style("display", layerDisplay); - - function getLabelPaths(): [number, PathPoints][] { - const labelPaths: [number, PathPoints][] = []; - - for (const state of states) { - if (!state.i || state.removed || state.lock) continue; - if (list && !list.includes(state.i)) continue; - - const offset = getOffsetWidth(state.cells!); - const maxLakeSize = state.cells! / 20; - const [x0, y0] = state.pole!; - - const rays: Ray[] = angles.map(({ angle, dx, dy }) => { - const { length, x, y } = raycast({ - stateId: state.i, - x0, - y0, - dx, - dy, - maxLakeSize, - offset - }); - return { angle, length, x, y }; - }); - const [ray1, ray2] = findBestRayPair(rays); - - const pathPoints: PathPoints = [[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(); - - const textPath = pathGroup - .append("path") - .attr("d", round(lineGen(pathPoints) || "")) - .attr("id", `textPath_stateLabel${stateId}`); - - const pathLength = (textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters - const [lines, ratio] = getLinesAndRatio(mode, state.name!, state.fullName!, pathLength); - - // prolongate path if it's too short - const longestLineLength = max(lines.map(line => line.length)) || 0; - if (pathLength && pathLength < longestLineLength) { - const [x1, y1] = pathPoints.at(0)!; - const [x2, y2] = pathPoints.at(-1)!; - const [dx, dy] = [(x2 - x1) / 2, (y2 - y1) / 2]; - - const mod = longestLineLength / pathLength; - pathPoints[0] = [x1 + dx - dx * mod, y1 + dy - dy * mod]; - pathPoints[pathPoints.length - 1] = [x2 - dx + dx * mod, y2 - dy + dy * mod]; - - textPath.attr("d", round(lineGen(pathPoints) || "")); - } - - const textElement = textGroup - .append("text") - .attr("text-rendering", "optimizeSpeed") - .attr("id", `stateLabel${stateId}`) - .append("textPath") - .attr("startOffset", "50%") - .attr("font-size", `${ratio}%`) - .node() as SVGTextPathElement; - - const top = (lines.length - 1) / -2; // y offset - const spans = lines.map((lineText, index) => `${lineText}`); - textElement.insertAdjacentHTML("afterbegin", spans.join("")); - - const { width, height } = textElement.getBBox(); - textElement.setAttribute("href", `#textPath_stateLabel${stateId}`); - - if (mode === "full" || lines.length === 1) continue; - - // check if label fits state boundaries. If no, replace it with short name - const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!]; - const angleRad = Math.atan2(y2 - y1, x2 - x1); - - const isInsideState = checkIfInsideState(textElement, angleRad, width / 2, height / 2, stateIds, stateId); - if (isInsideState) continue; - - // replace name to one-liner - const text = pathLength > state.fullName!.length * 1.8 ? state.fullName! : state.name!; - textElement.innerHTML = `${text}`; - - const correctedRatio = minmax(rn((pathLength / text.length) * 50), 50, 130); - textElement.setAttribute("font-size", `${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, fullName: string, pathLength: number): [string[], number] { - if (mode === "short") return getShortOneLine(); - if (pathLength > fullName.length * 2) return getFullOneLine(); - return getFullTwoLines(); - - function getShortOneLine(): [string[], number] { - const ratio = pathLength / name.length; - return [[name], minmax(rn(ratio * 60), 50, 150)]; - } - - function getFullOneLine(): [string[], number] { - const ratio = pathLength / fullName.length; - return [[fullName], minmax(rn(ratio * 70), 70, 170)]; - } - - function getFullTwoLines(): [string[], number] { - const lines = splitInTwo(fullName); - const longestLineLength = max(lines.map(line => line.length)) || 0; - const ratio = pathLength / longestLineLength; - return [lines, minmax(rn(ratio * 60), 70, 150)]; - } - } - - // check whether multi-lined label is mostly inside the state. If no, replace it with short name label - function checkIfInsideState( - textElement: SVGTextPathElement, - angleRad: number, - halfwidth: number, - halfheight: number, - stateIds: TypedArray, - stateId: number - ): boolean { - const bbox = textElement.getBBox(); - const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; - - const points: [number, number][] = [ - [-halfwidth, -halfheight], - [+halfwidth, -halfheight], - [+halfwidth, halfheight], - [-halfwidth, halfheight], - [0, halfheight], - [0, -halfheight] - ]; - - const sin = Math.sin(angleRad); - const cos = Math.cos(angleRad); - const rotatedPoints = points.map(([x, y]): [number, number] => [cx + x * cos - y * sin, cy + x * sin + y * cos]); - - let pointsInside = 0; - for (const [x, y] of rotatedPoints) { - const isInside = stateIds[findClosestCell(x, y, undefined, pack) as number] === stateId; - if (isInside) pointsInside++; - if (pointsInside > 4) return true; - } - - return false; - } - - TIME && console.timeEnd("drawStateLabels"); -}; - -window.drawStateLabels = stateLabelsRenderer; diff --git a/src/renderers/fit-state-labels.ts b/src/renderers/fit-state-labels.ts new file mode 100644 index 0000000000..a186eebdd7 --- /dev/null +++ b/src/renderers/fit-state-labels.ts @@ -0,0 +1,214 @@ +// NOTE: deliberate exception to "renderers must not modify world state" — this is a LAYOUT PASS. +// Fitting needs DOM text measurement, and its results (pathPoints, text, fontSize) are derived +// layout data that must be stored in the data model so labels serialize and redraw without refitting. +// It writes DATA ONLY: measurements run against throwaway elements in a hidden sandbox, +// nothing is rendered — the caller renders afterwards via drawStateLabels. +import { max } from "d3"; +import { Labels, type StateLabel } from "@/generators/labels"; +import type { State } from "@/generators/states-generator"; +import type { TypedArray } from "@/types/PackedGraph"; +import { findClosestCell, minmax, rn, splitInTwo } from "../utils"; +import { getStateLabels } from "./draw-labels"; +import { buildPathLabelElements, ensureLabelGroup } from "./draw-path-label"; +import { ANGLES, findBestRayPair, raycast } from "./label-raycast"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const MEASURE_PATH_ID = "measureLabelPath"; + +/** + * Fit state labels into their state borders and store the result (pathPoints, text, fontSize) + * in the Labels data model. Does NOT render — call drawStateLabels afterwards to see the result. + * Overwrites manual label edits — call it when the underlying state changed (name, borders), + * not for a plain redraw. + * list - optional array of stateIds to refit + */ +export const fitStateLabels = (list?: number[]): void => { + TIME && console.time("fitStateLabels"); + fitLabels(getStateLabels(list)); + TIME && console.timeEnd("fitStateLabels"); +}; + +export function fitLabels(labelDataList: StateLabel[]): void { + const sandbox = createMeasurementSandbox("states"); + + try { + const { states } = pack; + const mode = options.stateLabelsMode || "auto"; + const letterLength = checkExampleLetterLength(sandbox); + + for (const labelData of labelDataList) { + const state = states[labelData.stateId]; + if (!state?.i || state.removed) continue; + fitLabel(labelData, state, letterLength, mode, sandbox); + } + } finally { + sandbox.remove(); + } +} + +// hidden group at the svg root carrying the label group's computed font context, so +// measurements match the real render even while the labels layer itself is display:none +function createMeasurementSandbox(group: string): SVGGElement { + const sandbox = document.createElementNS(SVG_NS, "g"); + sandbox.id = "labelMeasurement"; + // visibility (not display): getBBox/getComputedTextLength need layout to be computed + sandbox.style.visibility = "hidden"; + + const groupStyle = getComputedStyle(ensureLabelGroup(group)); + sandbox.setAttribute("font-family", groupStyle.fontFamily); + sandbox.setAttribute("font-size", groupStyle.fontSize); + sandbox.setAttribute("letter-spacing", groupStyle.letterSpacing); + + document.getElementById("map")!.appendChild(sandbox); + return sandbox; +} + +function fitLabel(labelData: StateLabel, state: State, letterLength: number, mode: string, sandbox: SVGGElement): void { + // calculate pathPoints using raycast algorithm + const offset = getOffsetWidth(state.cells!); + const maxLakeSize = state.cells! / 20; + const [x0, y0] = state.pole!; + + const rays = ANGLES.map(({ angle, dx, dy }) => { + const { length, x, y } = raycast({ stateId: state.i, x0, y0, dx, dy, maxLakeSize, offset }); + return { angle, length, x, y }; + }); + const [ray1, ray2] = findBestRayPair(rays); + + const pathPoints: [number, number][] = [[ray1.x, ray1.y], state.pole!, [ray2.x, ray2.y]]; + if (ray1.x > ray2.x) pathPoints.reverse(); + Labels.update(labelData, { pathPoints }); + + const pathElement = measureLabelPath(labelData, sandbox); + const pathLength = pathElement.getTotalLength() / letterLength; // path length in letters + const [lines, ratio] = getLinesAndRatio(mode, state.name!, state.fullName!, pathLength); + Labels.update(labelData, { text: lines.join("|"), fontSize: ratio }); + + // prolongate path if it's too short + const longestLineLength = max(lines.map(line => line.length)) || 0; + if (pathLength && pathLength < longestLineLength) { + const [x1, y1] = pathPoints.at(0)!; + const [x2, y2] = pathPoints.at(-1)!; + const [dx, dy] = [(x2 - x1) / 2, (y2 - y1) / 2]; + + const mod = longestLineLength / pathLength; + pathPoints[0] = [x1 + dx - dx * mod, y1 + dy - dy * mod]; + pathPoints[pathPoints.length - 1] = [x2 - dx + dx * mod, y2 - dy + dy * mod]; + + Labels.update(labelData, { pathPoints }); + measureLabelPath(labelData, sandbox); + } + + if (mode === "full" || lines.length === 1) return; + + // check if label fits state boundaries. If no, replace it with short name + const textElement = measureLabelText(labelData, sandbox); + const { width, height } = textElement.getBBox(); + const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!]; + const angleRad = Math.atan2(y2 - y1, x2 - x1); + + const isInsideState = checkIfInsideState(textElement, angleRad, width / 2, height / 2, labelData.stateId); + textElement.remove(); + if (isInsideState) return; + + // replace name to one-liner + const text = pathLength > state.fullName!.length * 1.8 ? state.fullName! : state.name!; + const correctedRatio = minmax(rn((pathLength / text.length) * 50), 50, 130); + Labels.update(labelData, { text, fontSize: correctedRatio }); +} + +// create or update the sandbox measurement path for the label's current pathPoints +function measureLabelPath(label: StateLabel, sandbox: SVGGElement): SVGPathElement { + const { path } = buildPathLabelElements(label, MEASURE_PATH_ID); + sandbox.querySelector(`#${MEASURE_PATH_ID}`)?.remove(); + sandbox.appendChild(path); + return path; +} + +// attach a measurement copy of the label's text to the sandbox; caller removes it after measuring +function measureLabelText(label: StateLabel, sandbox: SVGGElement): SVGTextElement { + const { text } = buildPathLabelElements(label, MEASURE_PATH_ID); + sandbox.appendChild(text); + return text; +} + +/** + * 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; +} + +function checkExampleLetterLength(sandbox: SVGGElement): number { + const testLabel = document.createElementNS(SVG_NS, "text"); + testLabel.setAttribute("x", "0"); + testLabel.setAttribute("y", "0"); + testLabel.textContent = "Example"; + sandbox.appendChild(testLabel); + const letterLength = testLabel.getComputedTextLength() / 7; // approximate length of 1 letter + testLabel.remove(); + + return letterLength; +} + +function getLinesAndRatio(mode: string, name: string, fullName: string, pathLength: number): [string[], number] { + if (mode === "short") return getShortOneLine(); + if (pathLength > fullName.length * 2) return getFullOneLine(); + return getFullTwoLines(); + + function getShortOneLine(): [string[], number] { + const ratio = pathLength / name.length; + return [[name], minmax(rn(ratio * 60), 50, 150)]; + } + + function getFullOneLine(): [string[], number] { + const ratio = pathLength / fullName.length; + return [[fullName], minmax(rn(ratio * 70), 70, 170)]; + } + + function getFullTwoLines(): [string[], number] { + const lines = splitInTwo(fullName); + const longestLineLength = max(lines.map(line => line.length)) || 0; + const ratio = pathLength / longestLineLength; + return [lines, minmax(rn(ratio * 60), 70, 150)]; + } +} + +// check whether multi-lined label is mostly inside the state. If no, replace it with short name label +function checkIfInsideState( + textElement: SVGGraphicsElement, + angleRad: number, + halfwidth: number, + halfheight: number, + stateId: number +): boolean { + const stateIds: TypedArray = pack.cells.state; + const bbox = textElement.getBBox(); + const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; + + const points: [number, number][] = [ + [-halfwidth, -halfheight], + [+halfwidth, -halfheight], + [+halfwidth, halfheight], + [-halfwidth, halfheight], + [0, halfheight], + [0, -halfheight] + ]; + + const sin = Math.sin(angleRad); + const cos = Math.cos(angleRad); + const rotatedPoints = points.map(([x, y]): [number, number] => [cx + x * cos - y * sin, cy + x * sin + y * cos]); + + let pointsInside = 0; + for (const [x, y] of rotatedPoints) { + const isInside = stateIds[findClosestCell(x, y, undefined, pack) as number] === stateId; + if (isInside) pointsInside++; + if (pointsInside > 4) return true; + } + + return false; +} + +window.fitStateLabels = fitStateLabels; diff --git a/src/renderers/index.ts b/src/renderers/index.ts index bace93d5f1..7ba7f909c2 100644 --- a/src/renderers/index.ts +++ b/src/renderers/index.ts @@ -2,6 +2,8 @@ import "./coastline-fractal"; import "./draw-borders"; import "./draw-burg-icons"; import "./draw-burg-labels"; +import "./draw-labels"; +import "./fit-state-labels"; import "./draw-emblems"; import "./draw-features"; import "./draw-heightmap"; @@ -11,7 +13,6 @@ import "./draw-military"; import "./draw-relief-icons"; import "./draw-measurers"; import "./draw-scalebar"; -import "./draw-state-labels"; import "./draw-temperature"; import "./draw-goods"; import "./draw-markets"; diff --git a/src/renderers/label-raycast.ts b/src/renderers/label-raycast.ts new file mode 100644 index 0000000000..cf3e0e6e6d --- /dev/null +++ b/src/renderers/label-raycast.ts @@ -0,0 +1,173 @@ +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/renderers/view-3d-renderer.ts b/src/renderers/view-3d-renderer.ts index 98180af1ff..21e81c8c15 100644 --- a/src/renderers/view-3d-renderer.ts +++ b/src/renderers/view-3d-renderer.ts @@ -1,5 +1,6 @@ import { select } from "d3"; import type * as THREE from "three"; +import { Labels } from "@/generators/labels"; import { Services } from "@/services"; import { timeOfDayPresets } from "../data/view-3d-options"; import { minmax, rn, throttle } from "../utils"; @@ -555,7 +556,7 @@ async function createLabels() { if (state.removed) continue; const [x, y, z] = get3dCoords(state.pole![0], state.pole![1]); - const text = states.select(`#stateLabel${state.i}`)?.text() || state.name; + const text = Labels.getStateLabel(state.i)?.text.replace(/\|/g, " ") || state.name; const stateSprite = await createTextLabel({ text, ...stateOptions }); stateSprite.position.set(x, y + stateOptions.elevation, z); diff --git a/src/services/io/auto-update.ts b/src/services/io/auto-update.ts index 24468ec1be..4c17a0810a 100644 --- a/src/services/io/auto-update.ts +++ b/src/services/io/auto-update.ts @@ -1,11 +1,13 @@ // Update an old map file to the current version import { color, min, select } from "d3"; import { defaultOptions } from "@/data/view-3d-options"; +import { Labels, STATE_LABELS_GROUP } from "@/generators/labels"; import type { Measurer, MeasurerType } from "@/generators/measurers-generator"; import type { Point } from "@/generators/voronoi"; import { drawMeasurers } from "@/renderers/draw-measurers"; import { compareVersions } from "@/services/versioning"; import { ensureEl, P, parseTransform, rand, rn, rw, unique } from "@/utils"; +import { extractPathPoints } from "@/utils/pathUtils"; export function resolveVersionConflicts(mapVersion: string, data: string[]): void { const isOlderThan = (tagVersion: string) => compareVersions(mapVersion, tagVersion).isOlder; @@ -1240,4 +1242,165 @@ export function resolveVersionConflicts(mapVersion: string, data: string[]): voi if (data[33]) pack.measurers = parse(data[33]); } + + if (isOlderThan("1.139.0")) { + // v1.139.0 moved labels data from SVG to data model + // Migrate old SVG labels to pack.labels structure + if (!pack.labels?.length) { + Labels.clear(); + + // Migrate state labels + const stateLabelsGroup = document.querySelector("#labels > #states"); + if (stateLabelsGroup) { + stateLabelsGroup.querySelectorAll("text").forEach(textElement => { + const id = textElement.getAttribute("id"); + if (!id) 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; + 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 = textElement.getAttribute("transform"); + const [dx, dy] = transform ? parseTransform(transform) : [0, 0]; + + // 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.querySelector(`#${pathId}`); + if (!pathElement) return; + + Labels.addStateLabel({ + stateId, + group: STATE_LABELS_GROUP, + text, + pathPoints: extractPathPoints(pathElement), + startOffset, + fontSize, + letterSpacing, + dx, + dy + }); + }); + } + + // 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 gdx = dxAttr ? parseFloat(dxAttr) : 0; + const gdy = dyAttr ? parseFloat(dyAttr) : 0; + + groupElement.querySelectorAll("text").forEach(textElement => { + const burgIdStr = textElement.getAttribute("data-id"); + if (!burgIdStr) return; + + const burgId = Number(burgIdStr); + const burg = pack.burgs[burgId]; + if (!burg || burg.removed) return; + + const text = textElement.textContent.trim(); + const transform = textElement.getAttribute("transform"); + const [tdx, tdy] = transform ? parseTransform(transform) : [0, 0]; + const dx = gdx + tdx; + const dy = gdy + tdy; + const x = burg.x; + const y = burg.y; + + Labels.addBurgLabel({ + burgId, + group, + text, + x, + y, + dx, + dy + }); + }); + }); + } + + // Migrate custom labels: the default addedLabels group plus any user-created groups + const customLabelGroups = document.querySelectorAll("#labels > g:not(#states):not(#burgLabels)"); + customLabelGroups.forEach(groupElement => { + const group = groupElement.id; + groupElement.querySelectorAll("text").forEach(textElement => { + const id = textElement.getAttribute("id"); + if (!id) 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; + 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 = textElement.getAttribute("transform"); + const [dx, dy] = transform ? parseTransform(transform) : [0, 0]; + + const href = textPath.getAttribute("xlink:href") || textPath.getAttribute("href"); + if (!href) return; + + const pathId = href.replace("#", ""); + const pathElement = document.querySelector(`#${pathId}`); + if (!pathElement) return; + + Labels.addCustomLabel({ + group, + text, + pathPoints: extractPathPoints(pathElement), + startOffset, + fontSize, + letterSpacing, + dx, + dy + }); + }); + }); + + // 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(); + }); + customLabelGroups.forEach(groupElement => { + groupElement.querySelectorAll("text").forEach(el => { + el.remove(); + }); + }); + + // Regenerate labels from data + if (layerIsOn("toggleLabels")) { + drawStateLabels(); + drawBurgLabels(); + drawCustomLabels(); + } + } + } } diff --git a/src/services/io/load.ts b/src/services/io/load.ts index 87ac634d48..61950b5551 100644 --- a/src/services/io/load.ts +++ b/src/services/io/load.ts @@ -1,4 +1,5 @@ import { select } from "d3"; +import { Labels } from "@/generators/labels"; import { drawMeasurers } from "@/renderers/draw-measurers"; import { Services } from "@/services"; import { cleanupData, compareVersions, isValidVersion, parseMapVersion, VERSION } from "@/services/versioning"; @@ -449,6 +450,7 @@ async function parseLoadedData(data: string[], mapVersion: string | null): Promi pack.deals = data[43] ? JSON.parse(data[43]) : []; pack.cells.market = data[44] ? Uint16Array.from(data[44].split(","), Number) : new Uint16Array(pack.cells.i.length); pack.measurers = data[46] ? JSON.parse(data[46]) : []; + Labels.load(data[47] ? JSON.parse(data[47]) : []); if (data[31]) { const namesDL = data[31].split("/"); diff --git a/src/services/io/save.ts b/src/services/io/save.ts index 339d95804f..d8dec4973f 100644 --- a/src/services/io/save.ts +++ b/src/services/io/save.ts @@ -113,6 +113,7 @@ function prepareMapData(): string { const goods = JSON.stringify(pack.goods); const markets = JSON.stringify(pack.markets || []); const deals = JSON.stringify(pack.deals || []); + const labels = JSON.stringify(pack.labels || []); // store custom good icons const goodIconsEl = ensureEl("good-icons"); @@ -181,7 +182,8 @@ function prepareMapData(): string { deals, pack.cells.market, customGoodIcons, - measurers + measurers, + labels ].join("\r\n"); return mapData; } diff --git a/src/services/versioning.ts b/src/services/versioning.ts index 0a19d32b38..1157978a51 100644 --- a/src/services/versioning.ts +++ b/src/services/versioning.ts @@ -15,7 +15,7 @@ * For the changes that may be interesting to end users, update the `latestPublicChanges` array below (new changes on top). */ -export const VERSION = "1.138.0"; +export const VERSION = "1.139.0"; const latestPublicChanges = [ "Economic simulation", diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 16e811077c..da09056d68 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -3,6 +3,7 @@ import type { Culture } from "@/generators/cultures-generator"; import type { Feature } from "@/generators/features"; import type { Good } from "@/generators/goods-generator"; import type { Ice } from "@/generators/ice-generator"; +import type { LabelData } from "@/generators/labels"; import type { Marker } from "@/generators/markers-generator"; import type { Deal, Market } from "@/generators/markets-generator"; import type { Measurer } from "@/generators/measurers-generator"; @@ -67,4 +68,5 @@ export interface PackedGraph { markets: Market[]; deals: Deal[]; measurers: Measurer[]; + labels: LabelData[]; } diff --git a/src/types/global.ts b/src/types/global.ts index 0aaffed5e6..c7383415e7 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -256,7 +256,12 @@ declare global { var drawStates: () => void; var drawBorders: () => void; var drawProvinces: () => void; + var Labels: typeof import("@/generators/labels").Labels; var drawStateLabels: (ids?: number[]) => void; + var fitStateLabels: (ids?: number[]) => void; + var drawCustomLabels: () => void; + var drawCustomLabel: (label: import("@/generators/labels").CustomLabel) => void; + var ensureLabelGroup: (group: string) => SVGGElement; var drawPopulation: () => void; var toggleCultures: () => void; diff --git a/src/utils/index.ts b/src/utils/index.ts index 77ab8d32ce..448f86c8a7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -39,7 +39,14 @@ import { shouldRegenerateGrid } from "./graphUtils"; import { destroyDialogIfExists, ensureEl, findEl, getComposedPath, getNextId, getPointer } from "./nodeUtils"; -import { connectVertices, findPath, getIsolines, getPolesOfInaccessibility, getVertexPath } from "./pathUtils"; +import { + connectVertices, + extractPathPoints, + findPath, + getIsolines, + getPolesOfInaccessibility, + getVertexPath +} from "./pathUtils"; import { biased, each, gauss, generateSeed, getNumberInRange, P, Pint, ra, rand, rw } from "./probabilityUtils"; import { capitalize, isValidJSON, parseTransform, round, safeParseJSON, sanitizeId, splitInTwo } from "./stringUtils"; import { convertTemperature, formatPrice, getHeight, getIntegerFromSI, getTemperatureLikeness, si } from "./unitUtils"; @@ -98,6 +105,7 @@ window.getPolesOfInaccessibility = getPolesOfInaccessibility; window.connectVertices = connectVertices; window.findPath = (start, end, getCost) => findPath(start, end, getCost, (window as any).pack); window.getVertexPath = cellsArray => getVertexPath(cellsArray, (window as any).pack); +window.extractPathPoints = extractPathPoints; window.round = round; window.capitalize = capitalize; diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index 9674762010..3f2319387e 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -355,6 +355,20 @@ export const findPath = ( return null; }; +// Helper: extract control points from an SVG path element +export const extractPathPoints = (pathElement: SVGPathElement) => { + if (!pathElement) return []; + const l = pathElement.getTotalLength(); + if (!l) return []; + const points: [number, number][] = []; + 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; +}; + type MeanderOptions = { anchors?: Point[]; meandering?: number; @@ -522,5 +536,6 @@ declare global { connectVertices: typeof connectVertices; findPath: typeof findPath; getVertexPath: typeof getVertexPath; + extractPathPoints: typeof extractPathPoints; } } diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts index d004d565e5..da00e82423 100644 --- a/src/utils/stringUtils.ts +++ b/src/utils/stringUtils.ts @@ -64,7 +64,14 @@ export const parseTransform = (string: string) => { .replace(/[a-z()]/g, "") .replace(/[ ]/g, ",") .split(","); - return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1]; + return [ + Number(a[0] || 0), + Number(a[1] || 0), + Number(a[2] || 0), + Number(a[3] || 0), + Number(a[4] || 0), + Number(a[5] || 1) + ]; }; /** diff --git a/tests/e2e/layers.spec.ts-snapshots/labels.html b/tests/e2e/layers.spec.ts-snapshots/labels.html index 6ffcf3b951..6a4ac614a5 100644 --- a/tests/e2e/layers.spec.ts-snapshots/labels.html +++ b/tests/e2e/layers.spec.ts-snapshots/labels.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/tests/e2e/state-labels.spec.ts b/tests/e2e/state-labels.spec.ts new file mode 100644 index 0000000000..8cde45db88 --- /dev/null +++ b/tests/e2e/state-labels.spec.ts @@ -0,0 +1,144 @@ +import {test, expect} from "@playwright/test"; + +test.describe("State labels", () => { + test.beforeEach(async ({context, page}) => { + await context.clearCookies(); + + await page.goto("/"); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + // Navigate with seed parameter and wait for full load + await page.goto("/?seed=test-state-labels&width=1280&height=720"); + + // Wait for map generation to complete + await page.waitForFunction(() => (window as any).mapId !== undefined, {timeout: 60000}); + + // Additional wait for any rendering/animations to settle + await page.waitForTimeout(500); + }); + + test("state labels are fitted and rendered from data after generation", async ({page}) => { + const result = await page.evaluate(() => { + const {pack} = window as any; + const stateLabels = pack.labels.filter((l: any) => l.type === "state"); + const validLabels = stateLabels.filter((l: any) => { + const state = pack.states[l.stateId]; + return state?.i && !state.removed; + }); + return { + stateLabelCount: validLabels.length, + fittedCount: validLabels.filter((l: any) => l.pathPoints?.length && l.text && l.fontSize).length, + domTextCount: document.querySelectorAll("g#labels > g#states > text").length, + measurementLeftover: !!document.getElementById("labelMeasurement") + }; + }); + + expect(result.stateLabelCount).toBeGreaterThan(0); + expect(result.fittedCount).toBe(result.stateLabelCount); + expect(result.domTextCount).toBe(result.stateLabelCount); + expect(result.measurementLeftover).toBe(false); + }); + + test("fitStateLabels only updates data; drawStateLabels renders it", async ({page}) => { + const afterFit = await page.evaluate(() => { + const {pack, fitStateLabels} = window as any; + const label = pack.labels.find((l: any) => l.type === "state" && pack.states[l.stateId]?.i); + const state = pack.states[label.stateId]; + state.name = "Testland"; + state.fullName = "Kingdom of Testland"; + + const textElement = document.getElementById(`pathLabel${label.i}`)!; + const domBefore = textElement.outerHTML; + const pathBefore = document.getElementById(`textPath_pathLabel${label.i}`)!.getAttribute("d"); + + fitStateLabels([label.stateId]); + + return { + labelI: label.i, + stateId: label.stateId, + dataText: label.text, + domUnchanged: document.getElementById(`pathLabel${label.i}`)!.outerHTML === domBefore, + pathUnchanged: document.getElementById(`textPath_pathLabel${label.i}`)!.getAttribute("d") === pathBefore, + measurementLeftover: !!document.getElementById("labelMeasurement") + }; + }); + + // fitting stored the new name in data but did not touch the rendered elements + expect(afterFit.dataText).toContain("Testland"); + expect(afterFit.domUnchanged).toBe(true); + expect(afterFit.pathUnchanged).toBe(true); + expect(afterFit.measurementLeftover).toBe(false); + + const afterDraw = await page.evaluate(({labelI, stateId}: {labelI: number; stateId: number}) => { + const {pack, drawStateLabels} = window as any; + drawStateLabels([stateId]); + const label = pack.labels.find((l: any) => l.i === labelI); + const textElement = document.getElementById(`pathLabel${labelI}`)!; + return { + domText: textElement.textContent, + dataText: label.text.split("|").join("") + }; + }, afterFit); + + expect(afterDraw.domText).toBe(afterDraw.dataText); + expect(afterDraw.domText).toContain("Testland"); + }); + + test("fitting works while the labels layer is hidden", async ({page}) => { + const result = await page.evaluate(() => { + const {pack, fitStateLabels} = window as any; + const label = pack.labels.filter((l: any) => l.type === "state" && pack.states[l.stateId]?.i).at(-1); + const labelsLayer = document.getElementById("labels")!; + + labelsLayer.style.display = "none"; + fitStateLabels([label.stateId]); + const hidden = {text: label.text, fontSize: label.fontSize, pathPoints: label.pathPoints}; + + labelsLayer.style.display = ""; + fitStateLabels([label.stateId]); + const visible = {text: label.text, fontSize: label.fontSize, pathPoints: label.pathPoints}; + + return { + hidden: JSON.parse(JSON.stringify(hidden)), + visible: JSON.parse(JSON.stringify(visible)), + displayRestored: labelsLayer.style.display !== "none" + }; + }); + + expect(result.hidden).toEqual(result.visible); + expect(result.hidden.fontSize).toBeGreaterThan(0); + expect(result.displayRestored).toBe(true); + }); + + test("regenerating state labels refits and redraws all labels", async ({page}) => { + const result = await page.evaluate(() => { + const {pack, Labels, drawStateLabels} = window as any; + Labels.generateStateLabels(); + const unfittedBeforeDraw = pack.labels.filter((l: any) => l.type === "state" && !l.pathPoints?.length).length; + + drawStateLabels(); + + const validLabels = pack.labels.filter((l: any) => { + if (l.type !== "state") return false; + const state = pack.states[l.stateId]; + return state?.i && !state.removed; + }); + return { + unfittedBeforeDraw, + validCount: validLabels.length, + fittedCount: validLabels.filter((l: any) => l.pathPoints?.length).length, + domTextCount: document.querySelectorAll("g#labels > g#states > text").length, + measurementLeftover: !!document.getElementById("labelMeasurement") + }; + }); + + // generateStateLabels resets fitting, drawStateLabels lazily refits everything + expect(result.unfittedBeforeDraw).toBe(result.validCount); + expect(result.fittedCount).toBe(result.validCount); + expect(result.domTextCount).toBe(result.validCount); + expect(result.measurementLeftover).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 33a5f9a6cf..cf8f727380 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ +import { fileURLToPath, URL } from "node:url"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)) + } + }, test: { root: "./src", setupFiles: ["./test-setup.ts"],