This commit is contained in:
kruschen 2026-02-24 18:06:28 +00:00 committed by GitHub
commit be4d931540
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 823 additions and 285 deletions

View file

@ -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();

View file

@ -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();
}
}
}
} }

View file

@ -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);
} }

View file

@ -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;
} }

View file

@ -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();
} }

View file

@ -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");

View file

@ -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");
{ {

View file

@ -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>

View file

@ -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
View 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();

View file

@ -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 {

View file

@ -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,

View 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!;
}

View file

@ -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[];
} }