From cc7f7cbde2914f7423c3968ff4f3d0a3065853ec Mon Sep 17 00:00:00 2001 From: Azgaar Date: Fri, 11 Aug 2023 17:02:05 +0400 Subject: [PATCH 1/4] feat: draw state labels start --- index.html | 1 + main.js | 2 +- modules/burgs-and-states.js | 5 +- modules/dynamic/editors/states-editor.js | 8 +- modules/renderers/drawStatelabels.js | 278 +++++++++++++++++++++++ modules/submap.js | 2 +- modules/ui/heightmap-editor.js | 4 +- modules/ui/provinces-editor.js | 89 ++++++-- modules/ui/tools.js | 6 +- 9 files changed, 362 insertions(+), 33 deletions(-) create mode 100644 modules/renderers/drawStatelabels.js diff --git a/index.html b/index.html index ed6d14a45..b13b0df4e 100644 --- a/index.html +++ b/index.html @@ -7946,6 +7946,7 @@ + diff --git a/main.js b/main.js index 6967242db..4c40de431 100644 --- a/main.js +++ b/main.js @@ -648,7 +648,7 @@ async function generate(options) { drawStates(); drawBorders(); - BurgsAndStates.drawStateLabels(); + drawStateLabels(); Rivers.specify(); Lakes.generateName(); diff --git a/modules/burgs-and-states.js b/modules/burgs-and-states.js index 6f2bf831e..d41ea9bcb 100644 --- a/modules/burgs-and-states.js +++ b/modules/burgs-and-states.js @@ -503,7 +503,7 @@ window.BurgsAndStates = (function () { }; // calculate and draw curved state labels for a list of states - const drawStateLabels = function (list) { + const drawStateLabelsOld = function (list) { TIME && console.time("drawStateLabels"); const {cells, features, states} = pack; const paths = []; // text paths @@ -519,8 +519,8 @@ window.BurgsAndStates = (function () { 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 voronoi = new Voronoi(delaunay, points, points.length); const relaxed = chain.map(i => voronoi.vertices.p[i]).filter((p, i) => i % 15 === 0 || i + 1 === chain.length); paths.push([s.i, relaxed]); @@ -1405,7 +1405,6 @@ window.BurgsAndStates = (function () { specifyBurgs, defineBurgFeatures, getType, - drawStateLabels, collectStatistics, generateCampaign, generateCampaigns, diff --git a/modules/dynamic/editors/states-editor.js b/modules/dynamic/editors/states-editor.js index 11d9581d6..a6128c571 100644 --- a/modules/dynamic/editors/states-editor.js +++ b/modules/dynamic/editors/states-editor.js @@ -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(); } diff --git a/modules/renderers/drawStatelabels.js b/modules/renderers/drawStatelabels.js new file mode 100644 index 000000000..d51e3e666 --- /dev/null +++ b/modules/renderers/drawStatelabels.js @@ -0,0 +1,278 @@ +"use strict"; + +function drawStateLabels() { + 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.locked) 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("x", 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"); + if (pathPoints.length < 2) throw new Error("Label path must have at least 2 points"); + + textGroup.select("#textPath_stateLabel" + stateId).remove(); + pathGroup.select("#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) => `${line}`); + 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 = `${text}`; + + 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"); +} diff --git a/modules/submap.js b/modules/submap.js index 0544ba71a..0918d7150 100644 --- a/modules/submap.js +++ b/modules/submap.js @@ -276,7 +276,7 @@ window.Submap = (function () { drawStates(); drawBorders(); - BurgsAndStates.drawStateLabels(); + drawStateLabels(); Rivers.specify(); Lakes.generateName(); diff --git a/modules/ui/heightmap-editor.js b/modules/ui/heightmap-editor.js index bde67df79..410317860 100644 --- a/modules/ui/heightmap-editor.js +++ b/modules/ui/heightmap-editor.js @@ -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(); diff --git a/modules/ui/provinces-editor.js b/modules/ui/provinces-editor.js index f3c7660fa..07ee522d0 100644 --- a/modules/ui/provinces-editor.js +++ b/modules/ui/provinces-editor.js @@ -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() { > - - - + + + Urban: - -

Total population: ${l(total)} ⇒ ${l(total)} (100%)

`; + +

Total population: ${l(total)} ⇒ ${l( + total + )} (100%)

`; 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); diff --git a/modules/ui/tools.js b/modules/ui/tools.js index af04f4f29..4f00d983f 100644 --- a/modules/ui/tools.js +++ b/modules/ui/tools.js @@ -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(); @@ -836,7 +836,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"); From 2d89aaee944d21de1d98fa502f425e1607ef9343 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Fri, 11 Aug 2023 18:27:16 +0400 Subject: [PATCH 2/4] feat: update old .map files --- index.css | 2 +- index.html | 4 +- modules/burgs-and-states.js | 217 --------------------------- modules/dynamic/auto-update.js | 7 + modules/renderers/drawStatelabels.js | 14 +- modules/ui/labels-editor.js | 25 ++- modules/ui/tools.js | 5 +- versioning.js | 2 +- 8 files changed, 31 insertions(+), 245 deletions(-) diff --git a/index.css b/index.css index 4bd1a328b..505b07160 100644 --- a/index.css +++ b/index.css @@ -263,7 +263,7 @@ i.icon-lock { } #labels { - text-anchor: start; + text-anchor: middle; dominant-baseline: central; cursor: pointer; } diff --git a/index.html b/index.html index b13b0df4e..752da95bd 100644 --- a/index.html +++ b/index.html @@ -138,7 +138,7 @@ } - + @@ -7947,7 +7947,7 @@ - + diff --git a/modules/burgs-and-states.js b/modules/burgs-and-states.js index d41ea9bcb..ff433f465 100644 --- a/modules/burgs-and-states.js +++ b/modules/burgs-and-states.js @@ -502,223 +502,6 @@ window.BurgsAndStates = (function () { TIME && console.timeEnd("updateCulturesForBurgsAndStates"); }; - // calculate and draw curved state labels for a list of states - const drawStateLabelsOld = 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 chain = connectCenters(voronoi.vertices, s.pole[1]); - const voronoi = new Voronoi(delaunay, points, points.length); - 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 `${l}`; - }); - - 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 = `${text}`; - - 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"); diff --git a/modules/dynamic/auto-update.js b/modules/dynamic/auto-update.js index 5bcb2c2bb..fcd122735 100644 --- a/modules/dynamic/auto-update.js +++ b/modules/dynamic/auto-update.js @@ -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); + }); + } } diff --git a/modules/renderers/drawStatelabels.js b/modules/renderers/drawStatelabels.js index d51e3e666..0c82a4cd7 100644 --- a/modules/renderers/drawStatelabels.js +++ b/modules/renderers/drawStatelabels.js @@ -1,6 +1,7 @@ "use strict"; -function drawStateLabels() { +// list - an optional array of stateIds to regenerate +function drawStateLabels(list) { console.time("drawStateLabels"); const {cells, states, features} = pack; @@ -22,7 +23,8 @@ function drawStateLabels() { const labelPaths = []; for (const state of states) { - if (!state.i || state.removed || state.locked) continue; + 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; @@ -115,17 +117,17 @@ function drawStateLabels() { 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("x", 0).text("Example"); + 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"); + 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("#textPath_stateLabel" + stateId).remove(); - pathGroup.select("#stateLabel" + stateId).remove(); + textGroup.select("#stateLabel" + stateId).remove(); + pathGroup.select("#textPath_stateLabel" + stateId).remove(); const textPath = pathGroup .append("path") diff --git a/modules/ui/labels-editor.js b/modules/ui/labels-editor.js index 8bd04cdd2..d19de7ae3 100644 --- a/modules/ui/labels-editor.js +++ b/modules/ui/labels-editor.js @@ -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 `${l}`; - }) - .join(""); - - 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 (lines.length > 1) { + const top = (lines.length - 1) / -2; // y offset + el.innerHTML = lines.map((line, index) => `${line}`).join(""); + } else el.innerHTML = `${lines}`; + + 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() { diff --git a/modules/ui/tools.js b/modules/ui/tools.js index 4f00d983f..e1d554da8 100644 --- a/modules/ui/tools.js +++ b/modules/ui/tools.js @@ -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 diff --git a/versioning.js b/versioning.js index ab4fb4e1c..fccb2bac7 100644 --- a/versioning.js +++ b/versioning.js @@ -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; From a7099f3b3dbd367cdeada3605ddd2d0fa6edc168 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Fri, 11 Aug 2023 18:48:52 +0400 Subject: [PATCH 3/4] chore: update version hash --- index.html | 16 ++++++++-------- modules/io/load.js | 2 +- modules/ui/editors.js | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index 752da95bd..19dc0d0a5 100644 --- a/index.html +++ b/index.html @@ -7964,15 +7964,15 @@ - + - - + + - - + + @@ -7981,7 +7981,7 @@ - + @@ -8000,14 +8000,14 @@ - + - + diff --git a/modules/io/load.js b/modules/io/load.js index ff4792add..7b39fd4ef 100644 --- a/modules/io/load.js +++ b/modules/io/load.js @@ -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); } diff --git a/modules/ui/editors.js b/modules/ui/editors.js index ab1bcc668..d28abe309 100644 --- a/modules/ui/editors.js +++ b/modules/ui/editors.js @@ -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(); } From 24348490ba523af0b0bf8ed8781a3d12719fa854 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Fri, 11 Aug 2023 18:56:15 +0400 Subject: [PATCH 4/4] fear: add change to the user's changelog --- versioning.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/versioning.js b/versioning.js index fccb2bac7..55be68cb5 100644 --- a/versioning.js +++ b/versioning.js @@ -28,6 +28,7 @@ const version = "1.92.00"; // generator version, update each time
    Latest changes: +
  • New label placement algorithm for states
  • North and South Poles temperature can be set independently
  • More than 70 new heraldic charges
  • Multi-color heraldic charges support
  • @@ -36,8 +37,6 @@ const version = "1.92.00"; // generator version, update each time
  • Google translation support (in Options)
  • Religions can be edited and redrawn like cultures
  • Lock states, provinces, cultures, and religions from regeneration
  • -
  • Heightmap brushes: linear edit option
  • -
  • Data Charts screen

Join our Discord server and Reddit community to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.