refactor: migrate renderers to ts (#1296)

* refactor: migrate renderers to ts

* fix: copilot review
This commit is contained in:
Marc Emmanuel 2026-02-02 11:32:08 +01:00 committed by GitHub
parent e8b0b19ff0
commit 3ba8338508
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2094 additions and 1396 deletions

View file

@ -1,120 +0,0 @@
"use strict";
function drawBorders() {
TIME && console.time("drawBorders");
const {cells, vertices} = pack;
const statePath = [];
const provincePath = [];
const checked = {};
const isLand = cellId => cells.h[cellId] >= 20;
for (let cellId = 0; cellId < cells.i.length; cellId++) {
if (!cells.state[cellId]) continue;
const provinceId = cells.province[cellId];
const stateId = cells.state[cellId];
// bordering cell of another province
if (provinceId) {
const provToCell = cells.c[cellId].find(neibId => {
const neibProvinceId = cells.province[neibId];
return (
neibProvinceId &&
provinceId > neibProvinceId &&
!checked[`prov-${provinceId}-${neibProvinceId}-${cellId}`] &&
cells.state[neibId] === stateId
);
});
if (provToCell !== undefined) {
const addToChecked = cellId => (checked[`prov-${provinceId}-${cells.province[provToCell]}-${cellId}`] = true);
const border = getBorder({type: "province", fromCell: cellId, toCell: provToCell, addToChecked});
if (border) {
provincePath.push(border);
cellId--; // check the same cell again
continue;
}
}
}
// if cell is on state border
const stateToCell = cells.c[cellId].find(neibId => {
const neibStateId = cells.state[neibId];
return isLand(neibId) && stateId > neibStateId && !checked[`state-${stateId}-${neibStateId}-${cellId}`];
});
if (stateToCell !== undefined) {
const addToChecked = cellId => (checked[`state-${stateId}-${cells.state[stateToCell]}-${cellId}`] = true);
const border = getBorder({type: "state", fromCell: cellId, toCell: stateToCell, addToChecked});
if (border) {
statePath.push(border);
cellId--; // check the same cell again
continue;
}
}
}
svg.select("#borders").selectAll("path").remove();
svg.select("#stateBorders").append("path").attr("d", statePath.join(" "));
svg.select("#provinceBorders").append("path").attr("d", provincePath.join(" "));
function getBorder({type, fromCell, toCell, addToChecked}) {
const getType = cellId => cells[type][cellId];
const isTypeFrom = cellId => cellId < cells.i.length && getType(cellId) === getType(fromCell);
const isTypeTo = cellId => cellId < cells.i.length && getType(cellId) === getType(toCell);
addToChecked(fromCell);
const startingVertex = cells.v[fromCell].find(v => vertices.c[v].some(i => isLand(i) && isTypeTo(i)));
if (startingVertex === undefined) return null;
const checkVertex = vertex =>
vertices.c[vertex].some(isTypeFrom) && vertices.c[vertex].some(c => isLand(c) && isTypeTo(c));
const chain = getVerticesLine({vertices, startingVertex, checkCell: isTypeFrom, checkVertex, addToChecked});
if (chain.length > 1) return "M" + chain.map(cellId => vertices.p[cellId]).join(" ");
return null;
}
// connect vertices to chain to form a border
function getVerticesLine({vertices, startingVertex, checkCell, checkVertex, addToChecked}) {
let chain = []; // vertices chain to form a path
let next = startingVertex;
const MAX_ITERATIONS = vertices.c.length;
for (let run = 0; run < 2; run++) {
// first run: from any vertex to a border edge
// second run: from found border edge to another edge
chain = [];
for (let i = 0; i < MAX_ITERATIONS; i++) {
const previous = chain.at(-1);
const current = next;
chain.push(current);
const neibCells = vertices.c[current];
neibCells.map(addToChecked);
const [c1, c2, c3] = neibCells.map(checkCell);
const [v1, v2, v3] = vertices.v[current].map(checkVertex);
const [vertex1, vertex2, vertex3] = vertices.v[current];
if (v1 && vertex1 !== previous && c1 !== c2) next = vertex1;
else if (v2 && vertex2 !== previous && c2 !== c3) next = vertex2;
else if (v3 && vertex3 !== previous && c1 !== c3) next = vertex3;
if (next === current || next === startingVertex) {
if (next === startingVertex) chain.push(startingVertex);
startingVertex = next;
break;
}
}
}
return chain;
}
TIME && console.timeEnd("drawBorders");
}

View file

@ -1,108 +0,0 @@
"use strict";
function drawBurgIcons() {
TIME && console.time("drawBurgIcons");
createIconGroups();
for (const {name} of options.burgs.groups) {
const burgsInGroup = pack.burgs.filter(b => b.group === name && !b.removed);
if (!burgsInGroup.length) continue;
const iconsGroup = document.querySelector("#burgIcons > g#" + name);
if (!iconsGroup) continue;
const icon = iconsGroup.dataset.icon || "#icon-circle";
iconsGroup.innerHTML = burgsInGroup
.map(b => `<use id="burg${b.i}" data-id="${b.i}" href="${icon}" x="${b.x}" y="${b.y}"></use>`)
.join("");
const portsInGroup = burgsInGroup.filter(b => b.port);
if (!portsInGroup.length) continue;
const portGroup = document.querySelector("#anchors > g#" + name);
if (!portGroup) continue;
portGroup.innerHTML = portsInGroup
.map(b => `<use id="anchor${b.i}" data-id="${b.i}" href="#icon-anchor" x="${b.x}" y="${b.y}"></use>`)
.join("");
}
TIME && console.timeEnd("drawBurgIcons");
}
function drawBurgIcon(burg) {
const iconGroup = burgIcons.select("#" + burg.group);
if (iconGroup.empty()) {
drawBurgIcons();
return; // redraw all icons if group is missing
}
removeBurgIcon(burg.i);
const icon = iconGroup.attr("data-icon") || "#icon-circle";
burgIcons
.select("#" + burg.group)
.append("use")
.attr("href", icon)
.attr("id", "burg" + burg.i)
.attr("data-id", burg.i)
.attr("x", burg.x)
.attr("y", burg.y);
if (burg.port) {
anchors
.select("#" + burg.group)
.append("use")
.attr("href", "#icon-anchor")
.attr("id", "anchor" + burg.i)
.attr("data-id", burg.i)
.attr("x", burg.x)
.attr("y", burg.y);
}
}
function removeBurgIcon(burgId) {
const existingIcon = document.getElementById("burg" + burgId);
if (existingIcon) existingIcon.remove();
const existingAnchor = document.getElementById("anchor" + burgId);
if (existingAnchor) existingAnchor.remove();
}
function createIconGroups() {
// save existing styles and remove all groups
document.querySelectorAll("g#burgIcons > g").forEach(group => {
style.burgIcons[group.id] = Array.from(group.attributes).reduce((acc, attribute) => {
acc[attribute.name] = attribute.value;
return acc;
}, {});
group.remove();
});
document.querySelectorAll("g#anchors > g").forEach(group => {
style.anchors[group.id] = Array.from(group.attributes).reduce((acc, attribute) => {
acc[attribute.name] = attribute.value;
return acc;
}, {});
group.remove();
});
// create groups for each burg group and apply stored or default style
const defaultIconStyle = style.burgIcons.town || Object.values(style.burgIcons)[0] || {};
const defaultAnchorStyle = style.anchors.town || Object.values(style.anchors)[0] || {};
const sortedGroups = [...options.burgs.groups].sort((a, b) => a.order - b.order);
for (const {name} of sortedGroups) {
const burgGroup = burgIcons.append("g");
const iconStyles = style.burgIcons[name] || defaultIconStyle;
Object.entries(iconStyles).forEach(([key, value]) => {
burgGroup.attr(key, value);
});
burgGroup.attr("id", name);
const anchorGroup = anchors.append("g");
const anchorStyles = style.anchors[name] || defaultAnchorStyle;
Object.entries(anchorStyles).forEach(([key, value]) => {
anchorGroup.attr(key, value);
});
anchorGroup.attr("id", name);
}
}

View file

@ -1,84 +0,0 @@
"use strict";
function drawBurgLabels() {
TIME && console.time("drawBurgLabels");
createLabelGroups();
for (const {name} of options.burgs.groups) {
const burgsInGroup = pack.burgs.filter(b => b.group === name && !b.removed);
if (!burgsInGroup.length) continue;
const labelGroup = burgLabels.select("#" + name);
if (labelGroup.empty()) continue;
const dx = labelGroup.attr("data-dx") || 0;
const dy = labelGroup.attr("data-dy") || 0;
labelGroup
.selectAll("text")
.data(burgsInGroup)
.enter()
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("id", d => "burgLabel" + d.i)
.attr("data-id", d => d.i)
.attr("x", d => d.x)
.attr("y", d => d.y)
.attr("dx", dx + "em")
.attr("dy", dy + "em")
.text(d => d.name);
}
TIME && console.timeEnd("drawBurgLabels");
}
function drawBurgLabel(burg) {
const labelGroup = burgLabels.select("#" + burg.group);
if (labelGroup.empty()) {
drawBurgLabels();
return; // redraw all labels if group is missing
}
const dx = labelGroup.attr("data-dx") || 0;
const dy = labelGroup.attr("data-dy") || 0;
removeBurgLabel(burg.i);
labelGroup
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("id", "burgLabel" + burg.i)
.attr("data-id", burg.i)
.attr("x", burg.x)
.attr("y", burg.y)
.attr("dx", dx + "em")
.attr("dy", dy + "em")
.text(burg.name);
}
function removeBurgLabel(burgId) {
const existingLabel = document.getElementById("burgLabel" + burgId);
if (existingLabel) existingLabel.remove();
}
function createLabelGroups() {
// save existing styles and remove all groups
document.querySelectorAll("g#burgLabels > g").forEach(group => {
style.burgLabels[group.id] = Array.from(group.attributes).reduce((acc, attribute) => {
acc[attribute.name] = attribute.value;
return acc;
}, {});
group.remove();
});
// create groups for each burg group and apply stored or default style
const defaultStyle = style.burgLabels.town || Object.values(style.burgLabels)[0] || {};
const sortedGroups = [...options.burgs.groups].sort((a, b) => a.order - b.order);
for (const {name} of sortedGroups) {
const group = burgLabels.append("g");
const styles = style.burgLabels[name] || defaultStyle;
Object.entries(styles).forEach(([key, value]) => {
group.attr(key, value);
});
group.attr("id", name);
}
}

View file

@ -1,129 +0,0 @@
"use strict";
function drawEmblems() {
TIME && console.time("drawEmblems");
const {states, provinces, burgs} = pack;
const validStates = states.filter(s => s.i && !s.removed && s.coa && s.coa.size !== 0);
const validProvinces = provinces.filter(p => p.i && !p.removed && p.coa && p.coa.size !== 0);
const validBurgs = burgs.filter(b => b.i && !b.removed && b.coa && b.coa.size !== 0);
const getStateEmblemsSize = () => {
const startSize = minmax((graphHeight + graphWidth) / 40, 10, 100);
const statesMod = 1 + validStates.length / 100 - (15 - validStates.length) / 200; // states number modifier
const sizeMod = +emblems.select("#stateEmblems").attr("data-size") || 1;
return rn((startSize / statesMod) * sizeMod); // target size ~50px on 1536x754 map with 15 states
};
const getProvinceEmblemsSize = () => {
const startSize = minmax((graphHeight + graphWidth) / 100, 5, 70);
const provincesMod = 1 + validProvinces.length / 1000 - (115 - validProvinces.length) / 1000; // states number modifier
const sizeMod = +emblems.select("#provinceEmblems").attr("data-size") || 1;
return rn((startSize / provincesMod) * sizeMod); // target size ~20px on 1536x754 map with 115 provinces
};
const getBurgEmblemSize = () => {
const startSize = minmax((graphHeight + graphWidth) / 185, 2, 50);
const burgsMod = 1 + validBurgs.length / 1000 - (450 - validBurgs.length) / 1000; // states number modifier
const sizeMod = +emblems.select("#burgEmblems").attr("data-size") || 1;
return rn((startSize / burgsMod) * sizeMod); // target size ~8.5px on 1536x754 map with 450 burgs
};
const sizeBurgs = getBurgEmblemSize();
const burgCOAs = validBurgs.map(burg => {
const {x, y} = burg;
const size = burg.coa.size || 1;
const shift = (sizeBurgs * size) / 2;
return {type: "burg", i: burg.i, x: burg.coa.x || x, y: burg.coa.y || y, size, shift};
});
const sizeProvinces = getProvinceEmblemsSize();
const provinceCOAs = validProvinces.map(province => {
const [x, y] = province.pole || pack.cells.p[province.center];
const size = province.coa.size || 1;
const shift = (sizeProvinces * size) / 2;
return {type: "province", i: province.i, x: province.coa.x || x, y: province.coa.y || y, size, shift};
});
const sizeStates = getStateEmblemsSize();
const stateCOAs = validStates.map(state => {
const [x, y] = state.pole || pack.cells.p[state.center];
const size = state.coa.size || 1;
const shift = (sizeStates * size) / 2;
return {type: "state", i: state.i, x: state.coa.x || x, y: state.coa.y || y, size, shift};
});
const nodes = burgCOAs.concat(provinceCOAs).concat(stateCOAs);
const simulation = d3
.forceSimulation(nodes)
.alphaMin(0.6)
.alphaDecay(0.2)
.velocityDecay(0.6)
.force(
"collision",
d3.forceCollide().radius(d => d.shift)
)
.stop();
d3.timeout(function () {
const n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()));
for (let i = 0; i < n; ++i) {
simulation.tick();
}
const burgNodes = nodes.filter(node => node.type === "burg");
const burgString = burgNodes
.map(
d =>
`<use data-i="${d.i}" x="${rn(d.x - d.shift)}" y="${rn(d.y - d.shift)}" width="${d.size}em" height="${
d.size
}em"/>`
)
.join("");
emblems.select("#burgEmblems").attr("font-size", sizeBurgs).html(burgString);
const provinceNodes = nodes.filter(node => node.type === "province");
const provinceString = provinceNodes
.map(
d =>
`<use data-i="${d.i}" x="${rn(d.x - d.shift)}" y="${rn(d.y - d.shift)}" width="${d.size}em" height="${
d.size
}em"/>`
)
.join("");
emblems.select("#provinceEmblems").attr("font-size", sizeProvinces).html(provinceString);
const stateNodes = nodes.filter(node => node.type === "state");
const stateString = stateNodes
.map(
d =>
`<use data-i="${d.i}" x="${rn(d.x - d.shift)}" y="${rn(d.y - d.shift)}" width="${d.size}em" height="${
d.size
}em"/>`
)
.join("");
emblems.select("#stateEmblems").attr("font-size", sizeStates).html(stateString);
invokeActiveZooming();
});
TIME && console.timeEnd("drawEmblems");
}
const getDataAndType = id => {
if (id === "burgEmblems") return [pack.burgs, "burg"];
if (id === "provinceEmblems") return [pack.provinces, "province"];
if (id === "stateEmblems") return [pack.states, "state"];
throw new Error(`Unknown emblem type: ${id}`);
};
async function renderGroupCOAs(g) {
const [data, type] = getDataAndType(g.id);
for (let use of g.children) {
const i = +use.dataset.i;
const id = type + "COA" + i;
COArenderer.trigger(id, data[i].coa);
use.setAttribute("href", "#" + id);
}
}

View file

@ -1,66 +0,0 @@
"use strict";
function drawFeatures() {
TIME && console.time("drawFeatures");
const html = {
paths: [],
landMask: [],
waterMask: ['<rect x="0" y="0" width="100%" height="100%" fill="white" />'],
coastline: {},
lakes: {}
};
for (const feature of pack.features) {
if (!feature || feature.type === "ocean") continue;
html.paths.push(`<path d="${getFeaturePath(feature)}" id="feature_${feature.i}" data-f="${feature.i}"></path>`);
if (feature.type === "lake") {
html.landMask.push(`<use href="#feature_${feature.i}" data-f="${feature.i}" fill="black"></use>`);
const lakeGroup = feature.group || "freshwater";
if (!html.lakes[lakeGroup]) html.lakes[lakeGroup] = [];
html.lakes[lakeGroup].push(`<use href="#feature_${feature.i}" data-f="${feature.i}"></use>`);
} else {
html.landMask.push(`<use href="#feature_${feature.i}" data-f="${feature.i}" fill="white"></use>`);
html.waterMask.push(`<use href="#feature_${feature.i}" data-f="${feature.i}" fill="black"></use>`);
const coastlineGroup = feature.group === "lake_island" ? "lake_island" : "sea_island";
if (!html.coastline[coastlineGroup]) html.coastline[coastlineGroup] = [];
html.coastline[coastlineGroup].push(`<use href="#feature_${feature.i}" data-f="${feature.i}"></use>`);
}
}
defs.select("#featurePaths").html(html.paths.join(""));
defs.select("#land").html(html.landMask.join(""));
defs.select("#water").html(html.waterMask.join(""));
coastline.selectAll("g").each(function () {
const paths = html.coastline[this.id] || [];
d3.select(this).html(paths.join(""));
});
lakes.selectAll("g").each(function () {
const paths = html.lakes[this.id] || [];
d3.select(this).html(paths.join(""));
});
TIME && console.timeEnd("drawFeatures");
}
function getFeaturePath(feature) {
const points = feature.vertices.map(vertex => pack.vertices.p[vertex]);
if (points.some(point => point === undefined)) {
ERROR && console.error("Undefined point in getFeaturePath");
return "";
}
const simplifiedPoints = simplify(points, 0.3);
const clippedPoints = clipPoly(simplifiedPoints, 1);
const lineGen = d3.line().curve(d3.curveBasisClosed);
const path = round(lineGen(clippedPoints)) + "Z";
return path;
}

View file

@ -1,70 +0,0 @@
"use strict";
// Ice layer renderer - renders ice from data model to SVG
function drawIce() {
TIME && console.time("drawIce");
// Clear existing ice SVG
ice.selectAll("*").remove();
let html = "";
// Draw all ice elements
pack.ice.forEach(iceElement => {
if (iceElement.type === "glacier") {
html += getGlacierHtml(iceElement);
} else if (iceElement.type === "iceberg") {
html += getIcebergHtml(iceElement);
}
});
ice.html(html);
TIME && console.timeEnd("drawIce");
}
function redrawIceberg(id) {
TIME && console.time("redrawIceberg");
const iceberg = pack.ice.find(element => element.i === id);
let el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`);
if (!iceberg && !el.empty()) {
el.remove();
} else {
if (el.empty()) {
// Create new element if it doesn't exist
const polygon = getIcebergHtml(iceberg);
ice.node().insertAdjacentHTML("beforeend", polygon);
el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`);
}
el.attr("points", iceberg.points);
el.attr("transform", iceberg.offset ? `translate(${iceberg.offset[0]},${iceberg.offset[1]})` : null);
}
TIME && console.timeEnd("redrawIceberg");
}
function redrawGlacier(id) {
TIME && console.time("redrawGlacier");
const glacier = pack.ice.find(element => element.i === id);
let el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`);
if (!glacier && !el.empty()) {
el.remove();
} else {
if (el.empty()) {
// Create new element if it doesn't exist
const polygon = getGlacierHtml(glacier);
ice.node().insertAdjacentHTML("beforeend", polygon);
el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`);
}
el.attr("points", glacier.points);
el.attr("transform", glacier.offset ? `translate(${glacier.offset[0]},${glacier.offset[1]})` : null);
}
TIME && console.timeEnd("redrawGlacier");
}
function getGlacierHtml(glacier) {
return `<polygon points="${glacier.points}" type="glacier" data-id="${glacier.i}" ${glacier.offset ? `transform="translate(${glacier.offset[0]},${glacier.offset[1]})"` : ""}/>`;
}
function getIcebergHtml(iceberg) {
return `<polygon points="${iceberg.points}" data-id="${iceberg.i}" ${iceberg.offset ? `transform="translate(${iceberg.offset[0]},${iceberg.offset[1]})"` : ""}/>`;
}

View file

@ -1,53 +0,0 @@
"use strict";
function drawMarkers() {
TIME && console.time("drawMarkers");
const rescale = +markers.attr("rescale");
const pinned = +markers.attr("pinned");
const markersData = pinned ? pack.markers.filter(({pinned}) => pinned) : pack.markers;
const html = markersData.map(marker => drawMarker(marker, rescale));
markers.html(html.join(""));
TIME && console.timeEnd("drawMarkers");
}
// prettier-ignore
const pinShapes = {
bubble: (fill, stroke) => `<path d="M6,19 l9,10 L24,19" fill="${stroke}" stroke="none" /><circle cx="15" cy="15" r="10" fill="${fill}" stroke="${stroke}"/>`,
pin: (fill, stroke) => `<path d="m 15,3 c -5.5,0 -9.7,4.09 -9.7,9.3 0,6.8 9.7,17 9.7,17 0,0 9.7,-10.2 9.7,-17 C 24.7,7.09 20.5,3 15,3 Z" fill="${fill}" stroke="${stroke}"/>`,
square: (fill, stroke) => `<path d="m 20,25 -5,4 -5,-4 z" fill="${stroke}"/><path d="M 5,5 H 25 V 25 H 5 Z" fill="${fill}" stroke="${stroke}"/>`,
squarish: (fill, stroke) => `<path d="m 5,5 h 20 v 20 h -6 l -4,4 -4,-4 H 5 Z" fill="${fill}" stroke="${stroke}" />`,
diamond: (fill, stroke) => `<path d="M 2,15 15,1 28,15 15,29 Z" fill="${fill}" stroke="${stroke}" />`,
hex: (fill, stroke) => `<path d="M 15,29 4.61,21 V 9 L 15,3 25.4,9 v 12 z" fill="${fill}" stroke="${stroke}" />`,
hexy: (fill, stroke) => `<path d="M 15,29 6,21 5,8 15,4 25,8 24,21 Z" fill="${fill}" stroke="${stroke}" />`,
shieldy: (fill, stroke) => `<path d="M 15,29 6,21 5,7 c 0,0 5,-3 10,-3 5,0 10,3 10,3 l -1,14 z" fill="${fill}" stroke="${stroke}" />`,
shield: (fill, stroke) => `<path d="M 4.6,5.2 H 25 v 6.7 A 20.3,20.4 0 0 1 15,29 20.3,20.4 0 0 1 4.6,11.9 Z" fill="${fill}" stroke="${stroke}" />`,
pentagon: (fill, stroke) => `<path d="M 4,16 9,4 h 12 l 5,12 -11,13 z" fill="${fill}" stroke="${stroke}" />`,
heptagon: (fill, stroke) => `<path d="M 15,29 6,22 4,12 10,4 h 10 l 6,8 -2,10 z" fill="${fill}" stroke="${stroke}" />`,
circle: (fill, stroke) => `<circle cx="15" cy="15" r="11" fill="${fill}" stroke="${stroke}" />`,
no: () => ""
};
const getPin = (shape = "bubble", fill = "#fff", stroke = "#000") => {
const shapeFunction = pinShapes[shape] || pinShapes.bubble;
return shapeFunction(fill, stroke);
};
function drawMarker(marker, rescale = 1) {
const {i, icon, x, y, dx = 50, dy = 50, px = 12, size = 30, pin, fill, stroke} = marker;
const id = `marker${i}`;
const zoomSize = rescale ? Math.max(rn(size / 5 + 24 / scale, 2), 1) : size;
const viewX = rn(x - zoomSize / 2, 1);
const viewY = rn(y - zoomSize, 1);
const isExternal = icon.startsWith("http") || icon.startsWith("data:image");
return /* html */ `
<svg id="${id}" viewbox="0 0 30 30" width="${zoomSize}" height="${zoomSize}" x="${viewX}" y="${viewY}">
<g>${getPin(pin, fill, stroke)}</g>
<text x="${dx}%" y="${dy}%" font-size="${px}px" >${isExternal ? "" : icon}</text>
<image x="${dx / 2}%" y="${dy / 2}%" width="${px}px" height="${px}px" href="${isExternal ? icon : ""}" />
</svg>`;
}

View file

@ -1,155 +0,0 @@
"use strict";
function drawMilitary() {
TIME && console.time("drawMilitary");
armies.selectAll("g").remove();
pack.states.filter(s => s.i && !s.removed).forEach(s => drawRegiments(s.military, s.i));
TIME && console.timeEnd("drawMilitary");
}
const drawRegiments = function (regiments, s) {
const size = +armies.attr("box-size");
const w = d => (d.n ? size * 4 : size * 6);
const h = size * 2;
const x = d => rn(d.x - w(d) / 2, 2);
const y = d => rn(d.y - size, 2);
const baseColor = pack.states[s].color[0] === "#" ? pack.states[s].color : "#999";
const darkerColor = d3.color(baseColor).darker().hex();
const army = armies
.append("g")
.attr("id", "army" + s)
.attr("fill", baseColor)
.attr("color", darkerColor);
const g = army
.selectAll("g")
.data(regiments)
.enter()
.append("g")
.attr("id", d => "regiment" + s + "-" + d.i)
.attr("data-name", d => d.name)
.attr("data-state", s)
.attr("data-id", d => d.i)
.attr("transform", d => (d.angle ? `rotate(${d.angle})` : null))
.attr("transform-origin", d => `${d.x}px ${d.y}px`);
g.append("rect")
.attr("x", d => x(d))
.attr("y", d => y(d))
.attr("width", d => w(d))
.attr("height", h);
g.append("text")
.attr("x", d => d.x)
.attr("y", d => d.y)
.attr("text-rendering", "optimizeSpeed")
.text(d => Military.getTotal(d));
g.append("rect")
.attr("fill", "currentColor")
.attr("x", d => x(d) - h)
.attr("y", d => y(d))
.attr("width", h)
.attr("height", h);
g.append("text")
.attr("class", "regimentIcon")
.attr("text-rendering", "optimizeSpeed")
.attr("x", d => x(d) - size)
.attr("y", d => d.y)
.text(d => (d.icon.startsWith("http") || d.icon.startsWith("data:image") ? "" : d.icon));
g.append("image")
.attr("class", "regimentImage")
.attr("x", d => x(d) - h)
.attr("y", d => y(d))
.attr("height", h)
.attr("width", h)
.attr("href", d => (d.icon.startsWith("http") || d.icon.startsWith("data:image") ? d.icon : ""));
};
const drawRegiment = function (reg, stateId) {
const size = +armies.attr("box-size");
const w = reg.n ? size * 4 : size * 6;
const h = size * 2;
const x1 = rn(reg.x - w / 2, 2);
const y1 = rn(reg.y - size, 2);
let army = armies.select("g#army" + stateId);
if (!army.size()) {
const baseColor = pack.states[stateId].color[0] === "#" ? pack.states[stateId].color : "#999";
const darkerColor = d3.color(baseColor).darker().hex();
army = armies
.append("g")
.attr("id", "army" + stateId)
.attr("fill", baseColor)
.attr("color", darkerColor);
}
const g = army
.append("g")
.attr("id", "regiment" + stateId + "-" + reg.i)
.attr("data-name", reg.name)
.attr("data-state", stateId)
.attr("data-id", reg.i)
.attr("transform", `rotate(${reg.angle || 0})`)
.attr("transform-origin", `${reg.x}px ${reg.y}px`);
g.append("rect").attr("x", x1).attr("y", y1).attr("width", w).attr("height", h);
g.append("text")
.attr("x", reg.x)
.attr("y", reg.y)
.attr("text-rendering", "optimizeSpeed")
.text(Military.getTotal(reg));
g.append("rect")
.attr("fill", "currentColor")
.attr("x", x1 - h)
.attr("y", y1)
.attr("width", h)
.attr("height", h);
g.append("text")
.attr("class", "regimentIcon")
.attr("text-rendering", "optimizeSpeed")
.attr("x", x1 - size)
.attr("y", reg.y)
.text(reg.icon.startsWith("http") || reg.icon.startsWith("data:image") ? "" : reg.icon);
g.append("image")
.attr("class", "regimentImage")
.attr("x", x1 - h)
.attr("y", y1)
.attr("height", h)
.attr("width", h)
.attr("href", reg.icon.startsWith("http") || reg.icon.startsWith("data:image") ? reg.icon : "");
};
// move one regiment to another
const moveRegiment = function (reg, x, y) {
const el = armies.select("g#army" + reg.state).select("g#regiment" + reg.state + "-" + reg.i);
if (!el.size()) return;
const duration = Math.hypot(reg.x - x, reg.y - y) * 8;
reg.x = x;
reg.y = y;
const size = +armies.attr("box-size");
const w = reg.n ? size * 4 : size * 6;
const h = size * 2;
const x1 = x => rn(x - w / 2, 2);
const y1 = y => rn(y - size, 2);
const move = d3.transition().duration(duration).ease(d3.easeSinInOut);
el.select("rect").transition(move).attr("x", x1(x)).attr("y", y1(y));
el.select("text").transition(move).attr("x", x).attr("y", y);
el.selectAll("rect:nth-of-type(2)")
.transition(move)
.attr("x", x1(x) - h)
.attr("y", y1(y));
el.select(".regimentIcon")
.transition(move)
.attr("x", x1(x) - size)
.attr("y", y)
.attr("height", "6")
.attr("width", "6");
el.select(".regimentImage")
.transition(move)
.attr("x", x1(x) - h)
.attr("y", y1(y))
.attr("height", "6")
.attr("width", "6");
};

View file

@ -1,124 +0,0 @@
"use strict";
function drawReliefIcons() {
TIME && console.time("drawRelief");
terrain.selectAll("*").remove();
const cells = pack.cells;
const density = terrain.attr("density") || 0.4;
const size = 2 * (terrain.attr("size") || 1);
const mod = 0.2 * size; // size modifier
const relief = [];
for (const i of cells.i) {
const height = cells.h[i];
if (height < 20) continue; // no icons on water
if (cells.r[i]) continue; // no icons on rivers
const biome = cells.biome[i];
if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; // no icons for this biome
const polygon = getPackPolygon(i);
const [minX, maxX] = d3.extent(polygon, p => p[0]);
const [minY, maxY] = d3.extent(polygon, p => p[1]);
if (height < 50) placeBiomeIcons(i, biome);
else placeReliefIcons(i);
function placeBiomeIcons() {
const iconsDensity = biomesData.iconsDensity[biome] / 100;
const radius = 2 / iconsDensity / density;
if (Math.random() > iconsDensity * 10) return;
for (const [cx, cy] of poissonDiscSampler(minX, minY, maxX, maxY, radius)) {
if (!d3.polygonContains(polygon, [cx, cy])) continue;
let h = (4 + Math.random()) * size;
const icon = getBiomeIcon(i, biomesData.icons[biome]);
if (icon === "#relief-grass-1") h *= 1.2;
relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)});
}
}
function placeReliefIcons(i) {
const radius = 2 / density;
const [icon, h] = getReliefIcon(i, height);
for (const [cx, cy] of poissonDiscSampler(minX, minY, maxX, maxY, radius)) {
if (!d3.polygonContains(polygon, [cx, cy])) continue;
relief.push({i: icon, x: rn(cx - h, 2), y: rn(cy - h, 2), s: rn(h * 2, 2)});
}
}
function getReliefIcon(i, h) {
const temp = grid.cells.temp[pack.cells.g[i]];
const type = h > 70 && temp < 0 ? "mountSnow" : h > 70 ? "mount" : "hill";
const size = h > 70 ? (h - 45) * mod : minmax((h - 40) * mod, 3, 6);
return [getIcon(type), size];
}
}
// sort relief icons by y+size
relief.sort((a, b) => a.y + a.s - (b.y + b.s));
const reliefHTML = new Array(relief.length);
for (const r of relief) {
reliefHTML.push(`<use href="${r.i}" x="${r.x}" y="${r.y}" width="${r.s}" height="${r.s}"/>`);
}
terrain.html(reliefHTML.join(""));
TIME && console.timeEnd("drawRelief");
function getBiomeIcon(i, b) {
let type = b[Math.floor(Math.random() * b.length)];
const temp = grid.cells.temp[pack.cells.g[i]];
if (type === "conifer" && temp < 0) type = "coniferSnow";
return getIcon(type);
}
function getVariant(type) {
switch (type) {
case "mount":
return rand(2, 7);
case "mountSnow":
return rand(1, 6);
case "hill":
return rand(2, 5);
case "conifer":
return 2;
case "coniferSnow":
return 1;
case "swamp":
return rand(2, 3);
case "cactus":
return rand(1, 3);
case "deadTree":
return rand(1, 2);
default:
return 2;
}
}
function getOldIcon(type) {
switch (type) {
case "mountSnow":
return "mount";
case "vulcan":
return "mount";
case "coniferSnow":
return "conifer";
case "cactus":
return "dune";
case "deadTree":
return "dune";
default:
return type;
}
}
function getIcon(type) {
const set = terrain.attr("set") || "simple";
if (set === "simple") return "#relief-" + getOldIcon(type) + "-1";
if (set === "colored") return "#relief-" + type + "-" + getVariant(type);
if (set === "gray") return "#relief-" + type + "-" + getVariant(type) + "-bw";
return "#relief-" + getOldIcon(type) + "-1"; // simple
}
}

View file

@ -1,312 +0,0 @@
"use strict";
// list - an optional array of stateIds to regenerate
function drawStateLabels(list) {
TIME && console.time("drawStateLabels");
// temporary make the labels visible
const layerDisplay = labels.style("display");
labels.style("display", null);
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 angles = precalculateAngles(ANGLE_STEP);
const LENGTH_START = 5;
const LENGTH_STEP = 5;
const LENGTH_MAX = 300;
const labelPaths = getLabelPaths();
const letterLength = checkExampleLetterLength();
drawLabelPath(letterLength);
// restore labels visibility
labels.style("display", layerDisplay);
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 / 20;
const [x0, y0] = state.pole;
const rays = angles.map(({angle, dx, dy}) => {
const {length, x, y} = raycast({stateId: state.i, x0, y0, dx, dy, maxLakeSize, offset});
return {angle, length, x, y};
});
const [ray1, ray2] = findBestRayPair(rays);
const pathPoints = [[ray1.x, ray1.y], state.pole, [ray2.x, ray2.y]];
if (ray1.x > ray2.x) pathPoints.reverse();
if (DEBUG.stateLabels) {
drawPoint(state.pole, {color: "black", radius: 1});
drawPath(pathPoints, {color: "black", width: 0.2});
}
labelPaths.push([state.i, pathPoints]);
}
return labelPaths;
}
function checkExampleLetterLength() {
const textGroup = d3.select("g#labels > g#states");
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();
return letterLength;
}
function drawLabelPath(letterLength) {
const mode = options.stateLabelsMode || "auto";
const lineGen = d3.line().curve(d3.curveNatural);
const textGroup = d3.select("g#labels > g#states");
const pathGroup = d3.select("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
.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("text-rendering", "optimizeSpeed")
.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), 50, 130);
textElement.setAttribute("font-size", correctedRatio + "%");
}
}
function getOffsetWidth(cellsNumber) {
if (cellsNumber < 40) return 0;
if (cellsNumber < 200) return 5;
return 10;
}
function precalculateAngles(step) {
const angles = [];
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}) {
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 = [x + -dy * offset, y + dx * offset];
const offset2 = [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, y) {
if (x < 0 || x > graphWidth || y < 0 || y > graphHeight) return false;
const cellId = findCell(x, y);
const feature = features[cells.f[cellId]];
if (feature.type === "lake") return isInnerLake(feature) || isSmallLake(feature);
return stateIds[cellId] === stateId;
}
function isInnerLake(feature) {
return feature.shoreline.every(cellId => stateIds[cellId] === stateId);
}
function isSmallLake(feature) {
return feature.cells <= maxLakeSize;
}
}
function findBestRayPair(rays) {
let bestPair = 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) {
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, angle2) {
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, angle2) {
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, angle2) {
const proximity1 = Math.abs((angle1 % 180) - 90);
const proximity2 = Math.abs((angle2 % 180) - 90);
return 1 - Math.abs(proximity1 - proximity2) / 90;
}
function getLinesAndRatio(mode, name, fullName, pathLength) {
if (mode === "short") return getShortOneLine();
if (pathLength > fullName.length * 2) return getFullOneLine();
return getFullTwoLines();
function getShortOneLine() {
const ratio = pathLength / name.length;
return [[name], minmax(rn(ratio * 60), 50, 150)];
}
function getFullOneLine() {
const ratio = pathLength / fullName.length;
return [[fullName], minmax(rn(ratio * 70), 70, 170)];
}
function getFullTwoLines() {
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;
}
TIME && console.timeEnd("drawStateLabels");
}

View file

@ -1,104 +0,0 @@
"use strict";
function drawTemperature() {
TIME && console.time("drawTemperature");
temperature.selectAll("*").remove();
lineGen.curve(d3.curveBasisClosed);
const scheme = d3.scaleSequential(d3.interpolateSpectral);
const tMax = +byId("temperatureEquatorOutput").max;
const tMin = +byId("temperatureEquatorOutput").min;
const delta = tMax - tMin;
const {cells, vertices} = grid;
const n = cells.i.length;
const checkedCells = new Uint8Array(n);
const addToChecked = cellId => (checkedCells[cellId] = 1);
const min = d3.min(cells.temp);
const max = d3.max(cells.temp);
const step = Math.max(Math.round(Math.abs(min - max) / 5), 1);
const isolines = d3.range(min + step, max, step);
const chains = [];
const labels = []; // store label coordinates
for (const cellId of cells.i) {
const t = cells.temp[cellId];
if (checkedCells[cellId] || !isolines.includes(t)) continue;
const startingVertex = findStart(cellId, t);
if (!startingVertex) continue;
checkedCells[cellId] = 1;
const ofSameType = cellId => cells.temp[cellId] >= t;
const chain = connectVertices({vertices, startingVertex, ofSameType, addToChecked});
const relaxed = chain.filter((v, i) => i % 4 === 0 || vertices.c[v].some(c => c >= n));
if (relaxed.length < 6) continue;
const points = relaxed.map(v => vertices.p[v]);
chains.push([t, points]);
addLabel(points, t);
}
// min temp isoline covers all graph
temperature
.append("path")
.attr("d", `M0,0 h${graphWidth} v${graphHeight} h${-graphWidth} Z`)
.attr("fill", scheme(1 - (min - tMin) / delta))
.attr("stroke", "none");
for (const t of isolines) {
const path = chains
.filter(c => c[0] === t)
.map(c => round(lineGen(c[1])))
.join("");
if (!path) continue;
const fill = scheme(1 - (t - tMin) / delta),
stroke = d3.color(fill).darker(0.2);
temperature.append("path").attr("d", path).attr("fill", fill).attr("stroke", stroke);
}
const tempLabels = temperature.append("g").attr("id", "tempLabels").attr("fill-opacity", 1);
tempLabels
.selectAll("text")
.data(labels)
.enter()
.append("text")
.attr("x", d => d[0])
.attr("y", d => d[1])
.text(d => convertTemperature(d[2]));
// find cell with temp < isotherm and find vertex to start path detection
function findStart(i, t) {
if (cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= n)); // map border cell
return cells.v[i][cells.c[i].findIndex(c => cells.temp[c] < t || !cells.temp[c])];
}
function addLabel(points, t) {
const xCenter = svgWidth / 2;
// add label on isoline top center
const tc =
points[d3.scan(points, (a, b) => a[1] - b[1] + (Math.abs(a[0] - xCenter) - Math.abs(b[0] - xCenter)) / 2)];
pushLabel(tc[0], tc[1], t);
// add label on isoline bottom center
if (points.length > 20) {
const bc =
points[d3.scan(points, (a, b) => b[1] - a[1] + (Math.abs(a[0] - xCenter) - Math.abs(b[0] - xCenter)) / 2)];
const dist2 = (tc[1] - bc[1]) ** 2 + (tc[0] - bc[0]) ** 2; // square distance between this and top point
if (dist2 > 100) pushLabel(bc[0], bc[1], t);
}
}
function pushLabel(x, y, t) {
if (x < 20 || x > svgWidth - 20) return;
if (y < 20 || y > svgHeight - 20) return;
labels.push([x, y, t]);
}
TIME && console.timeEnd("drawTemperature");
}

View file

@ -8490,6 +8490,7 @@
<script type="module" src="utils/index.ts"></script>
<script type="module" src="modules/index.ts"></script>
<script type="module" src="renderers/index.ts"></script>
<script defer src="config/heightmap-templates.js"></script>
<script defer src="config/precreated-heightmaps.js"></script>
@ -8560,19 +8561,5 @@
<script defer src="modules/io/load.js?v=1.111.0"></script>
<script defer src="modules/io/cloud.js?v=1.106.0"></script>
<script defer src="modules/io/export.js?v=1.108.13"></script>
<script defer src="modules/renderers/draw-features.js?v=1.108.2"></script>
<script defer src="modules/renderers/draw-borders.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-heightmap.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-markers.js?v=1.108.5"></script>
<script defer src="modules/renderers/draw-scalebar.js?v=1.108.1"></script>
<script defer src="modules/renderers/draw-temperature.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-emblems.js?v=1.104.0"></script>
<script defer src="modules/renderers/draw-military.js?v=1.108.5"></script>
<script defer src="modules/renderers/draw-state-labels.js?v=1.108.1"></script>
<script defer src="modules/renderers/draw-burg-labels.js?v=1.109.4"></script>
<script defer src="modules/renderers/draw-burg-icons.js?v=1.109.4"></script>
<script defer src="modules/renderers/draw-relief-icons.js?v=1.108.4"></script>
<script defer src="modules/renderers/draw-ice.js?v=1.111.0"></script>
</body>
</html>

View file

@ -727,8 +727,8 @@ class BurgModule {
delete burg.coa;
}
removeBurgIcon(burg.i);
removeBurgLabel(burg.i);
removeBurgIcon(burg.i!);
removeBurgLabel(burg.i!);
}
}
window.Burgs = new BurgModule();

View file

@ -50,6 +50,7 @@ export interface State {
formName?: string;
fullName?: string;
form?: string;
military?: any[];
provinces?: number[];
}

View file

@ -0,0 +1,181 @@
declare global {
var drawBorders: () => void;
}
const bordersRenderer = () => {
TIME && console.time("drawBorders");
const { cells, vertices } = pack;
const statePath: string[] = [];
const provincePath: string[] = [];
const checked: { [key: string]: boolean } = {};
const isLand = (cellId: number) => cells.h[cellId] >= 20;
for (let cellId = 0; cellId < cells.i.length; cellId++) {
if (!cells.state[cellId]) continue;
const provinceId = cells.province[cellId];
const stateId = cells.state[cellId];
// bordering cell of another province
if (provinceId) {
const provToCell = cells.c[cellId].find((neibId) => {
const neibProvinceId = cells.province[neibId];
return (
neibProvinceId &&
provinceId > neibProvinceId &&
!checked[`prov-${provinceId}-${neibProvinceId}-${cellId}`] &&
cells.state[neibId] === stateId
);
});
if (provToCell !== undefined) {
const addToChecked = (cellId: number) => {
checked[
`prov-${provinceId}-${cells.province[provToCell]}-${cellId}`
] = true;
};
const border = getBorder({
type: "province",
fromCell: cellId,
toCell: provToCell,
addToChecked,
});
if (border) {
provincePath.push(border);
cellId--; // check the same cell again
continue;
}
}
}
// if cell is on state border
const stateToCell = cells.c[cellId].find((neibId) => {
const neibStateId = cells.state[neibId];
return (
isLand(neibId) &&
stateId > neibStateId &&
!checked[`state-${stateId}-${neibStateId}-${cellId}`]
);
});
if (stateToCell !== undefined) {
const addToChecked = (cellId: number) => {
checked[`state-${stateId}-${cells.state[stateToCell]}-${cellId}`] =
true;
};
const border = getBorder({
type: "state",
fromCell: cellId,
toCell: stateToCell,
addToChecked,
});
if (border) {
statePath.push(border);
cellId--; // check the same cell again
}
}
}
svg.select("#borders").selectAll("path").remove();
svg.select("#stateBorders").append("path").attr("d", statePath.join(" "));
svg
.select("#provinceBorders")
.append("path")
.attr("d", provincePath.join(" "));
function getBorder({
type,
fromCell,
toCell,
addToChecked,
}: {
type: "state" | "province";
fromCell: number;
toCell: number;
addToChecked: (cellId: number) => void;
}): string | null {
const getType = (cellId: number) => cells[type][cellId];
const isTypeFrom = (cellId: number) =>
cellId < cells.i.length && getType(cellId) === getType(fromCell);
const isTypeTo = (cellId: number) =>
cellId < cells.i.length && getType(cellId) === getType(toCell);
addToChecked(fromCell);
const startingVertex = cells.v[fromCell].find((v) =>
vertices.c[v].some((i) => isLand(i) && isTypeTo(i)),
);
if (startingVertex === undefined) return null;
const checkVertex = (vertex: number) =>
vertices.c[vertex].some(isTypeFrom) &&
vertices.c[vertex].some((c) => isLand(c) && isTypeTo(c));
const chain = getVerticesLine({
vertices,
startingVertex,
checkCell: isTypeFrom,
checkVertex,
addToChecked,
});
if (chain.length > 1)
return `M${chain.map((cellId) => vertices.p[cellId]).join(" ")}`;
return null;
}
// connect vertices to chain to form a border
function getVerticesLine({
vertices,
startingVertex,
checkCell,
checkVertex,
addToChecked,
}: {
vertices: typeof pack.vertices;
startingVertex: number;
checkCell: (cellId: number) => boolean;
checkVertex: (vertex: number) => boolean;
addToChecked: (cellId: number) => void;
}) {
let chain = []; // vertices chain to form a path
let next = startingVertex;
const MAX_ITERATIONS = vertices.c.length;
for (let run = 0; run < 2; run++) {
// first run: from any vertex to a border edge
// second run: from found border edge to another edge
chain = [];
for (let i = 0; i < MAX_ITERATIONS; i++) {
const previous = chain.at(-1);
const current = next;
chain.push(current);
const neibCells = vertices.c[current];
neibCells.map(addToChecked);
const [c1, c2, c3] = neibCells.map(checkCell);
const [v1, v2, v3] = vertices.v[current].map(checkVertex);
const [vertex1, vertex2, vertex3] = vertices.v[current];
if (v1 && vertex1 !== previous && c1 !== c2) next = vertex1;
else if (v2 && vertex2 !== previous && c2 !== c3) next = vertex2;
else if (v3 && vertex3 !== previous && c1 !== c3) next = vertex3;
if (next === current || next === startingVertex) {
if (next === startingVertex) chain.push(startingVertex);
startingVertex = next;
break;
}
}
}
return chain;
}
TIME && console.timeEnd("drawBorders");
};
window.drawBorders = bordersRenderer;

View file

@ -0,0 +1,145 @@
import type { Burg } from "../modules/burgs-generator";
declare global {
var drawBurgIcons: () => void;
var drawBurgIcon: (burg: Burg) => void;
var removeBurgIcon: (burgId: number) => void;
}
interface BurgGroup {
name: string;
order: number;
}
const burgIconsRenderer = (): void => {
TIME && console.time("drawBurgIcons");
createIconGroups();
for (const { name } of options.burgs.groups as BurgGroup[]) {
const burgsInGroup = pack.burgs.filter(
(b) => b.group === name && !b.removed,
);
if (!burgsInGroup.length) continue;
const iconsGroup = document.querySelector<SVGGElement>(
`#burgIcons > g#${name}`,
);
if (!iconsGroup) continue;
const icon = iconsGroup.dataset.icon || "#icon-circle";
iconsGroup.innerHTML = burgsInGroup
.map(
(b) =>
`<use id="burg${b.i}" data-id="${b.i}" href="${icon}" x="${b.x}" y="${b.y}"></use>`,
)
.join("");
const portsInGroup = burgsInGroup.filter((b) => b.port);
if (!portsInGroup.length) continue;
const portGroup = document.querySelector<SVGGElement>(
`#anchors > g#${name}`,
);
if (!portGroup) continue;
portGroup.innerHTML = portsInGroup
.map(
(b) =>
`<use id="anchor${b.i}" data-id="${b.i}" href="#icon-anchor" x="${b.x}" y="${b.y}"></use>`,
)
.join("");
}
TIME && console.timeEnd("drawBurgIcons");
};
const drawBurgIconRenderer = (burg: Burg): void => {
const iconGroup = burgIcons.select<SVGGElement>(`#${burg.group}`);
if (iconGroup.empty()) {
drawBurgIcons();
return; // redraw all icons if group is missing
}
removeBurgIconRenderer(burg.i!);
const icon = iconGroup.attr("data-icon") || "#icon-circle";
burgIcons
.select(`#${burg.group}`)
.append("use")
.attr("href", icon)
.attr("id", `burg${burg.i}`)
.attr("data-id", burg.i!)
.attr("x", burg.x)
.attr("y", burg.y);
if (burg.port) {
anchors
.select(`#${burg.group}`)
.append("use")
.attr("href", "#icon-anchor")
.attr("id", `anchor${burg.i}`)
.attr("data-id", burg.i!)
.attr("x", burg.x)
.attr("y", burg.y);
}
};
const removeBurgIconRenderer = (burgId: number): void => {
const existingIcon = document.getElementById(`burg${burgId}`);
if (existingIcon) existingIcon.remove();
const existingAnchor = document.getElementById(`anchor${burgId}`);
if (existingAnchor) existingAnchor.remove();
};
function createIconGroups(): void {
// save existing styles and remove all groups
document.querySelectorAll("g#burgIcons > g").forEach((group) => {
style.burgIcons[group.id] = Array.from(group.attributes).reduce(
(acc: { [key: string]: string }, attribute) => {
acc[attribute.name] = attribute.value;
return acc;
},
{},
);
group.remove();
});
document.querySelectorAll("g#anchors > g").forEach((group) => {
style.anchors[group.id] = Array.from(group.attributes).reduce(
(acc: { [key: string]: string }, attribute) => {
acc[attribute.name] = attribute.value;
return acc;
},
{},
);
group.remove();
});
// create groups for each burg group and apply stored or default style
const defaultIconStyle =
style.burgIcons.town || Object.values(style.burgIcons)[0] || {};
const defaultAnchorStyle =
style.anchors.town || Object.values(style.anchors)[0] || {};
const sortedGroups = [...(options.burgs.groups as BurgGroup[])].sort(
(a, b) => a.order - b.order,
);
for (const { name } of sortedGroups) {
const burgGroup = burgIcons.append("g");
const iconStyles = style.burgIcons[name] || defaultIconStyle;
Object.entries(iconStyles).forEach(([key, value]) => {
burgGroup.attr(key, value);
});
burgGroup.attr("id", name);
const anchorGroup = anchors.append("g");
const anchorStyles = style.anchors[name] || defaultAnchorStyle;
Object.entries(anchorStyles).forEach(([key, value]) => {
anchorGroup.attr(key, value);
});
anchorGroup.attr("id", name);
}
}
window.drawBurgIcons = burgIconsRenderer;
window.drawBurgIcon = drawBurgIconRenderer;
window.removeBurgIcon = removeBurgIconRenderer;

View file

@ -0,0 +1,107 @@
import type { Burg } from "../modules/burgs-generator";
declare global {
var drawBurgLabels: () => void;
var drawBurgLabel: (burg: Burg) => void;
var removeBurgLabel: (burgId: number) => void;
}
interface BurgGroup {
name: string;
order: number;
}
const burgLabelsRenderer = (): void => {
TIME && console.time("drawBurgLabels");
createLabelGroups();
for (const { name } of options.burgs.groups as BurgGroup[]) {
const burgsInGroup = pack.burgs.filter(
(b) => b.group === name && !b.removed,
);
if (!burgsInGroup.length) continue;
const labelGroup = burgLabels.select<SVGGElement>(`#${name}`);
if (labelGroup.empty()) continue;
const dx = labelGroup.attr("data-dx") || 0;
const dy = labelGroup.attr("data-dy") || 0;
labelGroup
.selectAll("text")
.data(burgsInGroup)
.enter()
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("id", (d) => `burgLabel${d.i}`)
.attr("data-id", (d) => d.i!)
.attr("x", (d) => d.x)
.attr("y", (d) => d.y)
.attr("dx", `${dx}em`)
.attr("dy", `${dy}em`)
.text((d) => d.name!);
}
TIME && console.timeEnd("drawBurgLabels");
};
const drawBurgLabelRenderer = (burg: Burg): void => {
const labelGroup = burgLabels.select<SVGGElement>(`#${burg.group}`);
if (labelGroup.empty()) {
drawBurgLabels();
return; // redraw all labels if group is missing
}
const dx = labelGroup.attr("data-dx") || 0;
const dy = labelGroup.attr("data-dy") || 0;
removeBurgLabelRenderer(burg.i!);
labelGroup
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("id", `burgLabel${burg.i}`)
.attr("data-id", burg.i!)
.attr("x", burg.x)
.attr("y", burg.y)
.attr("dx", `${dx}em`)
.attr("dy", `${dy}em`)
.text(burg.name!);
};
const removeBurgLabelRenderer = (burgId: number): void => {
const existingLabel = document.getElementById(`burgLabel${burgId}`);
if (existingLabel) existingLabel.remove();
};
function createLabelGroups(): void {
// save existing styles and remove all groups
document.querySelectorAll("g#burgLabels > g").forEach((group) => {
style.burgLabels[group.id] = Array.from(group.attributes).reduce(
(acc: { [key: string]: string }, attribute) => {
acc[attribute.name] = attribute.value;
return acc;
},
{},
);
group.remove();
});
// create groups for each burg group and apply stored or default style
const defaultStyle =
style.burgLabels.town || Object.values(style.burgLabels)[0] || {};
const sortedGroups = [...(options.burgs.groups as BurgGroup[])].sort(
(a, b) => a.order - b.order,
);
for (const { name } of sortedGroups) {
const group = burgLabels.append("g");
const styles = style.burgLabels[name] || defaultStyle;
Object.entries(styles).forEach(([key, value]) => {
group.attr(key, value);
});
group.attr("id", name);
}
}
window.drawBurgLabels = burgLabelsRenderer;
window.drawBurgLabel = drawBurgLabelRenderer;
window.removeBurgLabel = removeBurgLabelRenderer;

