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,144 +0,0 @@
"use strict";
function drawHeightmap() {
TIME && console.time("drawHeightmap");
const ocean = terrs.select("#oceanHeights");
const land = terrs.select("#landHeights");
ocean.selectAll("*").remove();
land.selectAll("*").remove();
const paths = 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]);
// 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"]);
let currentLayer = 0;
for (const i of heights) {
const h = cells.h[i];
if (h > currentLayer) currentLayer += skip;
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);
if (!onborder) continue;
const vertex = cells.v[i].find(v => vertices.c[v].some(i => 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]);
if (!paths[h]) paths[h] = "";
paths[h] += round(lineGen(points));
}
}
// land cells
{
const skip = +land.attr("skip") + 1 || 1;
const relax = +land.attr("relax") || 0;
lineGen.curve(d3[land.attr("curve") || "curveBasisClosed"]);
let currentLayer = 20;
for (const i of heights) {
const h = cells.h[i];
if (h > currentLayer) currentLayer += skip;
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);
if (!onborder) continue;
const startVertex = cells.v[i].find(v => vertices.c[v].some(i => 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]);
if (!paths[h]) paths[h] = "";
paths[h] += round(lineGen(points));
}
}
// render paths
for (const height of d3.range(0, 101)) {
const group = height < 20 ? ocean : land;
const scheme = getColorScheme(group.attr("scheme"));
if (height === 0 && renderOceanCells) {
// draw base ocean layer
group
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", graphWidth)
.attr("height", graphHeight)
.attr("fill", scheme(1));
}
if (height === 20) {
// draw base land layer
group
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", graphWidth)
.attr("height", graphHeight)
.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 (terracing) {
group
.append("path")
.attr("d", paths[height])
.attr("transform", "translate(.7,1.4)")
.attr("fill", d3.color(color).darker(terracing))
.attr("data-height", height);
}
group.append("path").attr("d", paths[height]).attr("fill", color).attr("data-height", height);
}
}
// connect vertices to chain: specific case for heightmap
function connectVertices(cells, vertices, start, h, used) {
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 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));
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;
const v = vertices.v[current]; // neighboring vertices
if (v[0] !== prev && c0 !== c1) current = v[0];
else if (v[1] !== prev && c1 !== c2) current = v[1];
else if (v[2] !== prev && c0 !== c2) current = v[2];
if (current === chain[chain.length - 1]) {
ERROR && console.error("Next vertex is not found");
break;
}
}
return chain;
}
function simplifyLine(chain, simplification) {
if (!simplification) return chain;
const n = simplification + 1; // filter each nth element
return chain.filter((d, i) => i % n === 0);
}
TIME && console.timeEnd("drawHeightmap");
}

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,103 +0,0 @@
"use strict";
function drawScaleBar(scaleBar, scaleLevel) {
if (!scaleBar.size() || scaleBar.style("display") === "none") return;
const unit = distanceUnitInput.value;
const size = +scaleBar.attr("data-bar-size");
const length = getLength(scaleLevel, size);
scaleBar.select("#scaleBarContent").remove(); // redraw content every time
const content = scaleBar.append("g").attr("id", "scaleBarContent");
const lines = content.append("g");
lines
.append("line")
.attr("x1", 0.5)
.attr("y1", 0)
.attr("x2", length + size - 0.5)
.attr("y2", 0)
.attr("stroke-width", size)
.attr("stroke", "white");
lines
.append("line")
.attr("x1", 0)
.attr("y1", size)
.attr("x2", length + size)
.attr("y2", size)
.attr("stroke-width", size)
.attr("stroke", "#3d3d3d");
lines
.append("line")
.attr("x1", 0)
.attr("y1", 0)
.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", "#3d3d3d");
const texts = content.append("g").attr("text-anchor", "middle").attr("font-family", "var(--serif)");
texts
.selectAll("text")
.data(d3.range(0, 6))
.enter()
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("x", d => rn((d * length) / 5, 2))
.attr("y", 0)
.attr("dy", "-.6em")
.text(d => rn((((d * length) / 5) * distanceScale) / scaleLevel) + (d < 5 ? "" : " " + unit));
const label = scaleBar.attr("data-label");
if (label) {
texts
.append("text")
.attr("text-rendering", "optimizeSpeed")
.attr("x", (length + 1) / 2)
.attr("dy", ".6em")
.attr("dominant-baseline", "text-before-edge")
.text(label);
}
const scaleBarBack = scaleBar.select("#scaleBarBack");
if (scaleBarBack.size()) {
const bbox = content.node().getBBox();
const paddingTop = +scaleBarBack.attr("data-top") || 0;
const paddingLeft = +scaleBarBack.attr("data-left") || 0;
const paddingRight = +scaleBarBack.attr("data-right") || 0;
const paddingBottom = +scaleBarBack.attr("data-bottom") || 0;
scaleBar
.select("#scaleBarBack")
.attr("x", -paddingLeft)
.attr("y", -paddingTop)
.attr("width", bbox.width + paddingRight)
.attr("height", bbox.height + paddingBottom);
}
}
function getLength(scaleLevel) {
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
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 posX = +scaleBar.attr("data-x") || 99;
const posY = +scaleBar.attr("data-y") || 99;
const bbox = scaleBar.select("rect").node().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})`);
}

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