mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-25 00:27:24 +01:00
Merge fd3200739f into 0ff0311a98
This commit is contained in:
commit
be4d931540
14 changed files with 823 additions and 285 deletions
|
|
@ -650,6 +650,8 @@ async function generate(options) {
|
|||
Provinces.generate();
|
||||
Provinces.getPoles();
|
||||
|
||||
Labels.generate();
|
||||
|
||||
Rivers.specify();
|
||||
Lakes.defineNames();
|
||||
|
||||
|
|
|
|||
|
|
@ -1106,4 +1106,156 @@ export function resolveVersionConflicts(mapVersion) {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
if (isOlderThan("1.113.0")) {
|
||||
// v1.113.0 moved labels data from SVG to data model
|
||||
// Migrate old SVG labels to pack.labels structure
|
||||
if (!pack.labels || !pack.labels.length) {
|
||||
pack.labels = [];
|
||||
let labelId = 0;
|
||||
|
||||
// Migrate state labels
|
||||
const stateLabelsGroup = document.querySelector("#labels > #states");
|
||||
if (stateLabelsGroup) {
|
||||
stateLabelsGroup.querySelectorAll("text").forEach(textElement => {
|
||||
const id = textElement.getAttribute("id");
|
||||
if (!id || !id.startsWith("stateLabel")) return;
|
||||
|
||||
const stateIdMatch = id.match(/stateLabel(\d+)/);
|
||||
if (!stateIdMatch) return;
|
||||
|
||||
const stateId = +stateIdMatch[1];
|
||||
const state = pack.states[stateId];
|
||||
if (!state || state.removed) return;
|
||||
|
||||
const textPath = textElement.querySelector("textPath");
|
||||
if (!textPath) return;
|
||||
|
||||
const text = textPath.textContent.trim();
|
||||
const fontSizeAttr = textPath.getAttribute("font-size");
|
||||
const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : 100;
|
||||
|
||||
pack.labels.push({
|
||||
i: labelId++,
|
||||
type: "state",
|
||||
stateId: stateId,
|
||||
text: text,
|
||||
fontSize: fontSize
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate burg labels
|
||||
const burgLabelsGroup = document.querySelector("#burgLabels");
|
||||
if (burgLabelsGroup) {
|
||||
burgLabelsGroup.querySelectorAll("g").forEach(groupElement => {
|
||||
const group = groupElement.getAttribute("id");
|
||||
if (!group) return;
|
||||
|
||||
const dxAttr = groupElement.getAttribute("data-dx");
|
||||
const dyAttr = groupElement.getAttribute("data-dy");
|
||||
const dx = dxAttr ? parseFloat(dxAttr) : 0;
|
||||
const dy = dyAttr ? parseFloat(dyAttr) : 0;
|
||||
|
||||
groupElement.querySelectorAll("text").forEach(textElement => {
|
||||
const burgId = +textElement.getAttribute("data-id");
|
||||
if (!burgId) return;
|
||||
|
||||
const burg = pack.burgs[burgId];
|
||||
if (!burg || burg.removed) return;
|
||||
|
||||
const text = textElement.textContent.trim();
|
||||
// Use burg coordinates, not SVG text coordinates
|
||||
// SVG coordinates may be affected by viewbox transforms
|
||||
const x = burg.x;
|
||||
const y = burg.y;
|
||||
|
||||
pack.labels.push({
|
||||
i: labelId++,
|
||||
type: "burg",
|
||||
burgId: burgId,
|
||||
group: group,
|
||||
text: text,
|
||||
x: x,
|
||||
y: y,
|
||||
dx: dx,
|
||||
dy: dy
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate custom labels
|
||||
const customLabelsGroup = document.querySelector("#labels > #addedLabels");
|
||||
if (customLabelsGroup) {
|
||||
customLabelsGroup.querySelectorAll("text").forEach(textElement => {
|
||||
const id = textElement.getAttribute("id");
|
||||
if (!id) return;
|
||||
|
||||
const group = "custom";
|
||||
const textPath = textElement.querySelector("textPath");
|
||||
if (!textPath) return;
|
||||
|
||||
const text = textPath.textContent.trim();
|
||||
const fontSizeAttr = textPath.getAttribute("font-size");
|
||||
const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : 100;
|
||||
const letterSpacingAttr = textPath.getAttribute("letter-spacing");
|
||||
const letterSpacing = letterSpacingAttr ? parseFloat(letterSpacingAttr) : 0;
|
||||
const startOffsetAttr = textPath.getAttribute("startOffset");
|
||||
const startOffset = startOffsetAttr ? parseFloat(startOffsetAttr) : 50;
|
||||
const transform = textPath.getAttribute("transform");
|
||||
|
||||
// Get path points from the referenced path
|
||||
const href = textPath.getAttribute("href");
|
||||
if (!href) return;
|
||||
|
||||
const pathId = href.replace("#", "");
|
||||
const pathElement = document.getElementById(pathId);
|
||||
if (!pathElement) return;
|
||||
|
||||
const d = pathElement.getAttribute("d");
|
||||
if (!d) return;
|
||||
|
||||
// Parse path data to extract points (simplified - assumes M and L commands)
|
||||
const pathPoints = [];
|
||||
const commands = d.match(/[MLZ][^MLZ]*/g);
|
||||
if (commands) {
|
||||
commands.forEach(cmd => {
|
||||
const type = cmd[0];
|
||||
if (type === "M" || type === "L") {
|
||||
const coords = cmd.slice(1).trim().split(/[\s,]+/).map(Number);
|
||||
if (coords.length >= 2) {
|
||||
pathPoints.push([coords[0], coords[1]]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (pathPoints.length > 0) {
|
||||
pack.labels.push({
|
||||
i: labelId++,
|
||||
type: "custom",
|
||||
group: group,
|
||||
text: text,
|
||||
pathPoints: pathPoints,
|
||||
startOffset: startOffset,
|
||||
fontSize: fontSize,
|
||||
letterSpacing: letterSpacing,
|
||||
transform: transform || undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear old SVG labels and redraw from data
|
||||
if (stateLabelsGroup) stateLabelsGroup.querySelectorAll("*").forEach(el => el.remove());
|
||||
if (burgLabelsGroup) burgLabelsGroup.querySelectorAll("text").forEach(el => el.remove());
|
||||
|
||||
// Regenerate labels from data
|
||||
if (layerIsOn("toggleLabels")) {
|
||||
drawStateLabels();
|
||||
drawBurgLabels();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -407,6 +407,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
// data[28] had deprecated cells.crossroad
|
||||
pack.cells.routes = data[36] ? JSON.parse(data[36]) : {};
|
||||
pack.ice = data[39] ? JSON.parse(data[39]) : [];
|
||||
pack.labels = data[40] ? JSON.parse(data[40]) : [];
|
||||
|
||||
if (data[31]) {
|
||||
const namesDL = data[31].split("/");
|
||||
|
|
@ -473,7 +474,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
|
||||
{
|
||||
// dynamically import and run auto-update script
|
||||
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.109.4");
|
||||
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.113.0");
|
||||
resolveVersionConflicts(mapVersion);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ function prepareMapData() {
|
|||
const routes = JSON.stringify(pack.routes);
|
||||
const zones = JSON.stringify(pack.zones);
|
||||
const ice = JSON.stringify(pack.ice);
|
||||
const labels = JSON.stringify(pack.labels || []);
|
||||
|
||||
// store name array only if not the same as default
|
||||
const defaultNB = Names.getNameBases();
|
||||
|
|
@ -158,7 +159,8 @@ function prepareMapData() {
|
|||
cellRoutes,
|
||||
routes,
|
||||
zones,
|
||||
ice
|
||||
ice,
|
||||
labels
|
||||
].join("\r\n");
|
||||
return mapData;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,9 @@ function editBurg(id) {
|
|||
const id = +elSelected.attr("data-id");
|
||||
pack.burgs[id].name = burgName.value;
|
||||
elSelected.text(burgName.value);
|
||||
// Sync to Labels data model
|
||||
const labelData = Labels.getBurgLabel(id);
|
||||
if (labelData) Labels.updateLabel(labelData.i, {text: burgName.value});
|
||||
}
|
||||
|
||||
function generateNameRandom() {
|
||||
|
|
@ -382,6 +385,10 @@ function editBurg(id) {
|
|||
burg.y = y;
|
||||
if (burg.capital) pack.states[newState].center = burg.cell;
|
||||
|
||||
// Sync position to Labels data model
|
||||
const labelData = Labels.getBurgLabel(id);
|
||||
if (labelData) Labels.updateLabel(labelData.i, {x, y});
|
||||
|
||||
if (d3.event.shiftKey === false) toggleRelocateBurg();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,54 @@
|
|||
"use strict";
|
||||
|
||||
// Helper: extract control points from an SVG path element
|
||||
function extractPathPoints(pathElement) {
|
||||
if (!pathElement) return [];
|
||||
const l = pathElement.getTotalLength();
|
||||
if (!l) return [];
|
||||
const points = [];
|
||||
const increment = l / Math.max(Math.ceil(l / 200), 2);
|
||||
for (let i = 0; i <= l; i += increment) {
|
||||
const point = pathElement.getPointAtLength(i);
|
||||
points.push([point.x, point.y]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// Helper: find label data from the Labels data model for an SVG text element
|
||||
function getLabelData(textElement) {
|
||||
const id = textElement.id || "";
|
||||
if (id.startsWith("stateLabel")) {
|
||||
return Labels.getStateLabel(+id.slice(10));
|
||||
}
|
||||
// Custom labels: check for existing data-label-id attribute
|
||||
const dataLabelId = textElement.getAttribute("data-label-id");
|
||||
if (dataLabelId != null) {
|
||||
const existing = Labels.get(+dataLabelId);
|
||||
if (existing) return existing;
|
||||
// Data was cleared (e.g., map regenerated) — recreate
|
||||
textElement.removeAttribute("data-label-id");
|
||||
}
|
||||
// No data entry found — create one from SVG state (migration path)
|
||||
return createCustomLabelDataFromSvg(textElement);
|
||||
}
|
||||
|
||||
// Helper: create a CustomLabelData entry from existing SVG elements
|
||||
function createCustomLabelDataFromSvg(textElement) {
|
||||
const textPathEl = textElement.querySelector("textPath");
|
||||
if (!textPathEl) return null;
|
||||
const group = textElement.parentNode.id;
|
||||
const text = [...textPathEl.querySelectorAll("tspan")].map(t => t.textContent).join("|");
|
||||
const pathEl = byId("textPath_" + textElement.id);
|
||||
const pathPoints = extractPathPoints(pathEl);
|
||||
const startOffset = parseFloat(textPathEl.getAttribute("startOffset")) || 50;
|
||||
const fontSize = parseFloat(textPathEl.getAttribute("font-size")) || 100;
|
||||
const letterSpacing = parseFloat(textPathEl.getAttribute("letter-spacing") || "0");
|
||||
const transform = textElement.getAttribute("transform") || undefined;
|
||||
const label = Labels.addCustomLabel({ group, text, pathPoints, startOffset, fontSize, letterSpacing, transform });
|
||||
textElement.setAttribute("data-label-id", String(label.i));
|
||||
return label;
|
||||
}
|
||||
|
||||
function editLabel() {
|
||||
if (customization) return;
|
||||
closeDialogs();
|
||||
|
|
@ -14,7 +64,7 @@ function editLabel() {
|
|||
title: "Edit Label",
|
||||
resizable: false,
|
||||
width: fitContent(),
|
||||
position: {my: "center top+10", at: "bottom", of: text, collision: "fit"},
|
||||
position: { my: "center top+10", at: "bottom", of: text, collision: "fit" },
|
||||
close: closeLabelEditor
|
||||
});
|
||||
|
||||
|
|
@ -82,11 +132,21 @@ function editLabel() {
|
|||
}
|
||||
|
||||
function updateValues(textPath) {
|
||||
byId("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|");
|
||||
byId("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset"));
|
||||
byId("labelRelativeSize").value = parseFloat(textPath.getAttribute("font-size"));
|
||||
let letterSpacingSize = textPath.getAttribute("letter-spacing") ? textPath.getAttribute("letter-spacing") : 0;
|
||||
byId("labelLetterSpacingSize").value = parseFloat(letterSpacingSize);
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData && labelData.type === "custom") {
|
||||
// Custom labels: read all values from data model
|
||||
byId("labelText").value = labelData.text || "";
|
||||
byId("labelStartOffset").value = labelData.startOffset || 50;
|
||||
byId("labelRelativeSize").value = labelData.fontSize || 100;
|
||||
byId("labelLetterSpacingSize").value = labelData.letterSpacing || 0;
|
||||
} else {
|
||||
// State labels and fallback: read from SVG, use data model fontSize if available
|
||||
byId("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|");
|
||||
byId("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset")) || 50;
|
||||
byId("labelRelativeSize").value = (labelData && labelData.fontSize) || parseFloat(textPath.getAttribute("font-size")) || 100;
|
||||
let letterSpacingSize = textPath.getAttribute("letter-spacing") ? textPath.getAttribute("letter-spacing") : 0;
|
||||
byId("labelLetterSpacingSize").value = parseFloat(letterSpacingSize);
|
||||
}
|
||||
}
|
||||
|
||||
function drawControlPointsAndLine() {
|
||||
|
|
@ -128,11 +188,14 @@ function editLabel() {
|
|||
.select("#controlPoints")
|
||||
.selectAll("circle")
|
||||
.each(function () {
|
||||
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
|
||||
points.push([+this.getAttribute("cx"), +this.getAttribute("cy")]);
|
||||
});
|
||||
const d = round(lineGen(points));
|
||||
path.setAttribute("d", d);
|
||||
debug.select("#controlPoints > path").attr("d", d);
|
||||
// Sync path control points back to data model
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData) Labels.updateLabel(labelData.i, { pathPoints: points });
|
||||
}
|
||||
|
||||
function clickControlPoint() {
|
||||
|
|
@ -187,6 +250,8 @@ function editLabel() {
|
|||
const transform = `translate(${dx + x},${dy + y})`;
|
||||
elSelected.attr("transform", transform);
|
||||
debug.select("#controlPoints").attr("transform", transform);
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData) Labels.updateLabel(labelData.i, { transform });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +270,10 @@ function editLabel() {
|
|||
|
||||
function changeGroup() {
|
||||
byId(this.value).appendChild(elSelected.node());
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData && labelData.type === "custom") {
|
||||
Labels.updateLabel(labelData.i, { group: this.value });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNewGroupInput() {
|
||||
|
|
@ -243,6 +312,9 @@ function editLabel() {
|
|||
if (oldGroup !== "states" && oldGroup !== "addedLabels" && oldGroup.childElementCount === 1) {
|
||||
byId("labelGroupSelect").selectedOptions[0].remove();
|
||||
byId("labelGroupSelect").options.add(new Option(group, group, false, true));
|
||||
// Update data model for labels in the old group
|
||||
const oldGroupName = oldGroup.id;
|
||||
Labels.getByGroup(oldGroupName).forEach(l => Labels.updateLabel(l.i, { group }));
|
||||
oldGroup.id = group;
|
||||
toggleNewGroupInput();
|
||||
byId("labelGroupInput").value = "";
|
||||
|
|
@ -254,6 +326,11 @@ function editLabel() {
|
|||
newGroup.id = group;
|
||||
byId("labelGroupSelect").options.add(new Option(group, group, false, true));
|
||||
byId(group).appendChild(elSelected.node());
|
||||
// Update data model group for the moved label
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData && labelData.type === "custom") {
|
||||
Labels.updateLabel(labelData.i, { group });
|
||||
}
|
||||
|
||||
toggleNewGroupInput();
|
||||
byId("labelGroupInput").value = "";
|
||||
|
|
@ -263,9 +340,8 @@ function editLabel() {
|
|||
const group = elSelected.node().parentNode.id;
|
||||
const basic = group === "states" || group === "addedLabels";
|
||||
const count = elSelected.node().parentNode.childElementCount;
|
||||
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${
|
||||
basic ? "all elements in the group" : "the entire label group"
|
||||
}? <br /><br />Labels to be
|
||||
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${basic ? "all elements in the group" : "the entire label group"
|
||||
}? <br /><br />Labels to be
|
||||
removed: ${count}`;
|
||||
$("#alert").dialog({
|
||||
resizable: false,
|
||||
|
|
@ -275,6 +351,12 @@ function editLabel() {
|
|||
$(this).dialog("close");
|
||||
$("#labelEditor").dialog("close");
|
||||
hideGroupSection();
|
||||
// Remove from data model
|
||||
if (basic && group === "states") {
|
||||
Labels.removeByType("state");
|
||||
} else {
|
||||
Labels.removeByGroup(group);
|
||||
}
|
||||
labels
|
||||
.select("#" + group)
|
||||
.selectAll("text")
|
||||
|
|
@ -311,15 +393,19 @@ function editLabel() {
|
|||
el.innerHTML = lines.map((line, index) => `<tspan x="0" dy="${index ? 1 : top}em">${line}</tspan>`).join("");
|
||||
} else el.innerHTML = `<tspan x="0">${lines}</tspan>`;
|
||||
|
||||
// Update data model
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData) Labels.updateLabel(labelData.i, { text: input });
|
||||
|
||||
if (elSelected.attr("id").slice(0, 10) === "stateLabel")
|
||||
tip("Use States Editor to change an actual state name, not just a label", false, "warning");
|
||||
}
|
||||
|
||||
function generateRandomName() {
|
||||
let name = "";
|
||||
if (elSelected.attr("id").slice(0, 10) === "stateLabel") {
|
||||
const id = +elSelected.attr("id").slice(10);
|
||||
const culture = pack.states[id].culture;
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData && labelData.type === "state") {
|
||||
const culture = pack.states[labelData.stateId].culture;
|
||||
name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture);
|
||||
} else {
|
||||
const box = elSelected.node().getBBox();
|
||||
|
|
@ -358,17 +444,23 @@ function editLabel() {
|
|||
|
||||
function changeStartOffset() {
|
||||
elSelected.select("textPath").attr("startOffset", this.value + "%");
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData) Labels.updateLabel(labelData.i, { startOffset: +this.value });
|
||||
tip("Label offset: " + this.value + "%");
|
||||
}
|
||||
|
||||
function changeRelativeSize() {
|
||||
elSelected.select("textPath").attr("font-size", this.value + "%");
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData) Labels.updateLabel(labelData.i, { fontSize: +this.value });
|
||||
tip("Label relative size: " + this.value + "%");
|
||||
changeText();
|
||||
}
|
||||
|
||||
function changeLetterSpacingSize() {
|
||||
elSelected.select("textPath").attr("letter-spacing", this.value + "px");
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData) Labels.updateLabel(labelData.i, { letterSpacing: +this.value });
|
||||
tip("Label letter-spacing size: " + this.value + "px");
|
||||
changeText();
|
||||
}
|
||||
|
|
@ -379,6 +471,12 @@ function editLabel() {
|
|||
const path = defs.select("#textPath_" + elSelected.attr("id"));
|
||||
path.attr("d", `M${c[0] - bbox.width},${c[1]}h${bbox.width * 2}`);
|
||||
drawControlPointsAndLine();
|
||||
// Sync aligned path to data model
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData) {
|
||||
const pathEl = byId("textPath_" + elSelected.attr("id"));
|
||||
Labels.updateLabel(labelData.i, { pathPoints: extractPathPoints(pathEl) });
|
||||
}
|
||||
}
|
||||
|
||||
function editLabelLegend() {
|
||||
|
|
@ -395,6 +493,8 @@ function editLabel() {
|
|||
buttons: {
|
||||
Remove: function () {
|
||||
$(this).dialog("close");
|
||||
const labelData = getLabelData(elSelected.node());
|
||||
if (labelData) Labels.removeLabel(labelData.i);
|
||||
defs.select("#textPath_" + elSelected.attr("id")).remove();
|
||||
elSelected.remove();
|
||||
$("#labelEditor").dialog("close");
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
|
||||
*/
|
||||
|
||||
const VERSION = "1.112.4";
|
||||
const VERSION = "1.113.0";
|
||||
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8506,7 +8506,7 @@
|
|||
<script defer src="modules/ui/style-presets.js?v=1.100.00"></script>
|
||||
<script defer src="modules/ui/general.js?v=1.100.00"></script>
|
||||
<script defer src="modules/ui/options.js?v=1.106.2"></script>
|
||||
<script defer src="main.js?v=1.111.0"></script>
|
||||
<script defer src="main.js?v=1.113.0"></script>
|
||||
|
||||
<script defer src="modules/ui/style.js?v=1.108.4"></script>
|
||||
<script defer src="modules/ui/editors.js?v=1.112.1"></script>
|
||||
|
|
@ -8524,12 +8524,12 @@
|
|||
<script defer src="modules/ui/ice-editor.js?v=1.111.0"></script>
|
||||
<script defer src="modules/ui/lakes-editor.js?v=1.106.0"></script>
|
||||
<script defer src="modules/ui/coastline-editor.js?v=1.99.00"></script>
|
||||
<script defer src="modules/ui/labels-editor.js?v=1.106.0"></script>
|
||||
<script defer src="modules/ui/labels-editor.js?v=1.113.0"></script>
|
||||
<script defer src="modules/ui/rivers-editor.js?v=1.106.0"></script>
|
||||
<script defer src="modules/ui/rivers-creator.js?v=1.106.0"></script>
|
||||
<script defer src="modules/ui/relief-editor.js?v=1.99.00"></script>
|
||||
<script defer src="modules/ui/burg-group-editor.js?v=1.109.5"></script>
|
||||
<script defer src="modules/ui/burg-editor.js?v=1.106.6"></script>
|
||||
<script defer src="modules/ui/burg-editor.js?v=1.113.0"></script>
|
||||
<script defer src="modules/ui/units-editor.js?v=1.108.12"></script>
|
||||
<script defer src="modules/ui/notes-editor.js?v=1.107.3"></script>
|
||||
<script defer src="modules/ui/ai-generator.js?v=1.108.8"></script>
|
||||
|
|
@ -8551,8 +8551,8 @@
|
|||
<script defer src="modules/ui/hotkeys.js?v=1.104.0"></script>
|
||||
<script defer src="libs/rgbquant.min.js"></script>
|
||||
<script defer src="libs/jquery.ui.touch-punch.min.js"></script>
|
||||
<script defer src="modules/io/save.js?v=1.111.0"></script>
|
||||
<script defer src="modules/io/load.js?v=1.111.0"></script>
|
||||
<script defer src="modules/io/save.js?v=1.113.0"></script>
|
||||
<script defer src="modules/io/load.js?v=1.113.0"></script>
|
||||
<script defer src="modules/io/cloud.js?v=1.106.0"></script>
|
||||
<script defer src="modules/io/export.js?v=1.112.2"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import "./routes-generator";
|
|||
import "./states-generator";
|
||||
import "./zones-generator";
|
||||
import "./religions-generator";
|
||||
import "./labels";
|
||||
import "./provinces-generator";
|
||||
import "./emblem";
|
||||
import "./ice";
|
||||
|
|
|
|||
220
src/modules/labels.ts
Normal file
220
src/modules/labels.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
declare global {
|
||||
var Labels: LabelsModule;
|
||||
}
|
||||
|
||||
export interface StateLabelData {
|
||||
i: number;
|
||||
type: "state";
|
||||
stateId: number;
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export interface BurgLabelData {
|
||||
i: number;
|
||||
type: "burg";
|
||||
burgId: number;
|
||||
group: string;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
export interface CustomLabelData {
|
||||
i: number;
|
||||
type: "custom";
|
||||
group: string;
|
||||
text: string;
|
||||
pathPoints: [number, number][];
|
||||
startOffset?: number;
|
||||
fontSize?: number;
|
||||
letterSpacing?: number;
|
||||
transform?: string;
|
||||
}
|
||||
|
||||
export type LabelData = StateLabelData | BurgLabelData | CustomLabelData;
|
||||
|
||||
class LabelsModule {
|
||||
private getNextId(): number {
|
||||
const labels = pack.labels;
|
||||
if (labels.length === 0) return 0;
|
||||
|
||||
const existingIds = labels.map((l) => l.i).sort((a, b) => a - b);
|
||||
for (let id = 0; id < existingIds[existingIds.length - 1]; id++) {
|
||||
if (!existingIds.includes(id)) return id;
|
||||
}
|
||||
return existingIds[existingIds.length - 1] + 1;
|
||||
}
|
||||
|
||||
generate(): void {
|
||||
this.clear();
|
||||
this.generateStateLabels();
|
||||
this.generateBurgLabels();
|
||||
}
|
||||
|
||||
getAll(): LabelData[] {
|
||||
return pack.labels;
|
||||
}
|
||||
|
||||
get(id: number): LabelData | undefined {
|
||||
return pack.labels.find((l) => l.i === id);
|
||||
}
|
||||
|
||||
getByType(type: LabelData["type"]): LabelData[] {
|
||||
return pack.labels.filter((l) => l.type === type);
|
||||
}
|
||||
|
||||
getByGroup(group: string): LabelData[] {
|
||||
return pack.labels.filter(
|
||||
(l) => (l.type === "burg" || l.type === "custom") && l.group === group,
|
||||
);
|
||||
}
|
||||
|
||||
getStateLabel(stateId: number): StateLabelData | undefined {
|
||||
return pack.labels.find(
|
||||
(l) => l.type === "state" && l.stateId === stateId,
|
||||
) as StateLabelData | undefined;
|
||||
}
|
||||
|
||||
getBurgLabel(burgId: number): BurgLabelData | undefined {
|
||||
return pack.labels.find((l) => l.type === "burg" && l.burgId === burgId) as
|
||||
| BurgLabelData
|
||||
| undefined;
|
||||
}
|
||||
|
||||
addStateLabel(data: Omit<StateLabelData, "i" | "type">): StateLabelData {
|
||||
const label: StateLabelData = {
|
||||
i: this.getNextId(),
|
||||
type: "state",
|
||||
...data,
|
||||
};
|
||||
pack.labels.push(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
addBurgLabel(data: Omit<BurgLabelData, "i" | "type">): BurgLabelData {
|
||||
const label: BurgLabelData = { i: this.getNextId(), type: "burg", ...data };
|
||||
pack.labels.push(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
addCustomLabel(data: Omit<CustomLabelData, "i" | "type">): CustomLabelData {
|
||||
const label: CustomLabelData = {
|
||||
i: this.getNextId(),
|
||||
type: "custom",
|
||||
...data,
|
||||
};
|
||||
pack.labels.push(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
updateLabel(id: number, updates: Partial<LabelData>): void {
|
||||
const label = pack.labels.find((l) => l.i === id);
|
||||
if (!label) return;
|
||||
Object.assign(label, updates, { i: label.i, type: label.type });
|
||||
}
|
||||
|
||||
removeLabel(id: number): void {
|
||||
const index = pack.labels.findIndex((l) => l.i === id);
|
||||
if (index !== -1) pack.labels.splice(index, 1);
|
||||
}
|
||||
|
||||
removeByType(type: LabelData["type"]): void {
|
||||
pack.labels = pack.labels.filter((l) => l.type !== type);
|
||||
}
|
||||
|
||||
removeByGroup(group: string): void {
|
||||
pack.labels = pack.labels.filter(
|
||||
(l) => !((l.type === "burg" || l.type === "custom") && l.group === group),
|
||||
);
|
||||
}
|
||||
|
||||
removeStateLabel(stateId: number): void {
|
||||
const index = pack.labels.findIndex(
|
||||
(l) => l.type === "state" && l.stateId === stateId,
|
||||
);
|
||||
if (index !== -1) pack.labels.splice(index, 1);
|
||||
}
|
||||
|
||||
removeBurgLabel(burgId: number): void {
|
||||
const index = pack.labels.findIndex(
|
||||
(l) => l.type === "burg" && l.burgId === burgId,
|
||||
);
|
||||
if (index !== -1) pack.labels.splice(index, 1);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
pack.labels = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate state labels data entries for each state.
|
||||
* Only stores essential label data; raycast path calculation happens during rendering.
|
||||
* @param list - Optional array of stateIds to regenerate only those
|
||||
*/
|
||||
generateStateLabels(list?: number[]): void {
|
||||
if (TIME) console.time("generateStateLabels");
|
||||
|
||||
const { states } = pack;
|
||||
|
||||
// Remove existing state labels that need regeneration
|
||||
if (list) {
|
||||
list.forEach((stateId) => this.removeStateLabel(stateId));
|
||||
} else {
|
||||
this.removeByType("state");
|
||||
}
|
||||
|
||||
// Generate new label entries
|
||||
for (const state of states) {
|
||||
if (!state.i || state.removed || state.lock) continue;
|
||||
if (list && !list.includes(state.i)) continue;
|
||||
|
||||
this.addStateLabel({
|
||||
stateId: state.i,
|
||||
text: state.name!,
|
||||
fontSize: 100,
|
||||
});
|
||||
}
|
||||
|
||||
if (TIME) console.timeEnd("generateStateLabels");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate burg labels data from burgs.
|
||||
* Populates pack.labels with BurgLabelData for each burg.
|
||||
*/
|
||||
generateBurgLabels(): void {
|
||||
if (TIME) console.time("generateBurgLabels");
|
||||
|
||||
// Remove existing burg labels
|
||||
this.removeByType("burg");
|
||||
|
||||
// Generate new labels for all active burgs
|
||||
for (const burg of pack.burgs) {
|
||||
if (!burg.i || burg.removed) continue;
|
||||
|
||||
const group = burg.group || "unmarked";
|
||||
|
||||
// Get label group offset attributes if they exist (will be set during rendering)
|
||||
// For now, use defaults - these will be updated during rendering phase
|
||||
const dx = 0;
|
||||
const dy = 0;
|
||||
|
||||
this.addBurgLabel({
|
||||
burgId: burg.i,
|
||||
group,
|
||||
text: burg.name!,
|
||||
x: burg.x,
|
||||
y: burg.y,
|
||||
dx,
|
||||
dy,
|
||||
});
|
||||
}
|
||||
|
||||
if (TIME) console.timeEnd("generateBurgLabels");
|
||||
}
|
||||
}
|
||||
|
||||
window.Labels = new LabelsModule();
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Burg } from "../modules/burgs-generator";
|
||||
import type { BurgLabelData } from "../modules/labels";
|
||||
|
||||
declare global {
|
||||
var drawBurgLabels: () => void;
|
||||
|
|
@ -15,31 +16,43 @@ const burgLabelsRenderer = (): void => {
|
|||
TIME && console.time("drawBurgLabels");
|
||||
createLabelGroups();
|
||||
|
||||
for (const { name } of options.burgs.groups as BurgGroup[]) {
|
||||
const burgsInGroup = pack.burgs.filter(
|
||||
(b) => b.group === name && !b.removed,
|
||||
);
|
||||
if (!burgsInGroup.length) continue;
|
||||
// Get all burg labels grouped by group name
|
||||
const burgLabelsByGroup = new Map<string, BurgLabelData[]>();
|
||||
for (const label of Labels.getByType("burg").map((l) => l as BurgLabelData)) {
|
||||
if (!burgLabelsByGroup.has(label.group)) {
|
||||
burgLabelsByGroup.set(label.group, []);
|
||||
}
|
||||
burgLabelsByGroup.get(label.group)!.push(label);
|
||||
}
|
||||
|
||||
const labelGroup = burgLabels.select<SVGGElement>(`#${name}`);
|
||||
// Render each group and update label offsets from SVG attributes
|
||||
for (const [groupName, labels] of burgLabelsByGroup) {
|
||||
const labelGroup = burgLabels.select<SVGGElement>(`#${groupName}`);
|
||||
if (labelGroup.empty()) continue;
|
||||
|
||||
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;
|
||||
|
||||
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!);
|
||||
// Build HTML string for all labels in this group
|
||||
const labelsHTML: string[] = [];
|
||||
for (const labelData of labels) {
|
||||
// Update label data with SVG group offsets
|
||||
if (labelData.dx !== dx || labelData.dy !== dy) {
|
||||
Labels.updateLabel(labelData.i, { dx, dy });
|
||||
}
|
||||
|
||||
labelsHTML.push(
|
||||
`<text text-rendering="optimizeSpeed" id="burgLabel${labelData.burgId}" data-id="${labelData.burgId}" x="${labelData.x}" y="${labelData.y}" dx="${dx}em" dy="${dy}em">${labelData.text}</text>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Set all labels at once
|
||||
const groupNode = labelGroup.node();
|
||||
if (groupNode) {
|
||||
groupNode.innerHTML = labelsHTML.join("");
|
||||
}
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("drawBurgLabels");
|
||||
|
|
@ -48,14 +61,40 @@ const burgLabelsRenderer = (): void => {
|
|||
const drawBurgLabelRenderer = (burg: Burg): void => {
|
||||
const labelGroup = burgLabels.select<SVGGElement>(`#${burg.group}`);
|
||||
if (labelGroup.empty()) {
|
||||
drawBurgLabels();
|
||||
burgLabelsRenderer();
|
||||
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!);
|
||||
|
||||
// Add/update label in data layer
|
||||
const existingLabel = Labels.getBurgLabel(burg.i!);
|
||||
if (existingLabel) {
|
||||
Labels.updateLabel(existingLabel.i, {
|
||||
text: burg.name!,
|
||||
x: burg.x,
|
||||
y: burg.y,
|
||||
dx,
|
||||
dy,
|
||||
});
|
||||
} else {
|
||||
Labels.addBurgLabel({
|
||||
burgId: burg.i!,
|
||||
group: burg.group || "unmarked",
|
||||
text: burg.name!,
|
||||
x: burg.x,
|
||||
y: burg.y,
|
||||
dx,
|
||||
dy,
|
||||
});
|
||||
}
|
||||
|
||||
// Render to SVG
|
||||
labelGroup
|
||||
.append("text")
|
||||
.attr("text-rendering", "optimizeSpeed")
|
||||
|
|
@ -71,6 +110,7 @@ const drawBurgLabelRenderer = (burg: Burg): void => {
|
|||
const removeBurgLabelRenderer = (burgId: number): void => {
|
||||
const existingLabel = document.getElementById(`burgLabel${burgId}`);
|
||||
if (existingLabel) existingLabel.remove();
|
||||
Labels.removeBurgLabel(burgId);
|
||||
};
|
||||
|
||||
function createLabelGroups(): void {
|
||||
|
|
|
|||
|
|
@ -1,34 +1,40 @@
|
|||
import { curveNatural, line, max, select } from "d3";
|
||||
import {
|
||||
drawPath,
|
||||
drawPoint,
|
||||
findClosestCell,
|
||||
minmax,
|
||||
rn,
|
||||
round,
|
||||
splitInTwo,
|
||||
} from "../utils";
|
||||
import type { StateLabelData } from "../modules/labels";
|
||||
import { findClosestCell, minmax, rn, round, splitInTwo } from "../utils";
|
||||
import { ANGLES, findBestRayPair, raycast } from "./label-raycast";
|
||||
|
||||
declare global {
|
||||
var drawStateLabels: (list?: number[]) => void;
|
||||
}
|
||||
|
||||
interface Ray {
|
||||
angle: number;
|
||||
length: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/**
|
||||
* Helper function to calculate offset width for raycast based on state size
|
||||
*/
|
||||
function getOffsetWidth(cellsNumber: number): number {
|
||||
if (cellsNumber < 40) return 0;
|
||||
if (cellsNumber < 200) return 5;
|
||||
return 10;
|
||||
}
|
||||
|
||||
interface AngleData {
|
||||
angle: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
function checkExampleLetterLength(): number {
|
||||
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
|
||||
const testLabel = textGroup
|
||||
.append("text")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.text("Example");
|
||||
const letterLength =
|
||||
(testLabel.node() as SVGTextElement).getComputedTextLength() / 7; // approximate length of 1 letter
|
||||
testLabel.remove();
|
||||
|
||||
return letterLength;
|
||||
}
|
||||
|
||||
type PathPoints = [number, number][];
|
||||
|
||||
// list - an optional array of stateIds to regenerate
|
||||
/**
|
||||
* Render state labels from pack.labels data to SVG.
|
||||
* Adjusts and fits labels based on layout constraints.
|
||||
* list - optional array of stateIds to re-render
|
||||
*/
|
||||
const stateLabelsRenderer = (list?: number[]): void => {
|
||||
TIME && console.time("drawStateLabels");
|
||||
|
||||
|
|
@ -36,37 +42,46 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
|||
const layerDisplay = labels.style("display");
|
||||
labels.style("display", null);
|
||||
|
||||
const { cells, states, features } = pack;
|
||||
const stateIds = cells.state;
|
||||
const { states } = pack;
|
||||
|
||||
// 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);
|
||||
// Get labels to render
|
||||
const labelsToRender = list
|
||||
? Labels.getAll()
|
||||
.filter(
|
||||
(l) =>
|
||||
l.type === "state" && list.includes((l as StateLabelData).stateId),
|
||||
)
|
||||
.map((l) => l as StateLabelData)
|
||||
: Labels.getByType("state").map((l) => l as StateLabelData);
|
||||
|
||||
const LENGTH_START = 5;
|
||||
const LENGTH_STEP = 5;
|
||||
const LENGTH_MAX = 300;
|
||||
|
||||
const labelPaths = getLabelPaths();
|
||||
const letterLength = checkExampleLetterLength();
|
||||
drawLabelPath(letterLength);
|
||||
drawLabelPath(letterLength, labelsToRender);
|
||||
|
||||
// restore labels visibility
|
||||
labels.style("display", layerDisplay);
|
||||
|
||||
function getLabelPaths(): [number, PathPoints][] {
|
||||
const labelPaths: [number, PathPoints][] = [];
|
||||
function drawLabelPath(
|
||||
letterLength: number,
|
||||
labelDataList: StateLabelData[],
|
||||
): void {
|
||||
const mode = options.stateLabelsMode || "auto";
|
||||
const lineGen = line<[number, number]>().curve(curveNatural);
|
||||
|
||||
for (const state of states) {
|
||||
if (!state.i || state.removed || state.lock) continue;
|
||||
if (list && !list.includes(state.i)) continue;
|
||||
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
|
||||
const pathGroup = select<SVGGElement, unknown>(
|
||||
"defs > g#deftemp > g#textPaths",
|
||||
);
|
||||
|
||||
for (const labelData of labelDataList) {
|
||||
const state = states[labelData.stateId];
|
||||
if (!state.i || state.removed) continue;
|
||||
|
||||
// Calculate pathPoints using raycast algorithm (recalculated on each draw)
|
||||
const offset = getOffsetWidth(state.cells!);
|
||||
const maxLakeSize = state.cells! / 20;
|
||||
const [x0, y0] = state.pole!;
|
||||
|
||||
const rays: Ray[] = angles.map(({ angle, dx, dy }) => {
|
||||
const rays = ANGLES.map(({ angle, dx, dy }) => {
|
||||
const { length, x, y } = raycast({
|
||||
stateId: state.i,
|
||||
x0,
|
||||
|
|
@ -80,61 +95,20 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
|||
});
|
||||
const [ray1, ray2] = findBestRayPair(rays);
|
||||
|
||||
const pathPoints: PathPoints = [
|
||||
const pathPoints: [number, number][] = [
|
||||
[ray1.x, ray1.y],
|
||||
state.pole!,
|
||||
[ray2.x, ray2.y],
|
||||
];
|
||||
if (ray1.x > ray2.x) pathPoints.reverse();
|
||||
|
||||
if (DEBUG.stateLabels) {
|
||||
drawPoint(state.pole!, { color: "black", radius: 1 });
|
||||
drawPath(pathPoints, { color: "black", width: 0.2 });
|
||||
}
|
||||
|
||||
labelPaths.push([state.i, pathPoints]);
|
||||
}
|
||||
|
||||
return labelPaths;
|
||||
}
|
||||
|
||||
function checkExampleLetterLength(): number {
|
||||
const textGroup = select<SVGGElement, unknown>("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<SVGGElement, unknown>("g#labels > g#states");
|
||||
const pathGroup = select<SVGGElement, unknown>(
|
||||
"defs > g#deftemp > g#textPaths",
|
||||
);
|
||||
|
||||
for (const [stateId, pathPoints] of labelPaths) {
|
||||
const state = states[stateId];
|
||||
if (!state.i || state.removed)
|
||||
throw new Error("State must not be neutral or removed");
|
||||
if (pathPoints.length < 2)
|
||||
throw new Error("Label path must have at least 2 points");
|
||||
|
||||
textGroup.select(`#stateLabel${stateId}`).remove();
|
||||
pathGroup.select(`#textPath_stateLabel${stateId}`).remove();
|
||||
textGroup.select(`#stateLabel${labelData.stateId}`).remove();
|
||||
pathGroup.select(`#textPath_stateLabel${labelData.stateId}`).remove();
|
||||
|
||||
const textPath = pathGroup
|
||||
.append("path")
|
||||
.attr("d", round(lineGen(pathPoints) || ""))
|
||||
.attr("id", `textPath_stateLabel${stateId}`);
|
||||
.attr("id", `textPath_stateLabel${labelData.stateId}`);
|
||||
|
||||
const pathLength =
|
||||
(textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters
|
||||
|
|
@ -145,6 +119,9 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
|||
pathLength,
|
||||
);
|
||||
|
||||
// Update label data with font size
|
||||
Labels.updateLabel(labelData.i, { fontSize: ratio });
|
||||
|
||||
// prolongate path if it's too short
|
||||
const longestLineLength = max(lines.map((line) => line.length)) || 0;
|
||||
if (pathLength && pathLength < longestLineLength) {
|
||||
|
|
@ -165,7 +142,7 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
|||
const textElement = textGroup
|
||||
.append("text")
|
||||
.attr("text-rendering", "optimizeSpeed")
|
||||
.attr("id", `stateLabel${stateId}`)
|
||||
.attr("id", `stateLabel${labelData.stateId}`)
|
||||
.append("textPath")
|
||||
.attr("startOffset", "50%")
|
||||
.attr("font-size", `${ratio}%`)
|
||||
|
|
@ -179,8 +156,12 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
|||
textElement.insertAdjacentHTML("afterbegin", spans.join(""));
|
||||
|
||||
const { width, height } = textElement.getBBox();
|
||||
textElement.setAttribute("href", `#textPath_stateLabel${stateId}`);
|
||||
textElement.setAttribute(
|
||||
"href",
|
||||
`#textPath_stateLabel${labelData.stateId}`,
|
||||
);
|
||||
|
||||
const stateIds = pack.cells.state;
|
||||
if (mode === "full" || lines.length === 1) continue;
|
||||
|
||||
// check if label fits state boundaries. If no, replace it with short name
|
||||
|
|
@ -193,7 +174,7 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
|||
width / 2,
|
||||
height / 2,
|
||||
stateIds,
|
||||
stateId,
|
||||
labelData.stateId,
|
||||
);
|
||||
if (isInsideState) continue;
|
||||
|
||||
|
|
@ -203,6 +184,7 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
|||
? state.fullName!
|
||||
: state.name!;
|
||||
textElement.innerHTML = `<tspan x="0">${text}</tspan>`;
|
||||
Labels.updateLabel(labelData.i, { text });
|
||||
|
||||
const correctedRatio = minmax(
|
||||
rn((pathLength / text.length) * 50),
|
||||
|
|
@ -210,162 +192,10 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
|||
130,
|
||||
);
|
||||
textElement.setAttribute("font-size", `${correctedRatio}%`);
|
||||
Labels.updateLabel(labelData.i, { fontSize: correctedRatio });
|
||||
}
|
||||
}
|
||||
|
||||
function getOffsetWidth(cellsNumber: number): number {
|
||||
if (cellsNumber < 40) return 0;
|
||||
if (cellsNumber < 200) return 5;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function precalculateAngles(step: number): AngleData[] {
|
||||
const angles: AngleData[] = [];
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
for (let angle = 0; angle < 360; angle += step) {
|
||||
const dx = Math.cos(angle * RAD);
|
||||
const dy = Math.sin(angle * RAD);
|
||||
angles.push({ angle, dx, dy });
|
||||
}
|
||||
|
||||
return angles;
|
||||
}
|
||||
|
||||
function raycast({
|
||||
stateId,
|
||||
x0,
|
||||
y0,
|
||||
dx,
|
||||
dy,
|
||||
maxLakeSize,
|
||||
offset,
|
||||
}: {
|
||||
stateId: number;
|
||||
x0: number;
|
||||
y0: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
maxLakeSize: number;
|
||||
offset: number;
|
||||
}): { length: number; x: number; y: number } {
|
||||
let ray = { length: 0, x: x0, y: y0 };
|
||||
|
||||
for (
|
||||
let length = LENGTH_START;
|
||||
length < LENGTH_MAX;
|
||||
length += LENGTH_STEP
|
||||
) {
|
||||
const [x, y] = [x0 + length * dx, y0 + length * dy];
|
||||
// offset points are perpendicular to the ray
|
||||
const offset1: [number, number] = [x + -dy * offset, y + dx * offset];
|
||||
const offset2: [number, number] = [x + dy * offset, y + -dx * offset];
|
||||
|
||||
if (DEBUG.stateLabels) {
|
||||
drawPoint([x, y], {
|
||||
color: isInsideState(x, y) ? "blue" : "red",
|
||||
radius: 0.8,
|
||||
});
|
||||
drawPoint(offset1, {
|
||||
color: isInsideState(...offset1) ? "blue" : "red",
|
||||
radius: 0.4,
|
||||
});
|
||||
drawPoint(offset2, {
|
||||
color: isInsideState(...offset2) ? "blue" : "red",
|
||||
radius: 0.4,
|
||||
});
|
||||
}
|
||||
|
||||
const inState =
|
||||
isInsideState(x, y) &&
|
||||
isInsideState(...offset1) &&
|
||||
isInsideState(...offset2);
|
||||
if (!inState) break;
|
||||
ray = { length, x, y };
|
||||
}
|
||||
|
||||
return ray;
|
||||
|
||||
function isInsideState(x: number, y: number): boolean {
|
||||
if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false;
|
||||
const cellId = findClosestCell(x, y, undefined, pack) as number;
|
||||
|
||||
const feature = features[cells.f[cellId]];
|
||||
if (feature.type === "lake")
|
||||
return isInnerLake(feature) || isSmallLake(feature);
|
||||
|
||||
return stateIds[cellId] === stateId;
|
||||
}
|
||||
|
||||
function isInnerLake(feature: { shoreline: number[] }): boolean {
|
||||
return feature.shoreline.every((cellId) => stateIds[cellId] === stateId);
|
||||
}
|
||||
|
||||
function isSmallLake(feature: { cells: number }): boolean {
|
||||
return feature.cells <= maxLakeSize;
|
||||
}
|
||||
}
|
||||
|
||||
function findBestRayPair(rays: Ray[]): [Ray, Ray] {
|
||||
let bestPair: [Ray, Ray] | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const score1 = rays[i].length * scoreRayAngle(rays[i].angle);
|
||||
|
||||
for (let j = i + 1; j < rays.length; j++) {
|
||||
const score2 = rays[j].length * scoreRayAngle(rays[j].angle);
|
||||
const pairScore =
|
||||
(score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle);
|
||||
|
||||
if (pairScore > bestScore) {
|
||||
bestScore = pairScore;
|
||||
bestPair = [rays[i], rays[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestPair!;
|
||||
}
|
||||
|
||||
function scoreRayAngle(angle: number): number {
|
||||
const normalizedAngle = Math.abs(angle % 180); // [0, 180]
|
||||
const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1]
|
||||
|
||||
if (horizontality === 1) return 1; // Best: horizontal
|
||||
if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted
|
||||
if (horizontality >= 0.5) return 0.6; // Good: moderate slant
|
||||
if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted
|
||||
if (horizontality >= 0.15) return 0.2; // Poor: almost vertical
|
||||
return 0.1; // Very poor: almost vertical
|
||||
}
|
||||
|
||||
function scoreCurvature(angle1: number, angle2: number): number {
|
||||
const delta = getAngleDelta(angle1, angle2);
|
||||
const similarity = evaluateArc(angle1, angle2);
|
||||
|
||||
if (delta === 180) return 1; // straight line: best
|
||||
if (delta < 90) return 0; // acute: not allowed
|
||||
if (delta < 120) return 0.6 * similarity;
|
||||
if (delta < 140) return 0.7 * similarity;
|
||||
if (delta < 160) return 0.8 * similarity;
|
||||
|
||||
return similarity;
|
||||
}
|
||||
|
||||
function getAngleDelta(angle1: number, angle2: number): number {
|
||||
let delta = Math.abs(angle1 - angle2) % 360;
|
||||
if (delta > 180) delta = 360 - delta; // [0, 180]
|
||||
return delta;
|
||||
}
|
||||
|
||||
// compute arc similarity towards x-axis
|
||||
function evaluateArc(angle1: number, angle2: number): number {
|
||||
const proximity1 = Math.abs((angle1 % 180) - 90);
|
||||
const proximity2 = Math.abs((angle2 % 180) - 90);
|
||||
return 1 - Math.abs(proximity1 - proximity2) / 90;
|
||||
}
|
||||
|
||||
function getLinesAndRatio(
|
||||
mode: string,
|
||||
name: string,
|
||||
|
|
|
|||
181
src/renderers/label-raycast.ts
Normal file
181
src/renderers/label-raycast.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { findClosestCell } from "../utils/graphUtils";
|
||||
|
||||
export interface Ray {
|
||||
angle: number;
|
||||
length: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface AngleData {
|
||||
angle: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
interface RaycastParams {
|
||||
stateId: number;
|
||||
x0: number;
|
||||
y0: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
maxLakeSize: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// increase step to 15 or 30 to make it faster and more horizontal
|
||||
// decrease step to 5 to improve accuracy
|
||||
const ANGLE_STEP = 9;
|
||||
export const ANGLES = precalculateAngles(ANGLE_STEP);
|
||||
|
||||
const LENGTH_START = 5;
|
||||
const LENGTH_STEP = 5;
|
||||
const LENGTH_MAX = 300;
|
||||
|
||||
/**
|
||||
* Cast a ray from a point in a given direction until it exits a state.
|
||||
* Checks both the ray point and offset points perpendicular to it.
|
||||
*/
|
||||
export function raycast({
|
||||
stateId,
|
||||
x0,
|
||||
y0,
|
||||
dx,
|
||||
dy,
|
||||
maxLakeSize,
|
||||
offset,
|
||||
}: RaycastParams): { length: number; x: number; y: number } {
|
||||
const { cells, features } = pack;
|
||||
const stateIds = cells.state;
|
||||
let ray = { length: 0, x: x0, y: y0 };
|
||||
|
||||
for (let length = LENGTH_START; length < LENGTH_MAX; length += LENGTH_STEP) {
|
||||
const [x, y] = [x0 + length * dx, y0 + length * dy];
|
||||
// offset points are perpendicular to the ray
|
||||
const offset1: [number, number] = [x + -dy * offset, y + dx * offset];
|
||||
const offset2: [number, number] = [x + dy * offset, y + -dx * offset];
|
||||
|
||||
const inState =
|
||||
isInsideState(x, y, stateId) &&
|
||||
isInsideState(...offset1, stateId) &&
|
||||
isInsideState(...offset2, stateId);
|
||||
if (!inState) break;
|
||||
ray = { length, x, y };
|
||||
}
|
||||
|
||||
return ray;
|
||||
|
||||
function isInsideState(x: number, y: number, stateId: number): boolean {
|
||||
if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false;
|
||||
const cellId = findClosestCell(x, y, undefined, pack) as number;
|
||||
|
||||
const feature = features[cells.f[cellId]];
|
||||
if (feature.type === "lake")
|
||||
return isInnerLake(feature) || isSmallLake(feature);
|
||||
|
||||
return stateIds[cellId] === stateId;
|
||||
}
|
||||
|
||||
function isInnerLake(feature: { shoreline: number[] }): boolean {
|
||||
return feature.shoreline.every((cellId) => stateIds[cellId] === stateId);
|
||||
}
|
||||
|
||||
function isSmallLake(feature: { cells: number }): boolean {
|
||||
return feature.cells <= maxLakeSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a ray angle based on how horizontal it is.
|
||||
* Horizontal rays (0° or 180°) are preferred for label placement.
|
||||
*/
|
||||
function scoreRayAngle(angle: number): number {
|
||||
const normalizedAngle = Math.abs(angle % 180); // [0, 180]
|
||||
const horizontality = Math.abs(normalizedAngle - 90) / 90; // [0, 1]
|
||||
|
||||
if (horizontality === 1) return 1; // Best: horizontal
|
||||
if (horizontality >= 0.75) return 0.9; // Very good: slightly slanted
|
||||
if (horizontality >= 0.5) return 0.6; // Good: moderate slant
|
||||
if (horizontality >= 0.25) return 0.5; // Acceptable: more slanted
|
||||
if (horizontality >= 0.15) return 0.2; // Poor: almost vertical
|
||||
return 0.1; // Very poor: almost vertical
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the angle delta between two angles (0-180 degrees).
|
||||
*/
|
||||
function getAngleDelta(angle1: number, angle2: number): number {
|
||||
let delta = Math.abs(angle1 - angle2) % 360;
|
||||
if (delta > 180) delta = 360 - delta; // [0, 180]
|
||||
return delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate how similar the arc between two angles is.
|
||||
* Computes proximity of both angles towards the x-axis.
|
||||
*/
|
||||
function evaluateArc(angle1: number, angle2: number): number {
|
||||
const proximity1 = Math.abs((angle1 % 180) - 90);
|
||||
const proximity2 = Math.abs((angle2 % 180) - 90);
|
||||
return 1 - Math.abs(proximity1 - proximity2) / 90;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a ray pair based on the delta angle between them and their arc similarity.
|
||||
* Penalizes acute angles (<90°), favors straight lines (180°).
|
||||
*/
|
||||
function scoreCurvature(angle1: number, angle2: number): number {
|
||||
const delta = getAngleDelta(angle1, angle2);
|
||||
const similarity = evaluateArc(angle1, angle2);
|
||||
|
||||
if (delta === 180) return 1; // straight line: best
|
||||
if (delta < 90) return 0; // acute: not allowed
|
||||
if (delta < 120) return 0.6 * similarity;
|
||||
if (delta < 140) return 0.7 * similarity;
|
||||
if (delta < 160) return 0.8 * similarity;
|
||||
|
||||
return similarity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precompute angles and their vector components for raycast directions.
|
||||
* Used to sample rays around a point at regular angular intervals.
|
||||
*/
|
||||
function precalculateAngles(step: number): AngleData[] {
|
||||
const angles: AngleData[] = [];
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
for (let angle = 0; angle < 360; angle += step) {
|
||||
const dx = Math.cos(angle * RAD);
|
||||
const dy = Math.sin(angle * RAD);
|
||||
angles.push({ angle, dx, dy });
|
||||
}
|
||||
|
||||
return angles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best pair of rays for label placement along a curved path.
|
||||
* Prefers horizontal rays and well-separated angles.
|
||||
*/
|
||||
export function findBestRayPair(rays: Ray[]): [Ray, Ray] {
|
||||
let bestPair: [Ray, Ray] | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const score1 = rays[i].length * scoreRayAngle(rays[i].angle);
|
||||
|
||||
for (let j = i + 1; j < rays.length; j++) {
|
||||
const score2 = rays[j].length * scoreRayAngle(rays[j].angle);
|
||||
const pairScore =
|
||||
(score1 + score2) * scoreCurvature(rays[i].angle, rays[j].angle);
|
||||
|
||||
if (pairScore > bestScore) {
|
||||
bestScore = pairScore;
|
||||
bestPair = [rays[i], rays[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestPair!;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Burg } from "../modules/burgs-generator";
|
||||
import type { Culture } from "../modules/cultures-generator";
|
||||
import type { PackedGraphFeature } from "../modules/features";
|
||||
import type { LabelData } from "../modules/labels";
|
||||
import type { Province } from "../modules/provinces-generator";
|
||||
import type { River } from "../modules/river-generator";
|
||||
import type { Route } from "../modules/routes-generator";
|
||||
|
|
@ -62,5 +63,6 @@ export interface PackedGraph {
|
|||
zones: Zone[];
|
||||
markers: any[];
|
||||
ice: any[];
|
||||
labels: LabelData[];
|
||||
provinces: Province[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue