mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-02-04 09:31:23 +01:00
refactor: migrate renderers to ts
This commit is contained in:
parent
88c70b9264
commit
c8b0f5cd2e
31 changed files with 2097 additions and 1396 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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]})"` : ""}/>`;
|
||||
}
|
||||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
@ -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");
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -8562,19 +8563,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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export interface State {
|
|||
formName?: string;
|
||||
fullName?: string;
|
||||
form?: string;
|
||||
military?: any[];
|
||||
}
|
||||
|
||||
class StatesModule {
|
||||
|
|
|
|||
181
src/renderers/draw-borders.ts
Normal file
181
src/renderers/draw-borders.ts
Normal 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;
|
||||
145
src/renderers/draw-burg-icons.ts
Normal file
145
src/renderers/draw-burg-icons.ts
Normal 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;
|
||||
107
src/renderers/draw-burg-labels.ts
Normal file
107
src/renderers/draw-burg-labels.ts
Normal 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;
|
||||
200
src/renderers/draw-emblems.ts
Normal file
200
src/renderers/draw-emblems.ts
Normal 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;
|
||||
106
src/renderers/draw-features.ts
Normal file
106
src/renderers/draw-features.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { curveBasisClosed, line, select } from "d3";
|
||||
import type { PackedGraphFeature } from "../modules/features";
|
||||
import { clipPoly, round } from "../utils";
|
||||
|
||||
declare global {
|
||||
var drawFeatures: () => void;
|
||||
|
||||
var defs: d3.Selection<SVGDefsElement, unknown, null, undefined>;
|
||||
var coastline: d3.Selection<SVGGElement, unknown, null, undefined>;
|
||||
var lakes: d3.Selection<SVGGElement, unknown, null, undefined>;
|
||||
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);
|
||||
|
||||
const lineGen = line().curve(curveBasisClosed);
|
||||
const path = `${round(lineGen(clippedPoints) || "")}Z`;
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
window.drawFeatures = featuresRenderer;
|
||||
|
|
@ -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
102
src/renderers/draw-ice.ts
Normal 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;
|
||||
103
src/renderers/draw-markers.ts
Normal file
103
src/renderers/draw-markers.ts
Normal 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;
|
||||
216
src/renderers/draw-military.ts
Normal file
216
src/renderers/draw-military.ts
Normal 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;
|
||||
164
src/renderers/draw-relief-icons.ts
Normal file
164
src/renderers/draw-relief-icons.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
439
src/renderers/draw-state-labels.ts
Normal file
439
src/renderers/draw-state-labels.ts
Normal 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;
|
||||
155
src/renderers/draw-temperature.ts
Normal file
155
src/renderers/draw-temperature.ts
Normal 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
13
src/renderers/index.ts
Normal 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";
|
||||
|
|
@ -37,6 +37,7 @@ export interface PackedGraph {
|
|||
religion: TypedArray; // cell religion id
|
||||
state: number[]; // cell state id
|
||||
area: TypedArray; // cell area
|
||||
province: TypedArray; // cell province id
|
||||
};
|
||||
vertices: {
|
||||
i: number[]; // vertex indices
|
||||
|
|
@ -50,6 +51,9 @@ export interface PackedGraph {
|
|||
features: PackedGraphFeature[];
|
||||
burgs: Burg[];
|
||||
states: State[];
|
||||
provinces: any[];
|
||||
cultures: Culture[];
|
||||
religions: any[];
|
||||
ice: any[];
|
||||
markers: any[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,24 @@ 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 getColorScheme: (scheme: string | null) => (t: number) => string;
|
||||
var getColor: (height: number, scheme: (t: number) => string) => string;
|
||||
var svgWidth: number;
|
||||
var svgHeight: number;
|
||||
var biomesData: {
|
||||
i: number[];
|
||||
name: string[];
|
||||
|
|
@ -42,13 +58,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: (
|
||||
|
|
@ -59,4 +79,5 @@ declare global {
|
|||
var locked: (settingId: string) => boolean;
|
||||
var unlock: (settingId: string) => void;
|
||||
var $: (selector: any) => any;
|
||||
var scale: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue