State labels: new label placing algorithm (#977)

* feat: draw state labels start

* feat: update old .map files

* chore: update version hash

* fear: add change to the user's changelog
This commit is contained in:
Azgaar 2023-08-11 18:56:42 +04:00 committed by GitHub
parent 1bb90251cd
commit 87599d1530
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 395 additions and 281 deletions

View file

@ -263,7 +263,7 @@ i.icon-lock {
}
#labels {
text-anchor: start;
text-anchor: middle;
dominant-baseline: central;
cursor: pointer;
}

View file

@ -138,7 +138,7 @@
}
</style>
<link rel="preload" href="index.css?v=1.89.38" as="style" onload="this.onload=null; this.rel='stylesheet'" />
<link rel="preload" href="index.css?v=1.92.00" as="style" onload="this.onload=null; this.rel='stylesheet'" />
<link rel="preload" href="icons.css" as="style" onload="this.onload=null; this.rel='stylesheet'" />
<link rel="preload" href="libs/jquery-ui.css" as="style" onload="this.onload=null; this.rel='stylesheet'" />
</head>
@ -7946,7 +7946,8 @@
<script src="modules/biomes.js"></script>
<script src="modules/names-generator.js?v=1.87.14"></script>
<script src="modules/cultures-generator.js?v=1.89.10"></script>
<script src="modules/burgs-and-states.js?v=1.89.37"></script>
<script src="modules/renderers/drawStateLabels.js"></script>
<script src="modules/burgs-and-states.js?v=1.92.00"></script>
<script src="modules/routes-generator.js"></script>
<script src="modules/religions-generator.js?v=1.89.36"></script>
<script src="modules/military-generator.js"></script>
@ -7963,15 +7964,15 @@
<script src="modules/ui/general.js?v=1.87.03"></script>
<script src="modules/ui/options.js?v=1.91.00"></script>
<script src="main.js?v=1.91.05"></script>
<script src="main.js?v=1.92.00"></script>
<script defer src="modules/relief-icons.js"></script>
<script defer src="modules/ui/style.js"></script>
<script defer src="modules/ui/editors.js?v=1.91.00"></script>
<script defer src="modules/ui/tools.js?v=1.90.00"></script>
<script defer src="modules/ui/editors.js?v=1.92.00"></script>
<script defer src="modules/ui/tools.js?v=1.92.00"></script>
<script defer src="modules/ui/world-configurator.js?v=1.91.05"></script>
<script defer src="modules/ui/heightmap-editor.js?v=1.91.05"></script>
<script defer src="modules/ui/provinces-editor.js?v=1.89.00"></script>
<script defer src="modules/ui/heightmap-editor.js?v=1.92.00"></script>
<script defer src="modules/ui/provinces-editor.js?v=1.92.00"></script>
<script defer src="modules/ui/biomes-editor.js?v=1.91.05"></script>
<script defer src="modules/ui/namesbase-editor.js?v=1.89.26"></script>
<script defer src="modules/ui/elevation-profile.js"></script>
@ -7980,7 +7981,7 @@
<script defer src="modules/ui/ice-editor.js?v=1.89.08"></script>
<script defer src="modules/ui/lakes-editor.js?v=1.87.10"></script>
<script defer src="modules/ui/coastline-editor.js"></script>
<script defer src="modules/ui/labels-editor.js"></script>
<script defer src="modules/ui/labels-editor.js?v=1.92.00"></script>
<script defer src="modules/ui/rivers-editor.js"></script>
<script defer src="modules/ui/rivers-creator.js?v=1.89.13"></script>
<script defer src="modules/ui/relief-editor.js"></script>
@ -7999,14 +8000,14 @@
<script defer src="modules/ui/emblems-editor.js?v=1.91.00"></script>
<script defer src="modules/ui/markers-editor.js"></script>
<script defer src="modules/ui/3d.js?v=1.89.36"></script>
<script defer src="modules/ui/submap.js"></script>
<script defer src="modules/ui/submap.js?v=1.92.00"></script>
<script defer src="modules/ui/hotkeys.js?v=1.88.00"></script>
<script defer src="modules/coa-renderer.js?v=1.91.00"></script>
<script defer src="libs/rgbquant.min.js"></script>
<script defer src="libs/jquery.ui.touch-punch.min.js"></script>
<script defer src="modules/io/save.js?v=1.91.04"></script>
<script defer src="modules/io/load.js?v=1.91.05"></script>
<script defer src="modules/io/load.js?v=1.92.00"></script>
<script defer src="modules/io/cloud.js"></script>
<script defer src="modules/io/export.js?v=1.89.36"></script>
<script defer src="modules/io/formats.js"></script>

View file

@ -648,7 +648,7 @@ async function generate(options) {
drawStates();
drawBorders();
BurgsAndStates.drawStateLabels();
drawStateLabels();
Rivers.specify();
Lakes.generateName();

View file

@ -502,223 +502,6 @@ window.BurgsAndStates = (function () {
TIME && console.timeEnd("updateCulturesForBurgsAndStates");
};
// calculate and draw curved state labels for a list of states
const drawStateLabels = function (list) {
TIME && console.time("drawStateLabels");
const {cells, features, states} = pack;
const paths = []; // text paths
lineGen.curve(d3.curveBundle.beta(1));
const mode = options.stateLabelsMode || "auto";
for (const s of states) {
if (!s.i || s.removed || s.lock || !s.cells || (list && !list.includes(s.i))) continue;
const used = [];
const visualCenter = findCell(s.pole[0], s.pole[1]);
const start = cells.state[visualCenter] === s.i ? visualCenter : s.center;
const hull = getHull(start, s.i, s.cells / 10);
const points = [...hull].map(v => pack.vertices.p[v]);
const delaunay = Delaunator.from(points);
const voronoi = new Voronoi(delaunay, points, points.length);
const chain = connectCenters(voronoi.vertices, s.pole[1]);
const relaxed = chain.map(i => voronoi.vertices.p[i]).filter((p, i) => i % 15 === 0 || i + 1 === chain.length);
paths.push([s.i, relaxed]);
function getHull(start, state, maxLake) {
const queue = [start];
const hull = new Set();
while (queue.length) {
const q = queue.pop();
const sameStateNeibs = cells.c[q].filter(c => cells.state[c] === state);
cells.c[q].forEach(function (c, d) {
const passableLake = features[cells.f[c]].type === "lake" && features[cells.f[c]].cells < maxLake;
if (cells.b[c] || (cells.state[c] !== state && !passableLake)) return hull.add(cells.v[q][d]);
const hasCoadjacentSameStateCells = sameStateNeibs.some(neib => cells.c[c].includes(neib));
if (hull.size > 20 && !hasCoadjacentSameStateCells && !passableLake) return hull.add(cells.v[q][d]);
if (used[c]) return;
used[c] = 1;
queue.push(c);
});
}
return hull;
}
function connectCenters(c, y) {
// check if vertex is inside the area
const inside = c.p.map(function (p) {
if (p[0] <= 0 || p[1] <= 0 || p[0] >= graphWidth || p[1] >= graphHeight) return false; // out of the screen
return used[findCell(p[0], p[1])];
});
const pointsInside = d3.range(c.p.length).filter(i => inside[i]);
if (!pointsInside.length) return [0];
const h = c.p.length < 200 ? 0 : c.p.length < 600 ? 0.5 : 1; // power of horyzontality shift
const end =
pointsInside[
d3.scan(
pointsInside,
(a, b) => c.p[a][0] - c.p[b][0] + (Math.abs(c.p[a][1] - y) - Math.abs(c.p[b][1] - y)) * h
)
]; // left point
const start =
pointsInside[
d3.scan(
pointsInside,
(a, b) => c.p[b][0] - c.p[a][0] - (Math.abs(c.p[b][1] - y) - Math.abs(c.p[a][1] - y)) * h
)
]; // right point
// connect leftmost and rightmost points with shortest path
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
const cost = [],
from = [];
queue.queue({e: start, p: 0});
while (queue.length) {
const next = queue.dequeue(),
n = next.e,
p = next.p;
if (n === end) break;
for (const v of c.v[n]) {
if (v === -1) continue;
const totalCost = p + (inside[v] ? 1 : 100);
if (from[v] || totalCost >= cost[v]) continue;
cost[v] = totalCost;
from[v] = n;
queue.queue({e: v, p: totalCost});
}
}
// restore path
const chain = [end];
let cur = end;
while (cur !== start) {
cur = from[cur];
if (inside[cur]) chain.push(cur);
}
return chain;
}
}
void (function drawLabels() {
const g = labels.select("#states");
const t = defs.select("#textPaths");
const displayed = layerIsOn("toggleLabels");
if (!displayed) toggleLabels();
// remove state labels to be redrawn
for (const state of pack.states) {
if (!state.i || state.removed || state.lock) continue;
if (list && !list.includes(state.i)) continue;
byId(`stateLabel${state.i}`)?.remove();
byId(`textPath_stateLabel${state.i}`)?.remove();
}
const example = g.append("text").attr("x", 0).attr("x", 0).text("Average");
const letterLength = example.node().getComputedTextLength() / 7; // average length of 1 letter
paths.forEach(p => {
const id = p[0];
const state = states[p[0]];
const {name, fullName} = state;
const path = p[1].length > 1 ? round(lineGen(p[1])) : `M${p[1][0][0] - 50},${p[1][0][1]}h${100}`;
const textPath = t
.append("path")
.attr("d", path)
.attr("id", "textPath_stateLabel" + id);
const pathLength = p[1].length > 1 ? textPath.node().getTotalLength() / letterLength : 0; // path length in letters
const [lines, ratio] = getLines(mode, name, fullName, pathLength);
// prolongate path if it's too short
if (pathLength && pathLength < lines[0].length) {
const points = p[1];
const f = points[0];
const l = points[points.length - 1];
const [dx, dy] = [l[0] - f[0], l[1] - f[1]];
const mod = Math.abs((letterLength * lines[0].length) / dx) / 2;
points[0] = [rn(f[0] - dx * mod), rn(f[1] - dy * mod)];
points[points.length - 1] = [rn(l[0] + dx * mod), rn(l[1] + dy * mod)];
textPath.attr("d", round(lineGen(points)));
}
example.attr("font-size", ratio + "%");
const top = (lines.length - 1) / -2; // y offset
const spans = lines.map((l, d) => {
example.text(l);
const left = example.node().getBBox().width / -2; // x offset
return `<tspan x=${rn(left, 1)} dy="${d ? 1 : top}em">${l}</tspan>`;
});
const el = g
.append("text")
.attr("id", "stateLabel" + id)
.append("textPath")
.attr("xlink:href", "#textPath_stateLabel" + id)
.attr("startOffset", "50%")
.attr("font-size", ratio + "%")
.node();
el.insertAdjacentHTML("afterbegin", spans.join(""));
if (mode === "full" || lines.length === 1) return;
// check whether multilined label is generally inside the state. If no, replace with short name label
const cs = pack.cells.state;
const b = el.parentNode.getBBox();
const c1 = () => +cs[findCell(b.x, b.y)] === id;
const c2 = () => +cs[findCell(b.x + b.width / 2, b.y)] === id;
const c3 = () => +cs[findCell(b.x + b.width, b.y)] === id;
const c4 = () => +cs[findCell(b.x + b.width, b.y + b.height)] === id;
const c5 = () => +cs[findCell(b.x + b.width / 2, b.y + b.height)] === id;
const c6 = () => +cs[findCell(b.x, b.y + b.height)] === id;
if (c1() + c2() + c3() + c4() + c5() + c6() > 3) return; // generally inside => exit
// move to one-line name
const text = pathLength > fullName.length * 1.8 ? fullName : name;
example.text(text);
const left = example.node().getBBox().width / -2; // x offset
el.innerHTML = `<tspan x="${left}px">${text}</tspan>`;
const correctedRatio = minmax(rn((pathLength / text.length) * 60), 40, 130);
el.setAttribute("font-size", correctedRatio + "%");
});
example.remove();
if (!displayed) toggleLabels();
})();
function getLines(mode, name, fullName, pathLength) {
// short name
if (mode === "short" || (mode === "auto" && pathLength < name.length)) {
const lines = splitInTwo(name);
const ratio = pathLength / lines[0].length;
return [lines, minmax(rn(ratio * 60), 50, 150)];
}
// full name: one line
if (pathLength > fullName.length * 2.5) {
const lines = [fullName];
const ratio = pathLength / lines[0].length;
return [lines, minmax(rn(ratio * 70), 70, 170)];
}
// full name: two lines
const lines = splitInTwo(fullName);
const ratio = pathLength / lines[0].length;
return [lines, minmax(rn(ratio * 60), 70, 150)];
}
TIME && console.timeEnd("drawStateLabels");
};
// calculate states data like area, population etc.
const collectStatistics = function () {
TIME && console.time("collectStatistics");
@ -1405,7 +1188,6 @@ window.BurgsAndStates = (function () {
specifyBurgs,
defineBurgFeatures,
getType,
drawStateLabels,
collectStatistics,
generateCampaign,
generateCampaigns,

View file

@ -698,4 +698,11 @@ export function resolveVersionConflicts(version) {
}
});
}
if (version < 1.92) {
// v1.92 change labels text-anchor from 'start' to 'middle'
labels.selectAll("tspan").each(function () {
this.setAttribute("x", 0);
});
}
}

View file

@ -494,7 +494,7 @@ function editStateName(state) {
s.name = nameInput.value;
s.formName = formSelect.value;
s.fullName = fullNameInput.value;
if (changed && stateNameEditorUpdateLabel.checked) BurgsAndStates.drawStateLabels([s.i]);
if (changed && stateNameEditorUpdateLabel.checked) drawStateLabels([s.i]);
refreshStatesEditor();
}
}
@ -877,7 +877,7 @@ function recalculateStates(must) {
if (!layerIsOn("toggleBorders")) toggleBorders();
else drawBorders();
if (layerIsOn("toggleProvinces")) drawProvinces();
if (adjustLabels.checked) BurgsAndStates.drawStateLabels();
if (adjustLabels.checked) drawStateLabels();
refreshStatesEditor();
}
@ -1022,7 +1022,7 @@ function applyStatesManualAssignent() {
if (affectedStates.length) {
refreshStatesEditor();
layerIsOn("toggleStates") ? drawStates() : toggleStates();
if (adjustLabels.checked) BurgsAndStates.drawStateLabels([...new Set(affectedStates)]);
if (adjustLabels.checked) drawStateLabels([...new Set(affectedStates)]);
adjustProvinces([...new Set(affectedProvinces)]);
layerIsOn("toggleBorders") ? drawBorders() : toggleBorders();
if (layerIsOn("toggleProvinces")) drawProvinces();
@ -1459,7 +1459,7 @@ function openStateMergeDialog() {
layerIsOn("toggleStates") ? drawStates() : toggleStates();
layerIsOn("toggleBorders") ? drawBorders() : toggleBorders();
layerIsOn("toggleProvinces") && drawProvinces();
BurgsAndStates.drawStateLabels([rulingStateId]);
drawStateLabels([rulingStateId]);
refreshStatesEditor();
}

View file

@ -435,7 +435,7 @@ async function parseLoadedData(data) {
{
// dynamically import and run auto-udpdate script
const versionNumber = parseFloat(params[0]);
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.91.00");
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.92.00");
resolveVersionConflicts(versionNumber);
}

View file

@ -0,0 +1,280 @@
"use strict";
// list - an optional array of stateIds to regenerate
function drawStateLabels(list) {
console.time("drawStateLabels");
const {cells, states, features} = pack;
const stateIds = cells.state;
// increase step to 15 or 30 to make it faster and more horyzontal
// decrease step to 5 to improve accuracy
const ANGLE_STEP = 9;
const raycast = precalculateAngles(ANGLE_STEP);
const INITIAL_DISTANCE = 10;
const DISTANCE_STEP = 15;
const MAX_ITERATIONS = 100;
const labelPaths = getLabelPaths();
drawLabelPath();
function getLabelPaths() {
const labelPaths = [];
for (const state of states) {
if (!state.i || state.removed || state.lock) continue;
if (list && !list.includes(state.i)) continue;
const offset = getOffsetWidth(state.cells);
const maxLakeSize = state.cells / 50;
const [x0, y0] = state.pole;
const offsetPoints = new Map(
(offset ? raycast : []).map(({angle, x: x1, y: y1}) => {
const [x, y] = [x0 + offset * x1, y0 + offset * y1];
return [angle, {x, y}];
})
);
const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => {
let distanceMin;
const distance1 = getMaxDistance(state.i, {x: x0, y: y0}, dx, dy, maxLakeSize);
if (offset) {
const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90);
const distance2 = getMaxDistance(state.i, point2, dx, dy, maxLakeSize);
const point3 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90);
const distance3 = getMaxDistance(state.i, point3, dx, dy, maxLakeSize);
distanceMin = Math.min(distance1, distance2, distance3);
} else {
distanceMin = distance1;
}
const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy];
return {angle, distance: distanceMin * modifier, x, y};
});
const {
angle,
x: x1,
y: y1
} = distances.reduce(
(acc, {angle, distance, x, y}) => {
if (distance > acc.distance) return {angle, distance, x, y};
return acc;
},
{angle: 0, distance: 0, x: 0, y: 0}
);
const oppositeAngle = angle >= 180 ? angle - 180 : angle + 180;
const {x: x2, y: y2} = distances.reduce(
(acc, {angle, distance, x, y}) => {
const angleDif = getAnglesDif(angle, oppositeAngle);
const score = distance * getAngleModifier(angleDif);
if (score > acc.score) return {angle, score, x, y};
return acc;
},
{angle: 0, score: 0, x: 0, y: 0}
);
const pathPoints = [[x1, y1], state.pole, [x2, y2]];
if (x1 > x2) pathPoints.reverse();
labelPaths.push([state.i, pathPoints]);
}
return labelPaths;
function getMaxDistance(stateId, point, dx, dy, maxLakeSize) {
let distance = INITIAL_DISTANCE;
for (let i = 0; i < MAX_ITERATIONS; i++) {
const [x, y] = [point.x + distance * dx, point.y + distance * dy];
const cellId = findCell(x, y, DISTANCE_STEP);
// drawPoint([x, y], {color: cellId && isPassable(cellId) ? "blue" : "red", radius: 0.8});
if (!cellId || !isPassable(cellId)) break;
distance += DISTANCE_STEP;
}
return distance;
function isPassable(cellId) {
const feature = features[cells.f[cellId]];
if (feature.type === "lake") return feature.cells <= maxLakeSize;
return stateIds[cellId] === stateId;
}
}
}
function drawLabelPath() {
const mode = options.stateLabelsMode || "auto";
const lineGen = d3.line().curve(d3.curveBundle.beta(1));
const textGroup = d3.select("g#labels > g#states");
const pathGroup = d3.select("defs > g#deftemp > g#textPaths");
const testLabel = textGroup.append("text").attr("x", 0).attr("y", 0).text("Example");
const letterLength = testLabel.node().getComputedTextLength() / 7; // approximate length of 1 letter
testLabel.remove();
for (const [stateId, pathPoints] of labelPaths) {
const state = states[stateId];
if (!state.i || state.removed) throw new Error("State must not be neutral or removed");
if (pathPoints.length < 2) throw new Error("Label path must have at least 2 points");
textGroup.select("#stateLabel" + stateId).remove();
pathGroup.select("#textPath_stateLabel" + stateId).remove();
const textPath = pathGroup
.append("path")
.attr("d", round(lineGen(pathPoints)))
.attr("id", "textPath_stateLabel" + stateId);
const pathLength = textPath.node().getTotalLength() / letterLength; // path length in letters
const [lines, ratio] = getLinesAndRatio(mode, state.name, state.fullName, pathLength);
// prolongate path if it's too short
const longestLineLength = d3.max(lines.map(({length}) => length));
if (pathLength && pathLength < longestLineLength) {
const [x1, y1] = pathPoints.at(0);
const [x2, y2] = pathPoints.at(-1);
const [dx, dy] = [(x2 - x1) / 2, (y2 - y1) / 2];
const mod = longestLineLength / pathLength;
pathPoints[0] = [x1 + dx - dx * mod, y1 + dy - dy * mod];
pathPoints[pathPoints.length - 1] = [x2 - dx + dx * mod, y2 - dy + dy * mod];
textPath.attr("d", round(lineGen(pathPoints)));
}
const textElement = textGroup
.append("text")
.attr("id", "stateLabel" + stateId)
.append("textPath")
.attr("startOffset", "50%")
.attr("font-size", ratio + "%")
.node();
const top = (lines.length - 1) / -2; // y offset
const spans = lines.map((line, index) => `<tspan x="0" dy="${index ? 1 : top}em">${line}</tspan>`);
textElement.insertAdjacentHTML("afterbegin", spans.join(""));
const {width, height} = textElement.getBBox();
textElement.setAttribute("href", "#textPath_stateLabel" + stateId);
if (mode === "full" || lines.length === 1) continue;
// check if label fits state boundaries. If no, replace it with short name
const [[x1, y1], [x2, y2]] = [pathPoints.at(0), pathPoints.at(-1)];
const angleRad = Math.atan2(y2 - y1, x2 - x1);
const isInsideState = checkIfInsideState(textElement, angleRad, width / 2, height / 2, stateIds, stateId);
if (isInsideState) continue;
// replace name to one-liner
const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name;
textElement.innerHTML = `<tspan x="0">${text}</tspan>`;
const correctedRatio = minmax(rn((pathLength / text.length) * 50), 40, 130);
textElement.setAttribute("font-size", correctedRatio + "%");
}
}
// point offset to reduce label overlap with state borders
function getOffsetWidth(cellsNumber) {
if (cellsNumber < 80) return 0;
if (cellsNumber < 140) return 5;
if (cellsNumber < 200) return 15;
if (cellsNumber < 300) return 20;
if (cellsNumber < 500) return 25;
return 30;
}
// difference between two angles in range [0, 180]
function getAnglesDif(angle1, angle2) {
return 180 - Math.abs(Math.abs(angle1 - angle2) - 180);
}
// score multiplier based on angle difference betwee left and right sides
function getAngleModifier(angleDif) {
if (angleDif === 0) return 1;
if (angleDif <= 15) return 0.95;
if (angleDif <= 30) return 0.9;
if (angleDif <= 45) return 0.6;
if (angleDif <= 60) return 0.3;
if (angleDif <= 90) return 0.1;
return 0; // >90
}
function precalculateAngles(step) {
const angles = [];
const RAD = Math.PI / 180;
for (let angle = 0; angle < 360; angle += step) {
const x = Math.cos(angle * RAD);
const y = Math.sin(angle * RAD);
const angleDif = 90 - Math.abs((angle % 180) - 90);
const modifier = 1 - angleDif / 120; // [0.25, 1]
angles.push({angle, modifier, x, y});
}
return angles;
}
function getLinesAndRatio(mode, name, fullName, pathLength) {
// short name
if (mode === "short" || (mode === "auto" && pathLength <= name.length)) {
const lines = splitInTwo(name);
const longestLineLength = d3.max(lines.map(({length}) => length));
const ratio = pathLength / longestLineLength;
return [lines, minmax(rn(ratio * 60), 50, 150)];
}
// full name: one line
if (pathLength > fullName.length * 2) {
const lines = [fullName];
const ratio = pathLength / lines[0].length;
return [lines, minmax(rn(ratio * 70), 70, 170)];
}
// full name: two lines
const lines = splitInTwo(fullName);
const longestLineLength = d3.max(lines.map(({length}) => length));
const ratio = pathLength / longestLineLength;
return [lines, minmax(rn(ratio * 60), 70, 150)];
}
// check whether multi-lined label is mostly inside the state. If no, replace it with short name label
function checkIfInsideState(textElement, angleRad, halfwidth, halfheight, stateIds, stateId) {
const bbox = textElement.getBBox();
const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2];
const points = [
[-halfwidth, -halfheight],
[+halfwidth, -halfheight],
[+halfwidth, halfheight],
[-halfwidth, halfheight],
[0, halfheight],
[0, -halfheight]
];
const sin = Math.sin(angleRad);
const cos = Math.cos(angleRad);
const rotatedPoints = points.map(([x, y]) => [cx + x * cos - y * sin, cy + x * sin + y * cos]);
let pointsInside = 0;
for (const [x, y] of rotatedPoints) {
const isInside = stateIds[findCell(x, y)] === stateId;
if (isInside) pointsInside++;
if (pointsInside > 4) return true;
}
return false;
}
console.timeEnd("drawStateLabels");
}

View file

@ -276,7 +276,7 @@ window.Submap = (function () {
drawStates();
drawBorders();
BurgsAndStates.drawStateLabels();
drawStateLabels();
Rivers.specify();
Lakes.generateName();

View file

@ -1176,7 +1176,7 @@ function refreshAllEditors() {
// dynamically loaded editors
async function editStates() {
if (customization) return;
const Editor = await import("../dynamic/editors/states-editor.js?v=1.89.35");
const Editor = await import("../dynamic/editors/states-editor.js?v=1.92.00");
Editor.open();
}

View file

@ -253,7 +253,7 @@ function editHeightmap(options) {
drawStates();
drawBorders();
BurgsAndStates.drawStateLabels();
drawStateLabels();
Rivers.specify();
Lakes.generateName();
@ -442,7 +442,7 @@ function editHeightmap(options) {
c.center = findCell(c.x, c.y);
}
BurgsAndStates.drawStateLabels();
drawStateLabels();
drawStates();
drawBorders();

View file

@ -78,7 +78,9 @@ function editLabel() {
}
function updateValues(textPath) {
document.getElementById("labelText").value = [...textPath.querySelectorAll("tspan")].map(tspan => tspan.textContent).join("|");
document.getElementById("labelText").value = [...textPath.querySelectorAll("tspan")]
.map(tspan => tspan.textContent)
.join("|");
document.getElementById("labelStartOffset").value = parseFloat(textPath.getAttribute("startOffset"));
document.getElementById("labelRelativeSize").value = parseFloat(textPath.getAttribute("font-size"));
}
@ -298,22 +300,15 @@ function editLabel() {
function changeText() {
const input = document.getElementById("labelText").value;
const el = elSelected.select("textPath").node();
const example = d3.select(elSelected.node().parentNode).append("text").attr("x", 0).attr("x", 0).attr("font-size", el.getAttribute("font-size")).node();
const lines = input.split("|");
const top = (lines.length - 1) / -2; // y offset
const inner = lines
.map((l, d) => {
example.innerHTML = l;
const left = example.getBBox().width / -2; // x offset
return `<tspan x="${left}px" dy="${d ? 1 : top}em">${l}</tspan>`;
})
.join("");
if (lines.length > 1) {
const top = (lines.length - 1) / -2; // y offset
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>`;
el.innerHTML = inner;
example.remove();
if (elSelected.attr("id").slice(0, 10) === "stateLabel") tip("Use States Editor to change an actual state name, not just a label", false, "warning");
if (elSelected.attr("id").slice(0, 10) === "stateLabel")
tip("Use States Editor to change an actual state name, not just a label", false, "warning");
}
function generateRandomName() {

View file

@ -124,7 +124,9 @@ function editProvinces() {
const rural = p.rural * populationRate;
const urban = p.urban * populationRate * urbanization;
const population = rn(rural + urban);
const populationTip = `Total population: ${si(population)}; Rural population: ${si(rural)}; Urban population: ${si(urban)}`;
const populationTip = `Total population: ${si(population)}; Rural population: ${si(
rural
)}; Urban population: ${si(urban)}`;
totalPopulation += population;
const stateName = pack.states[p.state].name;
@ -145,9 +147,15 @@ function editProvinces() {
>
<fill-box fill="${p.color}"></fill-box>
<input data-tip="Province name. Click to change" class="name pointer" value="${p.name}" readonly />
<svg data-tip="Click to show and edit province emblem" class="coaIcon pointer hide" viewBox="0 0 200 200"><use href="#provinceCOA${p.i}"></use></svg>
<input data-tip="Province form name. Click to change" class="name pointer hide" value="${p.formName}" readonly />
<span data-tip="Province capital. Click to zoom into view" class="icon-star-empty pointer hide ${p.burg ? "" : "placeholder"}"></span>
<svg data-tip="Click to show and edit province emblem" class="coaIcon pointer hide" viewBox="0 0 200 200"><use href="#provinceCOA${
p.i
}"></use></svg>
<input data-tip="Province form name. Click to change" class="name pointer hide" value="${
p.formName
}" readonly />
<span data-tip="Province capital. Click to zoom into view" class="icon-star-empty pointer hide ${
p.burg ? "" : "placeholder"
}"></span>
<select
data-tip="Province capital. Click to select from burgs within the state. No capital means the province is governed from the state capital"
class="cultureBase hide ${p.burgs.length ? "" : "placeholder"}"
@ -164,7 +172,7 @@ function editProvinces() {
class="icon-flag-empty ${separable ? "" : "placeholder"} hide"
></span>
<span data-tip="Toggle province focus" class="icon-pin ${focused ? "" : " inactive"} hide"></span>
<span data-tip="Lock the province" class="icon-lock${p.lock ? '' : '-open'} hide"></span>
<span data-tip="Lock the province" class="icon-lock${p.lock ? "" : "-open"} hide"></span>
<span data-tip="Remove the province" class="icon-trash-empty hide"></span>
</div>`;
}
@ -193,7 +201,9 @@ function editProvinces() {
function getCapitalOptions(burgs, capital) {
let options = "";
burgs.forEach(b => (options += `<option ${b === capital ? "selected" : ""} value="${b}">${pack.burgs[b].name}</option>`));
burgs.forEach(
b => (options += `<option ${b === capital ? "selected" : ""} value="${b}">${pack.burgs[b].name}</option>`)
);
return options;
}
@ -267,7 +277,11 @@ function editProvinces() {
const {name, burg: burgId, burgs: provinceBurgs} = province;
if (provinceBurgs.some(b => burgs[b].capital))
return tip("Cannot declare independence of a province having capital burg. Please change capital first", false, "error");
return tip(
"Cannot declare independence of a province having capital burg. Please change capital first",
false,
"error"
);
if (!burgId) return tip("Cannot declare independence of a province without burg", false, "error");
const oldStateId = province.state;
@ -313,7 +327,10 @@ function editProvinces() {
return relations;
});
diplomacy.push("x");
states[0].diplomacy.push([`Independance declaration`, `${name} declared its independance from ${states[oldStateId].name}`]);
states[0].diplomacy.push([
`Independance declaration`,
`${name} declared its independance from ${states[oldStateId].name}`
]);
// create new state
states.push({
@ -348,7 +365,7 @@ function editProvinces() {
BurgsAndStates.collectStatistics();
BurgsAndStates.defineStateForms(newStates);
BurgsAndStates.drawStateLabels(allStates);
drawStateLabels(allStates);
// redraw emblems
allStates.forEach(stateId => {
@ -375,8 +392,12 @@ function editProvinces() {
const l = n => Number(n).toLocaleString();
alertMessage.innerHTML = /* html */ ` Rural: <input type="number" min="0" step="1" id="ruralPop" value=${rural} style="width:6em" /> Urban:
<input type="number" min="0" step="1" id="urbanPop" value=${urban} style="width:6em" ${p.burgs.length ? "" : "disabled"} />
<p>Total population: ${l(total)} <span id="totalPop">${l(total)}</span> (<span id="totalPopPerc">100</span>%)</p>`;
<input type="number" min="0" step="1" id="urbanPop" value=${urban} style="width:6em" ${
p.burgs.length ? "" : "disabled"
} />
<p>Total population: ${l(total)} <span id="totalPop">${l(
total
)}</span> (<span id="totalPopPerc">100</span>%)</p>`;
const update = function () {
const totalNew = ruralPop.valueAsNumber + urbanPop.valueAsNumber;
@ -694,7 +715,13 @@ function editProvinces() {
function updateChart() {
const value =
this.value === "area" ? d => d.area : this.value === "rural" ? d => d.rural : this.value === "urban" ? d => d.urban : d => d.rural + d.urban;
this.value === "area"
? d => d.area
: this.value === "rural"
? d => d.rural
: this.value === "urban"
? d => d.urban
: d => d.rural + d.urban;
root.sum(value);
node.data(treeLayout(root).leaves());
@ -776,7 +803,13 @@ function editProvinces() {
customization = 11;
provs.select("g#provincesBody").append("g").attr("id", "temp");
provs.select("g#provincesBody").append("g").attr("id", "centers").attr("fill", "none").attr("stroke", "#ff0000").attr("stroke-width", 1);
provs
.select("g#provincesBody")
.append("g")
.attr("id", "centers")
.attr("fill", "none")
.attr("stroke", "#ff0000")
.attr("stroke-width", 1);
document.querySelectorAll("#provincesBottom > *").forEach(el => (el.style.display = "none"));
document.getElementById("provincesManuallyButtons").style.display = "inline-block";
@ -788,7 +821,11 @@ function editProvinces() {
$("#provincesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}});
tip("Click on a province to select, drag the circle to change province", true);
viewbox.style("cursor", "crosshair").on("click", selectProvinceOnMapClick).call(d3.drag().on("start", dragBrush)).on("touchmove mousemove", moveBrush);
viewbox
.style("cursor", "crosshair")
.on("click", selectProvinceOnMapClick)
.call(d3.drag().on("start", dragBrush))
.on("touchmove mousemove", moveBrush);
body.querySelector("div").classList.add("selected");
selectProvince(+body.querySelector("div").dataset.id);
@ -859,7 +896,11 @@ function editProvinces() {
if (i === pack.provinces[provinceOld].center) {
const center = centers.select("polygon[data-center='" + i + "']");
if (!center.size()) centers.append("polygon").attr("data-center", i).attr("points", getPackPolygon(i));
tip("Province center cannot be assigned to a different region. Please remove the province first", false, "error");
tip(
"Province center cannot be assigned to a different region. Please remove the province first",
false,
"error"
);
return;
}
@ -921,7 +962,8 @@ function editProvinces() {
provincesHeader.querySelector("div[data-sortby='state']").style.left = "22em";
provincesFooter.style.display = "block";
body.querySelectorAll("div > input, select, span, svg").forEach(e => (e.style.pointerEvents = "all"));
if (!close) $("#provincesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}});
if (!close)
$("#provincesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}});
restoreDefaultEvents();
clearMainTip();
@ -943,14 +985,20 @@ function editProvinces() {
const {cells, provinces} = pack;
const point = d3.mouse(this);
const center = findCell(point[0], point[1]);
if (cells.h[center] < 20) return tip("You cannot place province into the water. Please click on a land cell", false, "error");
if (cells.h[center] < 20)
return tip("You cannot place province into the water. Please click on a land cell", false, "error");
const oldProvince = cells.province[center];
if (oldProvince && provinces[oldProvince].center === center)
return tip("The cell is already a center of a different province. Select other cell", false, "error");
const state = cells.state[center];
if (!state) return tip("You cannot create a province in neutral lands. Please assign this land to a state first", false, "error");
if (!state)
return tip(
"You cannot create a province in neutral lands. Please assign this land to a state first",
false,
"error"
);
if (d3.event.shiftKey === false) exitAddProvinceMode();
@ -1016,7 +1064,10 @@ function editProvinces() {
function downloadProvincesData() {
const unit = areaUnit.value === "square" ? distanceUnitInput.value + "2" : areaUnit.value;
let data = "Id,Province,Full Name,Form,State,Color,Capital,Area " + unit + ",Total Population,Rural Population,Urban Population\n"; // headers
let data =
"Id,Province,Full Name,Form,State,Color,Capital,Area " +
unit +
",Total Population,Rural Population,Urban Population\n"; // headers
body.querySelectorAll(":scope > div").forEach(function (el) {
const key = parseInt(el.dataset.id);

View file

@ -74,7 +74,7 @@ toolsContent.addEventListener("click", function (event) {
});
function processFeatureRegeneration(event, button) {
if (button === "regenerateStateLabels") BurgsAndStates.drawStateLabels();
if (button === "regenerateStateLabels") drawStateLabels();
else if (button === "regenerateReliefIcons") {
ReliefIcons();
if (!layerIsOn("toggleRelief")) toggleRelief();
@ -154,7 +154,7 @@ function regenerateStates() {
layerIsOn("toggleBorders") ? drawBorders() : toggleBorders();
if (layerIsOn("toggleProvinces")) drawProvinces();
BurgsAndStates.drawStateLabels();
drawStateLabels();
Military.generate();
if (layerIsOn("toggleEmblems")) drawEmblems();
@ -570,9 +570,8 @@ function addLabelOnClick() {
.attr("data-size", 18)
.attr("filter", null);
const example = group.append("text").attr("x", 0).attr("x", 0).text(name);
const example = group.append("text").attr("x", 0).attr("y", 0).text(name);
const width = example.node().getBBox().width;
const x = width / -2; // x offset;
example.remove();
group.classed("hidden", false);
@ -584,7 +583,7 @@ function addLabelOnClick() {
.attr("startOffset", "50%")
.attr("font-size", "100%")
.append("tspan")
.attr("x", x)
.attr("x", 0)
.text(name);
defs
@ -836,7 +835,7 @@ function addMarkerOnClick() {
const marker = Markers.add({...baseMarker, x, y, cell});
if (selectedConfig && selectedConfig.add) {
selectedConfig.add("marker"+marker.i, cell);
selectedConfig.add("marker" + marker.i, cell);
}
const markersElement = document.getElementById("markers");

View file

@ -1,7 +1,7 @@
"use strict";
// version and caching control
const version = "1.91.05"; // generator version, update each time
const version = "1.92.00"; // generator version, update each time
{
document.title += " v" + version;
@ -28,6 +28,7 @@ const version = "1.91.05"; // generator version, update each time
<ul>
<strong>Latest changes:</strong>
<li>New label placement algorithm for states</li>
<li>North and South Poles temperature can be set independently</li>
<li>More than 70 new heraldic charges</li>
<li>Multi-color heraldic charges support</li>
@ -36,8 +37,6 @@ const version = "1.91.05"; // generator version, update each time
<li>Google translation support (in Options)</li>
<li>Religions can be edited and redrawn like cultures</li>
<li>Lock states, provinces, cultures, and religions from regeneration</li>
<li>Heightmap brushes: linear edit option</li>
<li>Data Charts screen</li>
</ul>
<p>Join our <a href="${discord}" target="_blank">Discord server</a> and <a href="${reddit}" target="_blank">Reddit community</a> to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.</p>