View file

@ -0,0 +1,200 @@
import { forceCollide, forceSimulation, timeout } from "d3";
import type { Burg } from "../modules/burgs-generator";
import type { State } from "../modules/states-generator";
import { minmax, rn } from "../utils";
declare global {
var drawEmblems: () => void;
var renderGroupCOAs: (g: SVGGElement) => Promise<void>;
}
interface Province {
i: number;
removed?: boolean;
coa?: { size?: number; x?: number; y?: number };
pole?: [number, number];
center: number;
}
interface EmblemNode {
type: "burg" | "province" | "state";
i: number;
x: number;
y: number;
size: number;
shift: number;
}
const emblemsRenderer = (): void => {
TIME && console.time("drawEmblems");
const { states, provinces, burgs } = pack;
const validStates = states.filter(
(s) => s.i && !s.removed && s.coa && s.coa.size !== 0,
);
const validProvinces = (provinces as Province[]).filter(
(p) => p.i && !p.removed && p.coa && p.coa.size !== 0,
);
const validBurgs = burgs.filter(
(b) => b.i && !b.removed && b.coa && b.coa.size !== 0,
);
const getStateEmblemsSize = (): number => {
const startSize = minmax((graphHeight + graphWidth) / 40, 10, 100);
const statesMod =
1 + validStates.length / 100 - (15 - validStates.length) / 200; // states number modifier
const sizeMod = +emblems.select("#stateEmblems").attr("data-size") || 1;
return rn((startSize / statesMod) * sizeMod); // target size ~50px on 1536x754 map with 15 states
};
const getProvinceEmblemsSize = (): number => {
const startSize = minmax((graphHeight + graphWidth) / 100, 5, 70);
const provincesMod =
1 + validProvinces.length / 1000 - (115 - validProvinces.length) / 1000; // states number modifier
const sizeMod = +emblems.select("#provinceEmblems").attr("data-size") || 1;
return rn((startSize / provincesMod) * sizeMod); // target size ~20px on 1536x754 map with 115 provinces
};
const getBurgEmblemSize = (): number => {
const startSize = minmax((graphHeight + graphWidth) / 185, 2, 50);
const burgsMod =
1 + validBurgs.length / 1000 - (450 - validBurgs.length) / 1000; // states number modifier
const sizeMod = +emblems.select("#burgEmblems").attr("data-size") || 1;
return rn((startSize / burgsMod) * sizeMod); // target size ~8.5px on 1536x754 map with 450 burgs
};
const sizeBurgs = getBurgEmblemSize();
const burgCOAs: EmblemNode[] = validBurgs.map((burg) => {
const { x, y } = burg;
const size = burg.coa!.size || 1;
const shift = (sizeBurgs * size) / 2;
return {
type: "burg",
i: burg.i!,
x: burg.coa!.x || x,
y: burg.coa!.y || y,
size,
shift,
};
});
const sizeProvinces = getProvinceEmblemsSize();
const provinceCOAs: EmblemNode[] = validProvinces.map((province) => {
const [x, y] = province.pole || pack.cells.p[province.center];
const size = province.coa!.size || 1;
const shift = (sizeProvinces * size) / 2;
return {
type: "province",
i: province.i,
x: province.coa!.x || x,
y: province.coa!.y || y,
size,
shift,
};
});
const sizeStates = getStateEmblemsSize();
const stateCOAs: EmblemNode[] = validStates.map((state) => {
const [x, y] = state.pole || pack.cells.p[state.center!];
const size = state.coa!.size || 1;
const shift = (sizeStates * size) / 2;
return {
type: "state",
i: state.i,
x: state.coa!.x || x,
y: state.coa!.y || y,
size,
shift,
};
});
const nodes = burgCOAs.concat(provinceCOAs).concat(stateCOAs);
const simulation = forceSimulation(nodes)
.alphaMin(0.6)
.alphaDecay(0.2)
.velocityDecay(0.6)
.force(
"collision",
forceCollide<EmblemNode>().radius((d) => d.shift),
)
.stop();
timeout(() => {
const n = Math.ceil(
Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()),
);
for (let i = 0; i < n; ++i) {
simulation.tick();
}
const burgNodes = nodes.filter((node) => node.type === "burg");
const burgString = burgNodes
.map(
(d) =>
`<use data-i="${d.i}" x="${rn(d.x - d.shift)}" y="${rn(d.y - d.shift)}" width="${d.size}em" height="${
d.size
}em"/>`,
)
.join("");
emblems
.select("#burgEmblems")
.attr("font-size", sizeBurgs)
.html(burgString);
const provinceNodes = nodes.filter((node) => node.type === "province");
const provinceString = provinceNodes
.map(
(d) =>
`<use data-i="${d.i}" x="${rn(d.x - d.shift)}" y="${rn(d.y - d.shift)}" width="${d.size}em" height="${
d.size
}em"/>`,
)
.join("");
emblems
.select("#provinceEmblems")
.attr("font-size", sizeProvinces)
.html(provinceString);
const stateNodes = nodes.filter((node) => node.type === "state");
const stateString = stateNodes
.map(
(d) =>
`<use data-i="${d.i}" x="${rn(d.x - d.shift)}" y="${rn(d.y - d.shift)}" width="${d.size}em" height="${
d.size
}em"/>`,
)
.join("");
emblems
.select("#stateEmblems")
.attr("font-size", sizeStates)
.html(stateString);
invokeActiveZooming();
});
TIME && console.timeEnd("drawEmblems");
};
const getDataAndType = (
id: string,
): [Burg[] | Province[] | State[], string] => {
if (id === "burgEmblems") return [pack.burgs, "burg"];
if (id === "provinceEmblems")
return [pack.provinces as Province[], "province"];
if (id === "stateEmblems") return [pack.states, "state"];
throw new Error(`Unknown emblem type: ${id}`);
};
const renderGroupCOAsRenderer = async (g: SVGGElement): Promise<void> => {
const [data, type] = getDataAndType(g.id);
for (const use of g.children) {
const i = +(use as SVGUseElement).dataset.i!;
const id = `${type}COA${i}`;
COArenderer.trigger(id, (data[i] as any).coa);
use.setAttribute("href", `#${id}`);
}
};
window.drawEmblems = emblemsRenderer;
window.renderGroupCOAs = renderGroupCOAsRenderer;

View file

@ -0,0 +1,102 @@
import { curveBasisClosed, line, select } from "d3";
import type { PackedGraphFeature } from "../modules/features";
import { clipPoly, round } from "../utils";
declare global {
var drawFeatures: () => void;
var simplify: (
points: [number, number][],
tolerance: number,
highestQuality?: boolean,
) => [number, number][];
}
interface FeaturesHtml {
paths: string[];
landMask: string[];
waterMask: string[];
coastline: { [key: string]: string[] };
lakes: { [key: string]: string[] };
}
const featuresRenderer = (): void => {
TIME && console.time("drawFeatures");
const html: FeaturesHtml = {
paths: [],
landMask: [],
waterMask: ['<rect x="0" y="0" width="100%" height="100%" fill="white" />'],
coastline: {},
lakes: {},
};
for (const feature of pack.features) {
if (!feature || feature.type === "ocean") continue;
html.paths.push(
`<path d="${getFeaturePath(feature)}" id="feature_${feature.i}" data-f="${feature.i}"></path>`,
);
if (feature.type === "lake") {
html.landMask.push(
`<use href="#feature_${feature.i}" data-f="${feature.i}" fill="black"></use>`,
);
const lakeGroup = feature.group || "freshwater";
if (!html.lakes[lakeGroup]) html.lakes[lakeGroup] = [];
html.lakes[lakeGroup].push(
`<use href="#feature_${feature.i}" data-f="${feature.i}"></use>`,
);
} else {
html.landMask.push(
`<use href="#feature_${feature.i}" data-f="${feature.i}" fill="white"></use>`,
);
html.waterMask.push(
`<use href="#feature_${feature.i}" data-f="${feature.i}" fill="black"></use>`,
);
const coastlineGroup =
feature.group === "lake_island" ? "lake_island" : "sea_island";
if (!html.coastline[coastlineGroup]) html.coastline[coastlineGroup] = [];
html.coastline[coastlineGroup].push(
`<use href="#feature_${feature.i}" data-f="${feature.i}"></use>`,
);
}
}
defs.select("#featurePaths").html(html.paths.join(""));
defs.select("#land").html(html.landMask.join(""));
defs.select("#water").html(html.waterMask.join(""));
coastline.selectAll<SVGGElement, unknown>("g").each(function () {
const paths = html.coastline[this.id] || [];
select(this).html(paths.join(""));
});
lakes.selectAll<SVGGElement, unknown>("g").each(function () {
const paths = html.lakes[this.id] || [];
select(this).html(paths.join(""));
});
TIME && console.timeEnd("drawFeatures");
};
function getFeaturePath(feature: PackedGraphFeature): string {
const points: [number, number][] = feature.vertices.map(
(vertex: number) => pack.vertices.p[vertex],
);
if (points.some((point) => point === undefined)) {
ERROR && console.error("Undefined point in getFeaturePath");
return "";
}
const simplifiedPoints = simplify(points, 0.3);
const clippedPoints = clipPoly(simplifiedPoints, graphWidth, graphHeight, 1);
const lineGen = line().curve(curveBasisClosed);
const path = `${round(lineGen(clippedPoints) || "")}Z`;
return path;
}
window.drawFeatures = featuresRenderer;

View file

@ -1,25 +1,37 @@
"use strict";
import type { CurveFactory } from "d3";
import * as d3 from "d3";
import { color, line, range } from "d3";
import { round } from "../utils";
function drawHeightmap() {
declare global {
var drawHeightmap: () => void;
}
const heightmapRenderer = (): void => {
TIME && console.time("drawHeightmap");
const ocean = terrs.select("#oceanHeights");
const land = terrs.select("#landHeights");
const ocean = terrs.select<SVGGElement>("#oceanHeights");
const land = terrs.select<SVGGElement>("#landHeights");
ocean.selectAll("*").remove();
land.selectAll("*").remove();
const paths = new Array(101);
const {cells, vertices} = grid;
const paths: (string | undefined)[] = new Array(101);
const { cells, vertices } = grid;
const used = new Uint8Array(cells.i.length);
const heights = Array.from(cells.i).sort((a, b) => cells.h[a] - cells.h[b]);
const heights = Array.from(cells.i as number[]).sort(
(a, b) => cells.h[a] - cells.h[b],
);
// ocean cells
const renderOceanCells = Boolean(+ocean.attr("data-render"));
if (renderOceanCells) {
const skip = +ocean.attr("skip") + 1 || 1;
const relax = +ocean.attr("relax") || 0;
lineGen.curve(d3[ocean.attr("curve") || "curveBasisClosed"]);
// TODO: Improve for treeshaking
const curveType: keyof typeof d3 = (ocean.attr("curve") ||
"curveBasisClosed") as keyof typeof d3;
const lineGen = line().curve(d3[curveType] as CurveFactory);
let currentLayer = 0;
for (const i of heights) {
@ -28,14 +40,18 @@ function drawHeightmap() {
if (h < currentLayer) continue;
if (currentLayer >= 20) break;
if (used[i]) continue; // already marked
const onborder = cells.c[i].some(n => cells.h[n] < h);
const onborder = cells.c[i].some((n: number) => cells.h[n] < h);
if (!onborder) continue;
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.h[i] < h));
const vertex = cells.v[i].find((v: number) =>
vertices.c[v].some((i: number) => cells.h[i] < h),
);
const chain = connectVertices(cells, vertices, vertex, h, used);
if (chain.length < 3) continue;
const points = simplifyLine(chain, relax).map(v => vertices.p[v]);
const points = simplifyLine(chain, relax).map(
(v: number) => vertices.p[v],
);
if (!paths[h]) paths[h] = "";
paths[h] += round(lineGen(points));
paths[h] += round(lineGen(points) || "");
}
}
@ -43,7 +59,9 @@ function drawHeightmap() {
{
const skip = +land.attr("skip") + 1 || 1;
const relax = +land.attr("relax") || 0;
lineGen.curve(d3[land.attr("curve") || "curveBasisClosed"]);
const curveType: keyof typeof d3 = (land.attr("curve") ||
"curveBasisClosed") as keyof typeof d3;
const lineGen = line().curve(d3[curveType] as CurveFactory);
let currentLayer = 20;
for (const i of heights) {
@ -52,21 +70,25 @@ function drawHeightmap() {
if (h < currentLayer) continue;
if (currentLayer > 100) break; // no layers possible with height > 100
if (used[i]) continue; // already marked
const onborder = cells.c[i].some(n => cells.h[n] < h);
const onborder = cells.c[i].some((n: number) => cells.h[n] < h);
if (!onborder) continue;
const startVertex = cells.v[i].find(v => vertices.c[v].some(i => cells.h[i] < h));
const startVertex = cells.v[i].find((v: number) =>
vertices.c[v].some((i: number) => cells.h[i] < h),
);
const chain = connectVertices(cells, vertices, startVertex, h, used);
if (chain.length < 3) continue;
const points = simplifyLine(chain, relax).map(v => vertices.p[v]);
const points = simplifyLine(chain, relax).map(
(v: number) => vertices.p[v],
);
if (!paths[h]) paths[h] = "";
paths[h] += round(lineGen(points));
paths[h] += round(lineGen(points) || "");
}
}
// render paths
for (const height of d3.range(0, 101)) {
for (const height of range(0, 101)) {
const group = height < 20 ? ocean : land;
const scheme = getColorScheme(group.attr("scheme"));
@ -92,33 +114,49 @@ function drawHeightmap() {
.attr("fill", scheme(0.8));
}
if (paths[height] && paths[height].length >= 10) {
const terracing = group.attr("terracing") / 10 || 0;
const color = getColor(height, scheme);
if (paths[height] && paths[height]!.length >= 10) {
const terracing = +group.attr("terracing") / 10 || 0;
const fillColor = getColor(height, scheme);
if (terracing) {
group
.append("path")
.attr("d", paths[height])
.attr("d", paths[height]!)
.attr("transform", "translate(.7,1.4)")
.attr("fill", d3.color(color).darker(terracing))
.attr("fill", color(fillColor)!.darker(terracing).toString())
.attr("data-height", height);
}
group.append("path").attr("d", paths[height]).attr("fill", color).attr("data-height", height);
group
.append("path")
.attr("d", paths[height]!)
.attr("fill", fillColor)
.attr("data-height", height);
}
}
// connect vertices to chain: specific case for heightmap
function connectVertices(cells, vertices, start, h, used) {
function connectVertices(
cells: any,
vertices: any,
start: number,
h: number,
used: Uint8Array,
): number[] {
const MAX_ITERATIONS = vertices.c.length;
const n = cells.i.length;
const chain = []; // vertices chain to form a path
for (let i = 0, current = start; i === 0 || (current !== start && i < MAX_ITERATIONS); i++) {
const chain: number[] = []; // vertices chain to form a path
for (
let i = 0, current = start;
i === 0 || (current !== start && i < MAX_ITERATIONS);
i++
) {
const prev = chain[chain.length - 1]; // previous vertex in chain
chain.push(current); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.h[c] === h).forEach(c => (used[c] = 1));
c.filter((c: number) => cells.h[c] === h).forEach((c: number) => {
used[c] = 1;
});
const c0 = c[0] >= n || cells.h[c[0]] < h;
const c1 = c[1] >= n || cells.h[c[1]] < h;
const c2 = c[2] >= n || cells.h[c[2]] < h;
@ -134,11 +172,13 @@ function drawHeightmap() {
return chain;
}
function simplifyLine(chain, simplification) {
function simplifyLine(chain: number[], simplification: number): number[] {
if (!simplification) return chain;
const n = simplification + 1; // filter each nth element
return chain.filter((d, i) => i % n === 0);
return chain.filter((_d, i) => i % n === 0);
}
TIME && console.timeEnd("drawHeightmap");
}
};
window.drawHeightmap = heightmapRenderer;

102
src/renderers/draw-ice.ts Normal file
View file

@ -0,0 +1,102 @@
declare global {
var drawIce: () => void;
var redrawIceberg: (id: number) => void;
var redrawGlacier: (id: number) => void;
}
interface IceElement {
i: number;
points: string | [number, number][];
type: "glacier" | "iceberg";
offset?: [number, number];
}
const iceRenderer = (): void => {
TIME && console.time("drawIce");
// Clear existing ice SVG
ice.selectAll("*").remove();
let html = "";
// Draw all ice elements
pack.ice.forEach((iceElement: IceElement) => {
if (iceElement.type === "glacier") {
html += getGlacierHtml(iceElement);
} else if (iceElement.type === "iceberg") {
html += getIcebergHtml(iceElement);
}
});
ice.html(html);
TIME && console.timeEnd("drawIce");
};
const redrawIcebergRenderer = (id: number): void => {
TIME && console.time("redrawIceberg");
const iceberg = pack.ice.find((element: IceElement) => element.i === id);
let el = ice.selectAll<SVGPolygonElement, unknown>(
`polygon[data-id="${id}"]:not([type="glacier"])`,
);
if (!iceberg && !el.empty()) {
el.remove();
} else if (iceberg) {
if (el.empty()) {
// Create new element if it doesn't exist
const polygon = getIcebergHtml(iceberg);
(ice.node() as SVGGElement).insertAdjacentHTML("beforeend", polygon);
el = ice.selectAll<SVGPolygonElement, unknown>(
`polygon[data-id="${id}"]:not([type="glacier"])`,
);
}
el.attr("points", iceberg.points as string);
el.attr(
"transform",
iceberg.offset
? `translate(${iceberg.offset[0]},${iceberg.offset[1]})`
: null,
);
}
TIME && console.timeEnd("redrawIceberg");
};
const redrawGlacierRenderer = (id: number): void => {
TIME && console.time("redrawGlacier");
const glacier = pack.ice.find((element: IceElement) => element.i === id);
let el = ice.selectAll<SVGPolygonElement, unknown>(
`polygon[data-id="${id}"][type="glacier"]`,
);
if (!glacier && !el.empty()) {
el.remove();
} else if (glacier) {
if (el.empty()) {
// Create new element if it doesn't exist
const polygon = getGlacierHtml(glacier);
(ice.node() as SVGGElement).insertAdjacentHTML("beforeend", polygon);
el = ice.selectAll<SVGPolygonElement, unknown>(
`polygon[data-id="${id}"][type="glacier"]`,
);
}
el.attr("points", glacier.points as string);
el.attr(
"transform",
glacier.offset
? `translate(${glacier.offset[0]},${glacier.offset[1]})`
: null,
);
}
TIME && console.timeEnd("redrawGlacier");
};
function getGlacierHtml(glacier: IceElement): string {
return `<polygon points="${glacier.points}" type="glacier" data-id="${glacier.i}" ${glacier.offset ? `transform="translate(${glacier.offset[0]},${glacier.offset[1]})"` : ""}/>`;
}
function getIcebergHtml(iceberg: IceElement): string {
return `<polygon points="${iceberg.points}" data-id="${iceberg.i}" ${iceberg.offset ? `transform="translate(${iceberg.offset[0]},${iceberg.offset[1]})"` : ""}/>`;
}
window.drawIce = iceRenderer;
window.redrawIceberg = redrawIcebergRenderer;
window.redrawGlacier = redrawGlacierRenderer;

View file

@ -0,0 +1,103 @@
import { rn } from "../utils";
interface Marker {
i: number;
icon: string;
x: number;
y: number;
dx?: number;
dy?: number;
px?: number;
size?: number;
pin?: string;
fill?: string;
stroke?: string;
pinned?: boolean;
}
declare global {
var drawMarkers: () => void;
}
type PinShapeFunction = (fill: string, stroke: string) => string;
type PinShapes = { [key: string]: PinShapeFunction };
// prettier-ignore
const pinShapes: PinShapes = {
bubble: (fill: string, stroke: string) =>
`<path d="M6,19 l9,10 L24,19" fill="${stroke}" stroke="none" /><circle cx="15" cy="15" r="10" fill="${fill}" stroke="${stroke}"/>`,
pin: (fill: string, stroke: string) =>
`<path d="m 15,3 c -5.5,0 -9.7,4.09 -9.7,9.3 0,6.8 9.7,17 9.7,17 0,0 9.7,-10.2 9.7,-17 C 24.7,7.09 20.5,3 15,3 Z" fill="${fill}" stroke="${stroke}"/>`,
square: (fill: string, stroke: string) =>
`<path d="m 20,25 -5,4 -5,-4 z" fill="${stroke}"/><path d="M 5,5 H 25 V 25 H 5 Z" fill="${fill}" stroke="${stroke}"/>`,
squarish: (fill: string, stroke: string) =>
`<path d="m 5,5 h 20 v 20 h -6 l -4,4 -4,-4 H 5 Z" fill="${fill}" stroke="${stroke}" />`,
diamond: (fill: string, stroke: string) =>
`<path d="M 2,15 15,1 28,15 15,29 Z" fill="${fill}" stroke="${stroke}" />`,
hex: (fill: string, stroke: string) =>
`<path d="M 15,29 4.61,21 V 9 L 15,3 25.4,9 v 12 z" fill="${fill}" stroke="${stroke}" />`,
hexy: (fill: string, stroke: string) =>
`<path d="M 15,29 6,21 5,8 15,4 25,8 24,21 Z" fill="${fill}" stroke="${stroke}" />`,
shieldy: (fill: string, stroke: string) =>
`<path d="M 15,29 6,21 5,7 c 0,0 5,-3 10,-3 5,0 10,3 10,3 l -1,14 z" fill="${fill}" stroke="${stroke}" />`,
shield: (fill: string, stroke: string) =>
`<path d="M 4.6,5.2 H 25 v 6.7 A 20.3,20.4 0 0 1 15,29 20.3,20.4 0 0 1 4.6,11.9 Z" fill="${fill}" stroke="${stroke}" />`,
pentagon: (fill: string, stroke: string) =>
`<path d="M 4,16 9,4 h 12 l 5,12 -11,13 z" fill="${fill}" stroke="${stroke}" />`,
heptagon: (fill: string, stroke: string) =>
`<path d="M 15,29 6,22 4,12 10,4 h 10 l 6,8 -2,10 z" fill="${fill}" stroke="${stroke}" />`,
circle: (fill: string, stroke: string) =>
`<circle cx="15" cy="15" r="11" fill="${fill}" stroke="${stroke}" />`,
no: () => "",
};
const getPin = (shape = "bubble", fill = "#fff", stroke = "#000"): string => {
const shapeFunction = pinShapes[shape] || pinShapes.bubble;
return shapeFunction(fill, stroke);
};
function drawMarker(marker: Marker, rescale = 1): string {
const {
i,
icon,
x,
y,
dx = 50,
dy = 50,
px = 12,
size = 30,
pin,
fill,
stroke,
} = marker;
const id = `marker${i}`;
const zoomSize = rescale ? Math.max(rn(size / 5 + 24 / scale, 2), 1) : size;
const viewX = rn(x - zoomSize / 2, 1);
const viewY = rn(y - zoomSize, 1);
const isExternal = icon.startsWith("http") || icon.startsWith("data:image");
return /* html */ `
<svg id="${id}" viewbox="0 0 30 30" width="${zoomSize}" height="${zoomSize}" x="${viewX}" y="${viewY}">
<g>${getPin(pin, fill, stroke)}</g>
<text x="${dx}%" y="${dy}%" font-size="${px}px" >${isExternal ? "" : icon}</text>
<image x="${dx / 2}%" y="${dy / 2}%" width="${px}px" height="${px}px" href="${isExternal ? icon : ""}" />
</svg>`;
}
const markersRenderer = (): void => {
TIME && console.time("drawMarkers");
const rescale = +markers.attr("rescale");
const pinned = +markers.attr("pinned");
const markersData: Marker[] = pinned
? pack.markers.filter((m: Marker) => m.pinned)
: pack.markers;
const html = markersData.map((marker) => drawMarker(marker, rescale));
markers.html(html.join(""));
TIME && console.timeEnd("drawMarkers");
};
window.drawMarkers = markersRenderer;

View file

@ -0,0 +1,216 @@
import { color, easeSinInOut, transition } from "d3";
import { rn } from "../utils";
interface Regiment {
i: number;
name: string;
x: number;
y: number;
n?: number;
angle?: number;
icon: string;
state: number;
}
declare global {
var drawMilitary: () => void;
var drawRegiments: (regiments: Regiment[], stateId: number) => void;
var drawRegiment: (reg: Regiment, stateId: number) => void;
var moveRegiment: (reg: Regiment, x: number, y: number) => void;
var armies: import("d3").Selection<SVGGElement, unknown, null, undefined>;
var Military: { getTotal: (reg: Regiment) => number };
}
const militaryRenderer = (): void => {
TIME && console.time("drawMilitary");
armies.selectAll("g").remove();
pack.states
.filter((s) => s.i && !s.removed)
.forEach((s) => {
drawRegiments(s.military || [], s.i);
});
TIME && console.timeEnd("drawMilitary");
};
const drawRegimentsRenderer = (regiments: Regiment[], s: number): void => {
const size = +armies.attr("box-size");
const w = (d: Regiment) => (d.n ? size * 4 : size * 6);
const h = size * 2;
const x = (d: Regiment) => rn(d.x - w(d) / 2, 2);
const y = (d: Regiment) => rn(d.y - size, 2);
const stateColor = pack.states[s]?.color;
const baseColor = stateColor && stateColor[0] === "#" ? stateColor : "#999";
const darkerColor = color(baseColor)!.darker().formatHex();
const army = armies
.append("g")
.attr("id", `army${s}`)
.attr("fill", baseColor)
.attr("color", darkerColor);
const g = army
.selectAll("g")
.data(regiments)
.enter()
.append("g")
.attr("id", (d) => `regiment${s}-${d.i}`)
.attr("data-name", (d) => d.name)
.attr("data-state", s)
.attr("data-id", (d) => d.i)
.attr("transform", (d) => (d.angle ? `rotate(${d.angle})` : null))
.attr("transform-origin", (d) => `${d.x}px ${d.y}px`);
g.append("rect")
.attr("x", (d) => x(d))
.attr("y", (d) => y(d))
.attr("width", (d) => w(d))
.attr("height", h);
g.append("text")
.attr("x", (d) => d.x)
.attr("y", (d) => d.y)
.attr("text-rendering", "optimizeSpeed")
.text((d) => Military.getTotal(d));
g.append("rect")
.attr("fill", "currentColor")
.attr("x", (d) => x(d) - h)
.attr("y", (d) => y(d))
.attr("width", h)
.attr("height", h);
g.append("text")
.attr("class", "regimentIcon")
.attr("text-rendering", "optimizeSpeed")
.attr("x", (d) => x(d) - size)
.attr("y", (d) => d.y)
.text((d) =>
d.icon.startsWith("http") || d.icon.startsWith("data:image")
? ""
: d.icon,
);
g.append("image")
.attr("class", "regimentImage")
.attr("x", (d) => x(d) - h)
.attr("y", (d) => y(d))
.attr("height", h)
.attr("width", h)
.attr("href", (d) =>
d.icon.startsWith("http") || d.icon.startsWith("data:image")
? d.icon
: "",
);
};
const drawRegimentRenderer = (reg: Regiment, stateId: number): void => {
const size = +armies.attr("box-size");
const w = reg.n ? size * 4 : size * 6;
const h = size * 2;
const x1 = rn(reg.x - w / 2, 2);
const y1 = rn(reg.y - size, 2);
let army = armies.select<SVGGElement>(`g#army${stateId}`);
if (!army.size()) {
const stateColor = pack.states[stateId]?.color;
const baseColor = stateColor && stateColor[0] === "#" ? stateColor : "#999";
const darkerColor = color(baseColor)!.darker().formatHex();
army = armies
.append("g")
.attr("id", `army${stateId}`)
.attr("fill", baseColor)
.attr("color", darkerColor);
}
const g = army
.append("g")
.attr("id", `regiment${stateId}-${reg.i}`)
.attr("data-name", reg.name)
.attr("data-state", stateId)
.attr("data-id", reg.i)
.attr("transform", `rotate(${reg.angle || 0})`)
.attr("transform-origin", `${reg.x}px ${reg.y}px`);
g.append("rect")
.attr("x", x1)
.attr("y", y1)
.attr("width", w)
.attr("height", h);
g.append("text")
.attr("x", reg.x)
.attr("y", reg.y)
.attr("text-rendering", "optimizeSpeed")
.text(Military.getTotal(reg));
g.append("rect")
.attr("fill", "currentColor")
.attr("x", x1 - h)
.attr("y", y1)
.attr("width", h)
.attr("height", h);
g.append("text")
.attr("class", "regimentIcon")
.attr("text-rendering", "optimizeSpeed")
.attr("x", x1 - size)
.attr("y", reg.y)
.text(
reg.icon.startsWith("http") || reg.icon.startsWith("data:image")
? ""
: reg.icon,
);
g.append("image")
.attr("class", "regimentImage")
.attr("x", x1 - h)
.attr("y", y1)
.attr("height", h)
.attr("width", h)
.attr(
"href",
reg.icon.startsWith("http") || reg.icon.startsWith("data:image")
? reg.icon
: "",
);
};
// move one regiment to another
const moveRegimentRenderer = (reg: Regiment, x: number, y: number): void => {
const el = armies
.select(`g#army${reg.state}`)
.select(`g#regiment${reg.state}-${reg.i}`);
if (!el.size()) return;
const duration = Math.hypot(reg.x - x, reg.y - y) * 8;
reg.x = x;
reg.y = y;
const size = +armies.attr("box-size");
const w = reg.n ? size * 4 : size * 6;
const h = size * 2;
const x1 = (x: number) => rn(x - w / 2, 2);
const y1 = (y: number) => rn(y - size, 2);
const move = transition().duration(duration).ease(easeSinInOut);
el.select("rect")
.transition(move as any)
.attr("x", x1(x))
.attr("y", y1(y));
el.select("text")
.transition(move as any)
.attr("x", x)
.attr("y", y);
el.selectAll("rect:nth-of-type(2)")
.transition(move as any)
.attr("x", x1(x) - h)
.attr("y", y1(y));
el.select(".regimentIcon")
.transition(move as any)
.attr("x", x1(x) - size)
.attr("y", y)
.attr("height", "6")
.attr("width", "6");
el.select(".regimentImage")
.transition(move as any)
.attr("x", x1(x) - h)
.attr("y", y1(y))
.attr("height", "6")
.attr("width", "6");
};
window.drawMilitary = militaryRenderer;
window.drawRegiments = drawRegimentsRenderer;
window.drawRegiment = drawRegimentRenderer;
window.moveRegiment = moveRegimentRenderer;

View file

@ -0,0 +1,164 @@
import { extent, polygonContains } from "d3";
import { minmax, rand, rn } from "../utils";
interface ReliefIcon {
i: string;
x: number;
y: number;
s: number;
}
declare global {
var drawReliefIcons: () => void;
var terrain: import("d3").Selection<SVGGElement, unknown, null, undefined>;
var getPackPolygon: (i: number) => [number, number][];
}
const reliefIconsRenderer = (): void => {
TIME && console.time("drawRelief");
terrain.selectAll("*").remove();
const cells = pack.cells;
const density = Number(terrain.attr("density")) || 0.4;
const size = 2 * (Number(terrain.attr("size")) || 1);
const mod = 0.2 * size; // size modifier
const relief: ReliefIcon[] = [];
for (const i of cells.i) {
const height = cells.h[i];
if (height < 20) continue; // no icons on water
if (cells.r[i]) continue; // no icons on rivers
const biome = cells.biome[i];
if (height < 50 && biomesData.iconsDensity[biome] === 0) continue; // no icons for this biome
const polygon = getPackPolygon(i);
const [minX, maxX] = extent(polygon, (p) => p[0]) as [number, number];
const [minY, maxY] = extent(polygon, (p) => p[1]) as [number, number];
if (height < 50) placeBiomeIcons();
else placeReliefIcons();
function placeBiomeIcons(): void {
const iconsDensity = biomesData.iconsDensity[biome] / 100;
const radius = 2 / iconsDensity / density;
if (Math.random() > iconsDensity * 10) return;
for (const [cx, cy] of window.poissonDiscSampler(
minX,
minY,
maxX,
maxY,
radius,
)) {
if (!polygonContains(polygon, [cx, cy])) continue;
let h = (4 + Math.random()) * size;
const icon = getBiomeIcon(i, biomesData.icons[biome]);
if (icon === "#relief-grass-1") h *= 1.2;
relief.push({
i: icon,
x: rn(cx - h, 2),
y: rn(cy - h, 2),
s: rn(h * 2, 2),
});
}
}
function placeReliefIcons(): void {
const radius = 2 / density;
const [icon, h] = getReliefIcon(i, height);
for (const [cx, cy] of window.poissonDiscSampler(
minX,
minY,
maxX,
maxY,
radius,
)) {
if (!polygonContains(polygon, [cx, cy])) continue;
relief.push({
i: icon,
x: rn(cx - h, 2),
y: rn(cy - h, 2),
s: rn(h * 2, 2),
});
}
}
function getReliefIcon(cellIndex: number, h: number): [string, number] {
const temp = grid.cells.temp[pack.cells.g[cellIndex]];
const type = h > 70 && temp < 0 ? "mountSnow" : h > 70 ? "mount" : "hill";
const iconSize = h > 70 ? (h - 45) * mod : minmax((h - 40) * mod, 3, 6);
return [getIcon(type), iconSize];
}
}
// sort relief icons by y+size
relief.sort((a, b) => a.y + a.s - (b.y + b.s));
const reliefHTML: string[] = [];
for (const r of relief) {
reliefHTML.push(
`<use href="${r.i}" x="${r.x}" y="${r.y}" width="${r.s}" height="${r.s}"/>`,
);
}
terrain.html(reliefHTML.join(""));
TIME && console.timeEnd("drawRelief");
function getBiomeIcon(cellIndex: number, b: string[]): string {
let type = b[Math.floor(Math.random() * b.length)];
const temp = grid.cells.temp[pack.cells.g[cellIndex]];
if (type === "conifer" && temp < 0) type = "coniferSnow";
return getIcon(type);
}
function getVariant(type: string): number {
switch (type) {
case "mount":
return rand(2, 7);
case "mountSnow":
return rand(1, 6);
case "hill":
return rand(2, 5);
case "conifer":
return 2;
case "coniferSnow":
return 1;
case "swamp":
return rand(2, 3);
case "cactus":
return rand(1, 3);
case "deadTree":
return rand(1, 2);
default:
return 2;
}
}
function getOldIcon(type: string): string {
switch (type) {
case "mountSnow":
return "mount";
case "vulcan":
return "mount";
case "coniferSnow":
return "conifer";
case "cactus":
return "dune";
case "deadTree":
return "dune";
default:
return type;
}
}
function getIcon(type: string): string {
const set = terrain.attr("set") || "simple";
if (set === "simple") return `#relief-${getOldIcon(type)}-1`;
if (set === "colored") return `#relief-${type}-${getVariant(type)}`;
if (set === "gray") return `#relief-${type}-${getVariant(type)}-bw`;
return `#relief-${getOldIcon(type)}-1`; // simple
}
};
window.drawReliefIcons = reliefIconsRenderer;

View file

@ -1,12 +1,36 @@
"use strict";
import type { Selection } from "d3";
import { range } from "d3";
import { rn } from "../utils";
function drawScaleBar(scaleBar, scaleLevel) {
declare global {
var drawScaleBar: (
scaleBar: Selection<SVGGElement, unknown, HTMLElement, unknown>,
scaleLevel: number,
) => void;
var fitScaleBar: (
scaleBar: Selection<SVGGElement, unknown, HTMLElement, unknown>,
fullWidth: number,
fullHeight: number,
) => void;
}
type ScaleBarSelection = d3.Selection<
SVGGElement,
unknown,
HTMLElement,
unknown
>;
const scaleBarRenderer = (
scaleBar: ScaleBarSelection,
scaleLevel: number,
): void => {
if (!scaleBar.size() || scaleBar.style("display") === "none") return;
const unit = distanceUnitInput.value;
const size = +scaleBar.attr("data-bar-size");
const length = getLength(scaleLevel, size);
const length = getLength(scaleBar, scaleLevel);
scaleBar.select("#scaleBarContent").remove(); // redraw content every time
const content = scaleBar.append("g").attr("id", "scaleBarContent");
@ -34,20 +58,27 @@ function drawScaleBar(scaleBar, scaleLevel) {
.attr("x2", length + size)
.attr("y2", 0)
.attr("stroke-width", rn(size * 3, 2))
.attr("stroke-dasharray", size + " " + rn(length / 5 - size, 2))
.attr("stroke-dasharray", `${size} ${rn(length / 5 - size, 2)}`)
.attr("stroke", "#3d3d3d");
const texts = content.append("g").attr("text-anchor", "middle").attr("font-family", "var(--serif)");
const texts = content
.append("g")
.attr("text-anchor", "middle")
.attr("font-family", "var(--serif)");
texts
.selectAll("text")
.data(d3.range(0, 6))
.data(range(0, 6))
.enter()
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("x", d => rn((d * length) / 5, 2))
.attr("x", (d: number) => rn((d * length) / 5, 2))
.attr("y", 0)
.attr("dy", "-.6em")
.text(d => rn((((d * length) / 5) * distanceScale) / scaleLevel) + (d < 5 ? "" : " " + unit));
.text(
(d: number) =>
rn((((d * length) / 5) * distanceScale) / scaleLevel) +
(d < 5 ? "" : ` ${unit}`),
);
const label = scaleBar.attr("data-label");
if (label) {
@ -60,9 +91,9 @@ function drawScaleBar(scaleBar, scaleLevel) {
.text(label);
}
const scaleBarBack = scaleBar.select("#scaleBarBack");
const scaleBarBack = scaleBar.select<SVGRectElement>("#scaleBarBack");
if (scaleBarBack.size()) {
const bbox = content.node().getBBox();
const bbox = (content.node() as SVGGElement).getBBox();
const paddingTop = +scaleBarBack.attr("data-top") || 0;
const paddingLeft = +scaleBarBack.attr("data-left") || 0;
const paddingRight = +scaleBarBack.attr("data-right") || 0;
@ -75,29 +106,40 @@ function drawScaleBar(scaleBar, scaleLevel) {
.attr("width", bbox.width + paddingRight)
.attr("height", bbox.height + paddingBottom);
}
}
};
function getLength(scaleLevel) {
function getLength(scaleBar: ScaleBarSelection, scaleLevel: number): number {
const init = 100;
const size = +scaleBar.attr("data-bar-size");
let val = (init * size * distanceScale) / scaleLevel; // bar length in distance unit
if (val > 900) val = rn(val, -3); // round to 1000
else if (val > 90) val = rn(val, -2); // round to 100
else if (val > 9) val = rn(val, -1); // round to 10
if (val > 900)
val = rn(val, -3); // round to 1000
else if (val > 90)
val = rn(val, -2); // round to 100
else if (val > 9)
val = rn(val, -1); // round to 10
else val = rn(val); // round to 1
const length = (val * scaleLevel) / distanceScale; // actual length in pixels on this scale
return length;
}
function fitScaleBar(scaleBar, fullWidth, fullHeight) {
if (!scaleBar.select("rect").size() || scaleBar.style("display") === "none") return;
const scaleBarResize = (
scaleBar: ScaleBarSelection,
fullWidth: number,
fullHeight: number,
): void => {
if (!scaleBar.select("rect").size() || scaleBar.style("display") === "none")
return;
const posX = +scaleBar.attr("data-x") || 99;
const posY = +scaleBar.attr("data-y") || 99;
const bbox = scaleBar.select("rect").node().getBBox();
const bbox = (scaleBar.select("rect").node() as SVGRectElement).getBBox();
const x = rn((fullWidth * posX) / 100 - bbox.width + 10);
const y = rn((fullHeight * posY) / 100 - bbox.height + 20);
scaleBar.attr("transform", `translate(${x},${y})`);
}
};
window.drawScaleBar = scaleBarRenderer;
window.fitScaleBar = scaleBarResize;

View file

@ -0,0 +1,439 @@
import { curveNatural, line, max, select } from "d3";
import {
drawPath,
drawPoint,
findClosestCell,
minmax,
rn,
round,
splitInTwo,
} from "../utils";
declare global {
var drawStateLabels: (list?: number[]) => void;
}
interface Ray {
angle: number;
length: number;
x: number;
y: number;
}
interface AngleData {
angle: number;
dx: number;
dy: number;
}
type PathPoints = [number, number][];
// list - an optional array of stateIds to regenerate
const stateLabelsRenderer = (list?: number[]): void => {
TIME && console.time("drawStateLabels");
// temporary make the labels visible
const layerDisplay = labels.style("display");
labels.style("display", null);
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 angles = precalculateAngles(ANGLE_STEP);
const LENGTH_START = 5;
const LENGTH_STEP = 5;
const LENGTH_MAX = 300;
const labelPaths = getLabelPaths();
const letterLength = checkExampleLetterLength();
drawLabelPath(letterLength);
// restore labels visibility
labels.style("display", layerDisplay);
function getLabelPaths(): [number, PathPoints][] {
const labelPaths: [number, PathPoints][] = [];
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! / 20;
const [x0, y0] = state.pole!;
const rays: Ray[] = angles.map(({ angle, dx, dy }) => {
const { length, x, y } = raycast({
stateId: state.i,
x0,
y0,
dx,
dy,
maxLakeSize,
offset,
});
return { angle, length, x, y };
});
const [ray1, ray2] = findBestRayPair(rays);
const pathPoints: PathPoints = [
[ray1.x, ray1.y],
state.pole!,
[ray2.x, ray2.y],
];
if (ray1.x > ray2.x) pathPoints.reverse();
if (DEBUG.stateLabels) {
drawPoint(state.pole!, { color: "black", radius: 1 });
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
.append("path")
.attr("d", round(lineGen(pathPoints) || ""))
.attr("id", `textPath_stateLabel${stateId}`);
const pathLength =
(textPath.node() as SVGPathElement).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 = max(lines.map((line) => line.length)) || 0;
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("text-rendering", "optimizeSpeed")
.attr("id", `stateLabel${stateId}`)
.append("textPath")
.attr("startOffset", "50%")
.attr("font-size", `${ratio}%`)
.node() as SVGTextPathElement;
const top = (lines.length - 1) / -2; // y offset
const spans = lines.map(
(lineText, index) =>
`<tspan x="0" dy="${index ? 1 : top}em">${lineText}</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),
50,
130,
);
textElement.setAttribute("font-size", `${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(
mode: string,
name: string,
fullName: string,
pathLength: number,
): [string[], number] {
if (mode === "short") return getShortOneLine();
if (pathLength > fullName.length * 2) return getFullOneLine();
return getFullTwoLines();
function getShortOneLine(): [string[], number] {
const ratio = pathLength / name.length;
return [[name], minmax(rn(ratio * 60), 50, 150)];
}
function getFullOneLine(): [string[], number] {
const ratio = pathLength / fullName.length;
return [[fullName], minmax(rn(ratio * 70), 70, 170)];
}
function getFullTwoLines(): [string[], number] {
const lines = splitInTwo(fullName);
const longestLineLength = max(lines.map((line) => line.length)) || 0;
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: SVGTextPathElement,
angleRad: number,
halfwidth: number,
halfheight: number,
stateIds: number[],
stateId: number,
): boolean {
const bbox = textElement.getBBox();
const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2];
const points: [number, number][] = [
[-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]): [number, number] => [
cx + x * cos - y * sin,
cy + x * sin + y * cos,
]);
let pointsInside = 0;
for (const [x, y] of rotatedPoints) {
const isInside =
stateIds[findClosestCell(x, y, undefined, pack) as number] === stateId;
if (isInside) pointsInside++;
if (pointsInside > 4) return true;
}
return false;
}
TIME && console.timeEnd("drawStateLabels");
};
window.drawStateLabels = stateLabelsRenderer;

View file

@ -0,0 +1,155 @@
import {
color,
curveBasisClosed,
interpolateSpectral,
leastIndex,
line,
max,
min,
range,
scaleSequential,
} from "d3";
import { byId, connectVertices, convertTemperature, round } from "../utils";
declare global {
var drawTemperature: () => void;
}
const temperatureRenderer = (): void => {
TIME && console.time("drawTemperature");
temperature.selectAll("*").remove();
const lineGen = line<[number, number]>().curve(curveBasisClosed);
const scheme = scaleSequential(interpolateSpectral);
const tMax = +(byId("temperatureEquatorOutput") as HTMLInputElement).max;
const tMin = +(byId("temperatureEquatorOutput") as HTMLInputElement).min;
const delta = tMax - tMin;
const { cells, vertices } = grid;
const n = cells.i.length;
const checkedCells = new Uint8Array(n);
const addToChecked = (cellId: number) => {
checkedCells[cellId] = 1;
};
const minTemp = Number(min(cells.temp)) || 0;
const maxTemp = Number(max(cells.temp)) || 0;
const step = Math.max(Math.round(Math.abs(minTemp - maxTemp) / 5), 1);
const isolines = range(minTemp + step, maxTemp, step);
const chains: [number, [number, number][]][] = [];
const labels: [number, number, number][] = []; // store label coordinates
for (const cellId of cells.i) {
const t = cells.temp[cellId];
if (checkedCells[cellId] || !isolines.includes(t)) continue;
const startingVertex = findStart(cellId, t);
if (!startingVertex) continue;
checkedCells[cellId] = 1;
const ofSameType = (cellId: number) => cells.temp[cellId] >= t;
const chain = connectVertices({
vertices,
startingVertex,
ofSameType,
addToChecked,
});
const relaxed = chain.filter(
(v: number, i: number) =>
i % 4 === 0 || vertices.c[v].some((c: number) => c >= n),
);
if (relaxed.length < 6) continue;
const points: [number, number][] = relaxed.map(
(v: number) => vertices.p[v],
);
chains.push([t, points]);
addLabel(points, t);
}
// min temp isoline covers all graph
temperature
.append("path")
.attr("d", `M0,0 h${graphWidth} v${graphHeight} h${-graphWidth} Z`)
.attr("fill", scheme(1 - (minTemp - tMin) / delta))
.attr("stroke", "none");
for (const t of isolines) {
const path = chains
.filter((c) => c[0] === t)
.map((c) => round(lineGen(c[1]) || ""))
.join("");
if (!path) continue;
const fill = scheme(1 - (t - tMin) / delta);
const stroke = color(fill)!.darker(0.2);
temperature
.append("path")
.attr("d", path)
.attr("fill", fill)
.attr("stroke", stroke.toString());
}
const tempLabels = temperature
.append("g")
.attr("id", "tempLabels")
.attr("fill-opacity", 1);
tempLabels
.selectAll("text")
.data(labels)
.enter()
.append("text")
.attr("x", (d) => d[0])
.attr("y", (d) => d[1])
.text((d) => convertTemperature(d[2]));
// find cell with temp < isotherm and find vertex to start path detection
function findStart(i: number, t: number): number | undefined {
if (cells.b[i])
return cells.v[i].find((v: number) =>
vertices.c[v].some((c: number) => c >= n),
); // map border cell
return cells.v[i][
cells.c[i].findIndex((c: number) => cells.temp[c] < t || !cells.temp[c])
];
}
function addLabel(points: [number, number][], t: number): void {
const xCenter = svgWidth / 2;
// add label on isoline top center
const tcIndex = leastIndex(
points,
(a: [number, number], b: [number, number]) =>
a[1] - b[1] + (Math.abs(a[0] - xCenter) - Math.abs(b[0] - xCenter)) / 2,
);
const tc = points[tcIndex!];
pushLabel(tc[0], tc[1], t);
// add label on isoline bottom center
if (points.length > 20) {
const bcIndex = leastIndex(
points,
(a: [number, number], b: [number, number]) =>
b[1] -
a[1] +
(Math.abs(a[0] - xCenter) - Math.abs(b[0] - xCenter)) / 2,
);
const bc = points[bcIndex!];
const dist2 = (tc[1] - bc[1]) ** 2 + (tc[0] - bc[0]) ** 2; // square distance between this and top point
if (dist2 > 100) pushLabel(bc[0], bc[1], t);
}
}
function pushLabel(x: number, y: number, t: number): void {
if (x < 20 || x > svgWidth - 20) return;
if (y < 20 || y > svgHeight - 20) return;
labels.push([x, y, t]);
}
TIME && console.timeEnd("drawTemperature");
};
window.drawTemperature = temperatureRenderer;

13
src/renderers/index.ts Normal file
View file

@ -0,0 +1,13 @@
import "./draw-borders";
import "./draw-burg-icons";
import "./draw-burg-labels";
import "./draw-emblems";
import "./draw-features";
import "./draw-heightmap";
import "./draw-ice";
import "./draw-markers";
import "./draw-military";
import "./draw-relief-icons";
import "./draw-scalebar";
import "./draw-state-labels";
import "./draw-temperature";

View file

@ -58,5 +58,7 @@ export interface PackedGraph {
cultures: Culture[];
routes: Route[];
religions: any[];
ice: any[];
markers: any[];
provinces: Province[];
}

View file

@ -11,6 +11,7 @@ declare global {
var TIME: boolean;
var WARN: boolean;
var ERROR: boolean;
var DEBUG: { stateLabels?: boolean; [key: string]: boolean | undefined };
var options: any;
var heightmapTemplates: any;
@ -18,6 +19,7 @@ declare global {
var populationRate: number;
var urbanDensity: number;
var urbanization: number;
var distanceScale: number;
var nameBases: NameBase[];
var pointsInput: HTMLInputElement;
@ -26,10 +28,27 @@ declare global {
var heightExponentInput: HTMLInputElement;
var alertMessage: HTMLElement;
var mapName: HTMLInputElement;
var distanceUnitInput: HTMLInputElement;
var rivers: Selection<SVGElement, unknown, null, undefined>;
var oceanLayers: Selection<SVGGElement, unknown, null, undefined>;
var emblems: Selection<SVGElement, unknown, null, undefined>;
var svg: Selection<SVGSVGElement, unknown, null, undefined>;
var ice: Selection<SVGGElement, unknown, null, undefined>;
var labels: Selection<SVGGElement, unknown, null, undefined>;
var burgLabels: Selection<SVGGElement, unknown, null, undefined>;
var burgIcons: Selection<SVGGElement, unknown, null, undefined>;
var anchors: Selection<SVGGElement, unknown, null, undefined>;
var terrs: Selection<SVGGElement, unknown, null, undefined>;
var temperature: Selection<SVGGElement, unknown, null, undefined>;
var markers: Selection<SVGGElement, unknown, null, undefined>;
var defs: Selection<SVGDefsElement, unknown, null, undefined>;
var coastline: Selection<SVGGElement, unknown, null, undefined>;
var lakes: Selection<SVGGElement, unknown, null, undefined>;
var getColorScheme: (scheme: string | null) => (t: number) => string;
var getColor: (height: number, scheme: (t: number) => string) => string;
var svgWidth: number;
var svgHeight: number;
var viewbox: Selection<SVGElement, unknown, null, undefined>;
var routes: Selection<SVGElement, unknown, null, undefined>;
var biomesData: {
@ -44,13 +63,17 @@ declare global {
};
var COA: any;
var notes: any[];
var style: {
burgLabels: { [key: string]: { [key: string]: string } };
burgIcons: { [key: string]: { [key: string]: string } };
anchors: { [key: string]: { [key: string]: string } };
[key: string]: any;
};
var layerIsOn: (layerId: string) => boolean;
var drawRoute: (route: any) => void;
var drawBurgIcon: (burg: any) => void;
var drawBurgLabel: (burg: any) => void;
var removeBurgIcon: (burg: any) => void;
var removeBurgLabel: (burg: any) => void;
var invokeActiveZooming: () => void;
var COArenderer: { trigger: (id: string, coa: any) => void };
var FlatQueue: any;
var tip: (
@ -61,4 +84,5 @@ declare global {
var locked: (settingId: string) => boolean;
var unlock: (settingId: string) => void;
var $: (selector: any) => any;
var scale: number;
}

View file

@ -491,7 +491,7 @@ export function* poissonDiscSampler(
return true;
}
function sample(x: number, y: number) {
function sample(x: number, y: number): [number, number] {
const point: [number, number] = [x, y];
grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point;
queue.push(point);