mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-17 17:51:24 +01:00
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:
parent
1bb90251cd
commit
87599d1530
15 changed files with 395 additions and 281 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue