mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-25 16:47: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.generate();
|
||||||
Provinces.getPoles();
|
Provinces.getPoles();
|
||||||
|
|
||||||
|
Labels.generate();
|
||||||
|
|
||||||
Rivers.specify();
|
Rivers.specify();
|
||||||
Lakes.defineNames();
|
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
|
// data[28] had deprecated cells.crossroad
|
||||||
pack.cells.routes = data[36] ? JSON.parse(data[36]) : {};
|
pack.cells.routes = data[36] ? JSON.parse(data[36]) : {};
|
||||||
pack.ice = data[39] ? JSON.parse(data[39]) : [];
|
pack.ice = data[39] ? JSON.parse(data[39]) : [];
|
||||||
|
pack.labels = data[40] ? JSON.parse(data[40]) : [];
|
||||||
|
|
||||||
if (data[31]) {
|
if (data[31]) {
|
||||||
const namesDL = data[31].split("/");
|
const namesDL = data[31].split("/");
|
||||||
|
|
@ -473,7 +474,7 @@ async function parseLoadedData(data, mapVersion) {
|
||||||
|
|
||||||
{
|
{
|
||||||
// dynamically import and run auto-update script
|
// 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);
|
resolveVersionConflicts(mapVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ function prepareMapData() {
|
||||||
const routes = JSON.stringify(pack.routes);
|
const routes = JSON.stringify(pack.routes);
|
||||||
const zones = JSON.stringify(pack.zones);
|
const zones = JSON.stringify(pack.zones);
|
||||||
const ice = JSON.stringify(pack.ice);
|
const ice = JSON.stringify(pack.ice);
|
||||||
|
const labels = JSON.stringify(pack.labels || []);
|
||||||
|
|
||||||
// store name array only if not the same as default
|
// store name array only if not the same as default
|
||||||
const defaultNB = Names.getNameBases();
|
const defaultNB = Names.getNameBases();
|
||||||
|
|
@ -158,7 +159,8 @@ function prepareMapData() {
|
||||||
cellRoutes,
|
cellRoutes,
|
||||||
routes,
|
routes,
|
||||||
zones,
|
zones,
|
||||||
ice
|
ice,
|
||||||
|
labels
|
||||||
].join("\r\n");
|
].join("\r\n");
|
||||||
return mapData;
|
return mapData;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,9 @@ function editBurg(id) {
|
||||||
const id = +elSelected.attr("data-id");
|
const id = +elSelected.attr("data-id");
|
||||||
pack.burgs[id].name = burgName.value;
|
pack.burgs[id].name = burgName.value;
|
||||||
elSelected.text(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() {
|
function generateNameRandom() {
|
||||||
|
|
@ -382,6 +385,10 @@ function editBurg(id) {
|
||||||
burg.y = y;
|
burg.y = y;
|
||||||
if (burg.capital) pack.states[newState].center = burg.cell;
|
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();
|
if (d3.event.shiftKey === false) toggleRelocateBurg();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,54 @@
|
||||||
"use strict";
|
"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() {
|
function editLabel() {
|
||||||
if (customization) return;
|
if (customization) return;
|
||||||
closeDialogs();
|
closeDialogs();
|
||||||
|
|
@ -14,7 +64,7 @@ function editLabel() {
|
||||||
title: "Edit Label",
|
title: "Edit Label",
|
||||||
resizable: false,
|
resizable: false,
|
||||||
width: fitContent(),
|
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
|
close: closeLabelEditor
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -82,11 +132,21 @@ function editLabel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateValues(textPath) {
|
function updateValues(textPath) {
|
||||||
byId("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|");
|
const labelData = getLabelData(elSelected.node());
|
||||||
byId("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset"));
|
if (labelData && labelData.type === "custom") {
|
||||||
byId("labelRelativeSize").value = parseFloat(textPath.getAttribute("font-size"));
|
// Custom labels: read all values from data model
|
||||||
let letterSpacingSize = textPath.getAttribute("letter-spacing") ? textPath.getAttribute("letter-spacing") : 0;
|
byId("labelText").value = labelData.text || "";
|
||||||
byId("labelLetterSpacingSize").value = parseFloat(letterSpacingSize);
|
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() {
|
function drawControlPointsAndLine() {
|
||||||
|
|
@ -128,11 +188,14 @@ function editLabel() {
|
||||||
.select("#controlPoints")
|
.select("#controlPoints")
|
||||||
.selectAll("circle")
|
.selectAll("circle")
|
||||||
.each(function () {
|
.each(function () {
|
||||||
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
|
points.push([+this.getAttribute("cx"), +this.getAttribute("cy")]);
|
||||||
});
|
});
|
||||||
const d = round(lineGen(points));
|
const d = round(lineGen(points));
|
||||||
path.setAttribute("d", d);
|
path.setAttribute("d", d);
|
||||||
debug.select("#controlPoints > path").attr("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() {
|
function clickControlPoint() {
|
||||||
|
|
@ -187,6 +250,8 @@ function editLabel() {
|
||||||
const transform = `translate(${dx + x},${dy + y})`;
|
const transform = `translate(${dx + x},${dy + y})`;
|
||||||
elSelected.attr("transform", transform);
|
elSelected.attr("transform", transform);
|
||||||
debug.select("#controlPoints").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() {
|
function changeGroup() {
|
||||||
byId(this.value).appendChild(elSelected.node());
|
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() {
|
function toggleNewGroupInput() {
|
||||||
|
|
@ -243,6 +312,9 @@ function editLabel() {
|
||||||
if (oldGroup !== "states" && oldGroup !== "addedLabels" && oldGroup.childElementCount === 1) {
|
if (oldGroup !== "states" && oldGroup !== "addedLabels" && oldGroup.childElementCount === 1) {
|
||||||
byId("labelGroupSelect").selectedOptions[0].remove();
|
byId("labelGroupSelect").selectedOptions[0].remove();
|
||||||
byId("labelGroupSelect").options.add(new Option(group, group, false, true));
|
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;
|
oldGroup.id = group;
|
||||||
toggleNewGroupInput();
|
toggleNewGroupInput();
|
||||||
byId("labelGroupInput").value = "";
|
byId("labelGroupInput").value = "";
|
||||||
|
|
@ -254,6 +326,11 @@ function editLabel() {
|
||||||
newGroup.id = group;
|
newGroup.id = group;
|
||||||
byId("labelGroupSelect").options.add(new Option(group, group, false, true));
|
byId("labelGroupSelect").options.add(new Option(group, group, false, true));
|
||||||
byId(group).appendChild(elSelected.node());
|
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();
|
toggleNewGroupInput();
|
||||||
byId("labelGroupInput").value = "";
|
byId("labelGroupInput").value = "";
|
||||||
|
|
@ -263,9 +340,8 @@ function editLabel() {
|
||||||
const group = elSelected.node().parentNode.id;
|
const group = elSelected.node().parentNode.id;
|
||||||
const basic = group === "states" || group === "addedLabels";
|
const basic = group === "states" || group === "addedLabels";
|
||||||
const count = elSelected.node().parentNode.childElementCount;
|
const count = elSelected.node().parentNode.childElementCount;
|
||||||
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${
|
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${basic ? "all elements in the group" : "the entire label group"
|
||||||
basic ? "all elements in the group" : "the entire label group"
|
}? <br /><br />Labels to be
|
||||||
}? <br /><br />Labels to be
|
|
||||||
removed: ${count}`;
|
removed: ${count}`;
|
||||||
$("#alert").dialog({
|
$("#alert").dialog({
|
||||||
resizable: false,
|
resizable: false,
|
||||||
|
|
@ -275,6 +351,12 @@ function editLabel() {
|
||||||
$(this).dialog("close");
|
$(this).dialog("close");
|
||||||
$("#labelEditor").dialog("close");
|
$("#labelEditor").dialog("close");
|
||||||
hideGroupSection();
|
hideGroupSection();
|
||||||
|
// Remove from data model
|
||||||
|
if (basic && group === "states") {
|
||||||
|
Labels.removeByType("state");
|
||||||
|
} else {
|
||||||
|
Labels.removeByGroup(group);
|
||||||
|
}
|
||||||
labels
|
labels
|
||||||
.select("#" + group)
|
.select("#" + group)
|
||||||
.selectAll("text")
|
.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("");
|
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>`;
|
} 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")
|
if (elSelected.attr("id").slice(0, 10) === "stateLabel")
|
||||||
tip("Use States Editor to change an actual state name, not just a label", false, "warning");
|
tip("Use States Editor to change an actual state name, not just a label", false, "warning");
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateRandomName() {
|
function generateRandomName() {
|
||||||
let name = "";
|
let name = "";
|
||||||
if (elSelected.attr("id").slice(0, 10) === "stateLabel") {
|
const labelData = getLabelData(elSelected.node());
|
||||||
const id = +elSelected.attr("id").slice(10);
|
if (labelData && labelData.type === "state") {
|
||||||
const culture = pack.states[id].culture;
|
const culture = pack.states[labelData.stateId].culture;
|
||||||
name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture);
|
name = Names.getState(Names.getCulture(culture, 4, 7, ""), culture);
|
||||||
} else {
|
} else {
|
||||||
const box = elSelected.node().getBBox();
|
const box = elSelected.node().getBBox();
|
||||||
|
|
@ -358,17 +444,23 @@ function editLabel() {
|
||||||
|
|
||||||
function changeStartOffset() {
|
function changeStartOffset() {
|
||||||
elSelected.select("textPath").attr("startOffset", this.value + "%");
|
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 + "%");
|
tip("Label offset: " + this.value + "%");
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeRelativeSize() {
|
function changeRelativeSize() {
|
||||||
elSelected.select("textPath").attr("font-size", this.value + "%");
|
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 + "%");
|
tip("Label relative size: " + this.value + "%");
|
||||||
changeText();
|
changeText();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeLetterSpacingSize() {
|
function changeLetterSpacingSize() {
|
||||||
elSelected.select("textPath").attr("letter-spacing", this.value + "px");
|
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");
|
tip("Label letter-spacing size: " + this.value + "px");
|
||||||
changeText();
|
changeText();
|
||||||
}
|
}
|
||||||
|
|
@ -379,6 +471,12 @@ function editLabel() {
|
||||||
const path = defs.select("#textPath_" + elSelected.attr("id"));
|
const path = defs.select("#textPath_" + elSelected.attr("id"));
|
||||||
path.attr("d", `M${c[0] - bbox.width},${c[1]}h${bbox.width * 2}`);
|
path.attr("d", `M${c[0] - bbox.width},${c[1]}h${bbox.width * 2}`);
|
||||||
drawControlPointsAndLine();
|
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() {
|
function editLabelLegend() {
|
||||||
|
|
@ -395,6 +493,8 @@ function editLabel() {
|
||||||
buttons: {
|
buttons: {
|
||||||
Remove: function () {
|
Remove: function () {
|
||||||
$(this).dialog("close");
|
$(this).dialog("close");
|
||||||
|
const labelData = getLabelData(elSelected.node());
|
||||||
|
if (labelData) Labels.removeLabel(labelData.i);
|
||||||
defs.select("#textPath_" + elSelected.attr("id")).remove();
|
defs.select("#textPath_" + elSelected.attr("id")).remove();
|
||||||
elSelected.remove();
|
elSelected.remove();
|
||||||
$("#labelEditor").dialog("close");
|
$("#labelEditor").dialog("close");
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
|
* 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");
|
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/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/general.js?v=1.100.00"></script>
|
||||||
<script defer src="modules/ui/options.js?v=1.106.2"></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/style.js?v=1.108.4"></script>
|
||||||
<script defer src="modules/ui/editors.js?v=1.112.1"></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/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/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/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-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/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/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-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/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/notes-editor.js?v=1.107.3"></script>
|
||||||
<script defer src="modules/ui/ai-generator.js?v=1.108.8"></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="modules/ui/hotkeys.js?v=1.104.0"></script>
|
||||||
<script defer src="libs/rgbquant.min.js"></script>
|
<script defer src="libs/rgbquant.min.js"></script>
|
||||||
<script defer src="libs/jquery.ui.touch-punch.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/save.js?v=1.113.0"></script>
|
||||||
<script defer src="modules/io/load.js?v=1.111.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/cloud.js?v=1.106.0"></script>
|
||||||
<script defer src="modules/io/export.js?v=1.112.2"></script>
|
<script defer src="modules/io/export.js?v=1.112.2"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import "./routes-generator";
|
||||||
import "./states-generator";
|
import "./states-generator";
|
||||||
import "./zones-generator";
|
import "./zones-generator";
|
||||||
import "./religions-generator";
|
import "./religions-generator";
|
||||||
|
import "./labels";
|
||||||
import "./provinces-generator";
|
import "./provinces-generator";
|
||||||
import "./emblem";
|
import "./emblem";
|
||||||
import "./ice";
|
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 { Burg } from "../modules/burgs-generator";
|
||||||
|
import type { BurgLabelData } from "../modules/labels";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var drawBurgLabels: () => void;
|
var drawBurgLabels: () => void;
|
||||||
|
|
@ -15,31 +16,43 @@ const burgLabelsRenderer = (): void => {
|
||||||
TIME && console.time("drawBurgLabels");
|
TIME && console.time("drawBurgLabels");
|
||||||
createLabelGroups();
|
createLabelGroups();
|
||||||
|
|
||||||
for (const { name } of options.burgs.groups as BurgGroup[]) {
|
// Get all burg labels grouped by group name
|
||||||
const burgsInGroup = pack.burgs.filter(
|
const burgLabelsByGroup = new Map<string, BurgLabelData[]>();
|
||||||
(b) => b.group === name && !b.removed,
|
for (const label of Labels.getByType("burg").map((l) => l as BurgLabelData)) {
|
||||||
);
|
if (!burgLabelsByGroup.has(label.group)) {
|
||||||
if (!burgsInGroup.length) continue;
|
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;
|
if (labelGroup.empty()) continue;
|
||||||
|
|
||||||
const dx = labelGroup.attr("data-dx") || 0;
|
const dxAttr = labelGroup.attr("data-dx");
|
||||||
const dy = labelGroup.attr("data-dy") || 0;
|
const dyAttr = labelGroup.attr("data-dy");
|
||||||
|
const dx = dxAttr ? parseFloat(dxAttr) : 0;
|
||||||
|
const dy = dyAttr ? parseFloat(dyAttr) : 0;
|
||||||
|
|
||||||
labelGroup
|
// Build HTML string for all labels in this group
|
||||||
.selectAll("text")
|
const labelsHTML: string[] = [];
|
||||||
.data(burgsInGroup)
|
for (const labelData of labels) {
|
||||||
.enter()
|
// Update label data with SVG group offsets
|
||||||
.append("text")
|
if (labelData.dx !== dx || labelData.dy !== dy) {
|
||||||
.attr("text-rendering", "optimizeSpeed")
|
Labels.updateLabel(labelData.i, { dx, dy });
|
||||||
.attr("id", (d) => `burgLabel${d.i}`)
|
}
|
||||||
.attr("data-id", (d) => d.i!)
|
|
||||||
.attr("x", (d) => d.x)
|
labelsHTML.push(
|
||||||
.attr("y", (d) => d.y)
|
`<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>`,
|
||||||
.attr("dx", `${dx}em`)
|
);
|
||||||
.attr("dy", `${dy}em`)
|
}
|
||||||
.text((d) => d.name!);
|
|
||||||
|
// Set all labels at once
|
||||||
|
const groupNode = labelGroup.node();
|
||||||
|
if (groupNode) {
|
||||||
|
groupNode.innerHTML = labelsHTML.join("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TIME && console.timeEnd("drawBurgLabels");
|
TIME && console.timeEnd("drawBurgLabels");
|
||||||
|
|
@ -48,14 +61,40 @@ const burgLabelsRenderer = (): void => {
|
||||||
const drawBurgLabelRenderer = (burg: Burg): void => {
|
const drawBurgLabelRenderer = (burg: Burg): void => {
|
||||||
const labelGroup = burgLabels.select<SVGGElement>(`#${burg.group}`);
|
const labelGroup = burgLabels.select<SVGGElement>(`#${burg.group}`);
|
||||||
if (labelGroup.empty()) {
|
if (labelGroup.empty()) {
|
||||||
drawBurgLabels();
|
burgLabelsRenderer();
|
||||||
return; // redraw all labels if group is missing
|
return; // redraw all labels if group is missing
|
||||||
}
|
}
|
||||||
|
|
||||||
const dx = labelGroup.attr("data-dx") || 0;
|
const dxAttr = labelGroup.attr("data-dx");
|
||||||
const dy = labelGroup.attr("data-dy") || 0;
|
const dyAttr = labelGroup.attr("data-dy");
|
||||||
|
const dx = dxAttr ? parseFloat(dxAttr) : 0;
|
||||||
|
const dy = dyAttr ? parseFloat(dyAttr) : 0;
|
||||||
|
|
||||||
removeBurgLabelRenderer(burg.i!);
|
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
|
labelGroup
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("text-rendering", "optimizeSpeed")
|
.attr("text-rendering", "optimizeSpeed")
|
||||||
|
|
@ -71,6 +110,7 @@ const drawBurgLabelRenderer = (burg: Burg): void => {
|
||||||
const removeBurgLabelRenderer = (burgId: number): void => {
|
const removeBurgLabelRenderer = (burgId: number): void => {
|
||||||
const existingLabel = document.getElementById(`burgLabel${burgId}`);
|
const existingLabel = document.getElementById(`burgLabel${burgId}`);
|
||||||
if (existingLabel) existingLabel.remove();
|
if (existingLabel) existingLabel.remove();
|
||||||
|
Labels.removeBurgLabel(burgId);
|
||||||
};
|
};
|
||||||
|
|
||||||
function createLabelGroups(): void {
|
function createLabelGroups(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,40 @@
|
||||||
import { curveNatural, line, max, select } from "d3";
|
import { curveNatural, line, max, select } from "d3";
|
||||||
import {
|
import type { StateLabelData } from "../modules/labels";
|
||||||
drawPath,
|
import { findClosestCell, minmax, rn, round, splitInTwo } from "../utils";
|
||||||
drawPoint,
|
import { ANGLES, findBestRayPair, raycast } from "./label-raycast";
|
||||||
findClosestCell,
|
|
||||||
minmax,
|
|
||||||
rn,
|
|
||||||
round,
|
|
||||||
splitInTwo,
|
|
||||||
} from "../utils";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var drawStateLabels: (list?: number[]) => void;
|
var drawStateLabels: (list?: number[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Ray {
|
/**
|
||||||
angle: number;
|
* Helper function to calculate offset width for raycast based on state size
|
||||||
length: number;
|
*/
|
||||||
x: number;
|
function getOffsetWidth(cellsNumber: number): number {
|
||||||
y: number;
|
if (cellsNumber < 40) return 0;
|
||||||
|
if (cellsNumber < 200) return 5;
|
||||||
|
return 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AngleData {
|
function checkExampleLetterLength(): number {
|
||||||
angle: number;
|
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
|
||||||
dx: number;
|
const testLabel = textGroup
|
||||||
dy: number;
|
.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][];
|
/**
|
||||||
|
* Render state labels from pack.labels data to SVG.
|
||||||
// list - an optional array of stateIds to regenerate
|
* Adjusts and fits labels based on layout constraints.
|
||||||
|
* list - optional array of stateIds to re-render
|
||||||
|
*/
|
||||||
const stateLabelsRenderer = (list?: number[]): void => {
|
const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
TIME && console.time("drawStateLabels");
|
TIME && console.time("drawStateLabels");
|
||||||
|
|
||||||
|
|
@ -36,37 +42,46 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
const layerDisplay = labels.style("display");
|
const layerDisplay = labels.style("display");
|
||||||
labels.style("display", null);
|
labels.style("display", null);
|
||||||
|
|
||||||
const { cells, states, features } = pack;
|
const { states } = pack;
|
||||||
const stateIds = cells.state;
|
|
||||||
|
|
||||||
// increase step to 15 or 30 to make it faster and more horyzontal
|
// Get labels to render
|
||||||
// decrease step to 5 to improve accuracy
|
const labelsToRender = list
|
||||||
const ANGLE_STEP = 9;
|
? Labels.getAll()
|
||||||
const angles = precalculateAngles(ANGLE_STEP);
|
.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();
|
const letterLength = checkExampleLetterLength();
|
||||||
drawLabelPath(letterLength);
|
drawLabelPath(letterLength, labelsToRender);
|
||||||
|
|
||||||
// restore labels visibility
|
// restore labels visibility
|
||||||
labels.style("display", layerDisplay);
|
labels.style("display", layerDisplay);
|
||||||
|
|
||||||
function getLabelPaths(): [number, PathPoints][] {
|
function drawLabelPath(
|
||||||
const labelPaths: [number, PathPoints][] = [];
|
letterLength: number,
|
||||||
|
labelDataList: StateLabelData[],
|
||||||
|
): void {
|
||||||
|
const mode = options.stateLabelsMode || "auto";
|
||||||
|
const lineGen = line<[number, number]>().curve(curveNatural);
|
||||||
|
|
||||||
for (const state of states) {
|
const textGroup = select<SVGGElement, unknown>("g#labels > g#states");
|
||||||
if (!state.i || state.removed || state.lock) continue;
|
const pathGroup = select<SVGGElement, unknown>(
|
||||||
if (list && !list.includes(state.i)) continue;
|
"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 offset = getOffsetWidth(state.cells!);
|
||||||
const maxLakeSize = state.cells! / 20;
|
const maxLakeSize = state.cells! / 20;
|
||||||
const [x0, y0] = state.pole!;
|
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({
|
const { length, x, y } = raycast({
|
||||||
stateId: state.i,
|
stateId: state.i,
|
||||||
x0,
|
x0,
|
||||||
|
|
@ -80,61 +95,20 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
});
|
});
|
||||||
const [ray1, ray2] = findBestRayPair(rays);
|
const [ray1, ray2] = findBestRayPair(rays);
|
||||||
|
|
||||||
const pathPoints: PathPoints = [
|
const pathPoints: [number, number][] = [
|
||||||
[ray1.x, ray1.y],
|
[ray1.x, ray1.y],
|
||||||
state.pole!,
|
state.pole!,
|
||||||
[ray2.x, ray2.y],
|
[ray2.x, ray2.y],
|
||||||
];
|
];
|
||||||
if (ray1.x > ray2.x) pathPoints.reverse();
|
if (ray1.x > ray2.x) pathPoints.reverse();
|
||||||
|
|
||||||
if (DEBUG.stateLabels) {
|
textGroup.select(`#stateLabel${labelData.stateId}`).remove();
|
||||||
drawPoint(state.pole!, { color: "black", radius: 1 });
|
pathGroup.select(`#textPath_stateLabel${labelData.stateId}`).remove();
|
||||||
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();
|
|
||||||
|
|
||||||
const textPath = pathGroup
|
const textPath = pathGroup
|
||||||
.append("path")
|
.append("path")
|
||||||
.attr("d", round(lineGen(pathPoints) || ""))
|
.attr("d", round(lineGen(pathPoints) || ""))
|
||||||
.attr("id", `textPath_stateLabel${stateId}`);
|
.attr("id", `textPath_stateLabel${labelData.stateId}`);
|
||||||
|
|
||||||
const pathLength =
|
const pathLength =
|
||||||
(textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters
|
(textPath.node() as SVGPathElement).getTotalLength() / letterLength; // path length in letters
|
||||||
|
|
@ -145,6 +119,9 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
pathLength,
|
pathLength,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update label data with font size
|
||||||
|
Labels.updateLabel(labelData.i, { fontSize: ratio });
|
||||||
|
|
||||||
// prolongate path if it's too short
|
// prolongate path if it's too short
|
||||||
const longestLineLength = max(lines.map((line) => line.length)) || 0;
|
const longestLineLength = max(lines.map((line) => line.length)) || 0;
|
||||||
if (pathLength && pathLength < longestLineLength) {
|
if (pathLength && pathLength < longestLineLength) {
|
||||||
|
|
@ -165,7 +142,7 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
const textElement = textGroup
|
const textElement = textGroup
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("text-rendering", "optimizeSpeed")
|
.attr("text-rendering", "optimizeSpeed")
|
||||||
.attr("id", `stateLabel${stateId}`)
|
.attr("id", `stateLabel${labelData.stateId}`)
|
||||||
.append("textPath")
|
.append("textPath")
|
||||||
.attr("startOffset", "50%")
|
.attr("startOffset", "50%")
|
||||||
.attr("font-size", `${ratio}%`)
|
.attr("font-size", `${ratio}%`)
|
||||||
|
|
@ -179,8 +156,12 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
textElement.insertAdjacentHTML("afterbegin", spans.join(""));
|
textElement.insertAdjacentHTML("afterbegin", spans.join(""));
|
||||||
|
|
||||||
const { width, height } = textElement.getBBox();
|
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;
|
if (mode === "full" || lines.length === 1) continue;
|
||||||
|
|
||||||
// check if label fits state boundaries. If no, replace it with short name
|
// check if label fits state boundaries. If no, replace it with short name
|
||||||
|
|
@ -193,7 +174,7 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
width / 2,
|
width / 2,
|
||||||
height / 2,
|
height / 2,
|
||||||
stateIds,
|
stateIds,
|
||||||
stateId,
|
labelData.stateId,
|
||||||
);
|
);
|
||||||
if (isInsideState) continue;
|
if (isInsideState) continue;
|
||||||
|
|
||||||
|
|
@ -203,6 +184,7 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
? state.fullName!
|
? state.fullName!
|
||||||
: state.name!;
|
: state.name!;
|
||||||
textElement.innerHTML = `<tspan x="0">${text}</tspan>`;
|
textElement.innerHTML = `<tspan x="0">${text}</tspan>`;
|
||||||
|
Labels.updateLabel(labelData.i, { text });
|
||||||
|
|
||||||
const correctedRatio = minmax(
|
const correctedRatio = minmax(
|
||||||
rn((pathLength / text.length) * 50),
|
rn((pathLength / text.length) * 50),
|
||||||
|
|
@ -210,162 +192,10 @@ const stateLabelsRenderer = (list?: number[]): void => {
|
||||||
130,
|
130,
|
||||||
);
|
);
|
||||||
textElement.setAttribute("font-size", `${correctedRatio}%`);
|
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(
|
function getLinesAndRatio(
|
||||||
mode: string,
|
mode: string,
|
||||||
name: 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 { Burg } from "../modules/burgs-generator";
|
||||||
import type { Culture } from "../modules/cultures-generator";
|
import type { Culture } from "../modules/cultures-generator";
|
||||||
import type { PackedGraphFeature } from "../modules/features";
|
import type { PackedGraphFeature } from "../modules/features";
|
||||||
|
import type { LabelData } from "../modules/labels";
|
||||||
import type { Province } from "../modules/provinces-generator";
|
import type { Province } from "../modules/provinces-generator";
|
||||||
import type { River } from "../modules/river-generator";
|
import type { River } from "../modules/river-generator";
|
||||||
import type { Route } from "../modules/routes-generator";
|
import type { Route } from "../modules/routes-generator";
|
||||||
|
|
@ -62,5 +63,6 @@ export interface PackedGraph {
|
||||||
zones: Zone[];
|
zones: Zone[];
|
||||||
markers: any[];
|
markers: any[];
|
||||||
ice: any[];
|
ice: any[];
|
||||||
|
labels: LabelData[];
|
||||||
provinces: Province[];
|
provinces: Province[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue