mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-16 17:31:24 +01:00
* Scale bar styling (#1025) * feat: style scale bar * feat: style scale bar - style presets --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> * Ocean heightmap to v1.96 (#1044) * feat: allow to render ocean heightmap * feat: allow to render ocean heightmap - test * feat: allow to render ocean heightmap - fix issue * feat: allow to render ocean heightmap - cleanup --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> * fix: scale bar size * fix: remove mask on terrs lavel * fix: regenerate heigtmap preview to use current graph size * Add the name of culture and namesbase in the name editor dialog (#1033) * Add the name of culture and namesbase in the name editor dialog Added the name of the culture and namesbase in the dialog "name editor". This tells information on the "click to generate a culture-specific name" It tells you the culture before changing name. * cultureName into cultureId + cultureName And deleted the incomplete code of showing culture name on datatip * refactor: leave culture name only --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> * Added Burgs column to province editor (#1031) * Added Burgs column to province editor Added to province editor: + Burgs column + the number of Burgs, p.burgs.length + "icon-dot-circled" to go to overviewBurgs. + overviewBurgs Filtered by state id. + Fixed some typos. * fixed code as Azgaar suggested + Corrected provincesHeader distance in em. + const stateId = pack.provinces[p].state; - Deleted cell count. * deleted HTML code for provincesFooter cells - Deleted Total land cells number HTML from provincesFooter. * deleting totalCells in the code, maybe i will add provinceCells in the future. Deleted lines for const totalCells and for (+cells / totalCells) * 100 + "%"; * refactor: cleanup * refactor: cleanup --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> * fix: burgs overview - add MFCG link back * feat: add more details to burgs export * feat: don't show auto-update dialog * feat: pump version * fix: #1041 * feat: update style presets --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> Co-authored-by: Ángel Montero Lamas <angel.montero1@gmail.com>
2062 lines
69 KiB
JavaScript
2062 lines
69 KiB
JavaScript
// UI module stub to control map layers
|
|
"use strict";
|
|
|
|
let presets = {}; // global object
|
|
restoreCustomPresets(); // run on-load
|
|
|
|
function getDefaultPresets() {
|
|
return {
|
|
political: [
|
|
"toggleBorders",
|
|
"toggleIcons",
|
|
"toggleIce",
|
|
"toggleLabels",
|
|
"toggleRivers",
|
|
"toggleRoutes",
|
|
"toggleScaleBar",
|
|
"toggleStates",
|
|
"toggleVignette"
|
|
],
|
|
cultural: [
|
|
"toggleBorders",
|
|
"toggleCultures",
|
|
"toggleIcons",
|
|
"toggleLabels",
|
|
"toggleRivers",
|
|
"toggleRoutes",
|
|
"toggleScaleBar",
|
|
"toggleVignette"
|
|
],
|
|
religions: [
|
|
"toggleBorders",
|
|
"toggleIcons",
|
|
"toggleLabels",
|
|
"toggleReligions",
|
|
"toggleRivers",
|
|
"toggleRoutes",
|
|
"toggleScaleBar",
|
|
"toggleVignette"
|
|
],
|
|
provinces: ["toggleBorders", "toggleIcons", "toggleProvinces", "toggleRivers", "toggleScaleBar", "toggleVignette"],
|
|
biomes: ["toggleBiomes", "toggleIce", "toggleRivers", "toggleScaleBar", "toggleVignette"],
|
|
heightmap: ["toggleHeight", "toggleRivers", "toggleVignette"],
|
|
physical: ["toggleCoordinates", "toggleHeight", "toggleIce", "toggleRivers", "toggleScaleBar", "toggleVignette"],
|
|
poi: [
|
|
"toggleBorders",
|
|
"toggleHeight",
|
|
"toggleIce",
|
|
"toggleIcons",
|
|
"toggleMarkers",
|
|
"toggleRivers",
|
|
"toggleRoutes",
|
|
"toggleScaleBar",
|
|
"toggleVignette"
|
|
],
|
|
military: [
|
|
"toggleBorders",
|
|
"toggleIcons",
|
|
"toggleLabels",
|
|
"toggleMilitary",
|
|
"toggleRivers",
|
|
"toggleRoutes",
|
|
"toggleScaleBar",
|
|
"toggleStates",
|
|
"toggleVignette"
|
|
],
|
|
emblems: [
|
|
"toggleBorders",
|
|
"toggleIcons",
|
|
"toggleIce",
|
|
"toggleEmblems",
|
|
"toggleRivers",
|
|
"toggleRoutes",
|
|
"toggleScaleBar",
|
|
"toggleStates",
|
|
"toggleVignette"
|
|
],
|
|
landmass: ["toggleScaleBar"]
|
|
};
|
|
}
|
|
|
|
function restoreCustomPresets() {
|
|
presets = getDefaultPresets();
|
|
const storedPresets = JSON.parse(localStorage.getItem("presets"));
|
|
if (!storedPresets) return;
|
|
|
|
for (const preset in storedPresets) {
|
|
if (presets[preset]) continue;
|
|
layersPreset.add(new Option(preset, preset));
|
|
}
|
|
|
|
presets = storedPresets;
|
|
}
|
|
|
|
// run on map generation
|
|
function applyPreset() {
|
|
const preset = localStorage.getItem("preset") || document.getElementById("layersPreset").value;
|
|
changePreset(preset);
|
|
}
|
|
|
|
// toggle layers on preset change
|
|
function changePreset(preset) {
|
|
const layers = presets[preset]; // layers to be turned on
|
|
document
|
|
.getElementById("mapLayers")
|
|
.querySelectorAll("li")
|
|
.forEach(function (e) {
|
|
if (layers.includes(e.id) && !layerIsOn(e.id)) e.click();
|
|
else if (!layers.includes(e.id) && layerIsOn(e.id)) e.click();
|
|
});
|
|
layersPreset.value = preset;
|
|
localStorage.setItem("preset", preset);
|
|
|
|
const isDefault = getDefaultPresets()[preset];
|
|
removePresetButton.style.display = isDefault ? "none" : "inline-block";
|
|
savePresetButton.style.display = "none";
|
|
if (document.getElementById("canvas3d")) setTimeout(ThreeD.update(), 400);
|
|
}
|
|
|
|
function savePreset() {
|
|
prompt("Please provide a preset name", {default: ""}, preset => {
|
|
presets[preset] = Array.from(document.getElementById("mapLayers").querySelectorAll("li:not(.buttonoff)"))
|
|
.map(node => node.id)
|
|
.sort();
|
|
layersPreset.add(new Option(preset, preset, false, true));
|
|
localStorage.setItem("presets", JSON.stringify(presets));
|
|
localStorage.setItem("preset", preset);
|
|
removePresetButton.style.display = "inline-block";
|
|
savePresetButton.style.display = "none";
|
|
});
|
|
}
|
|
|
|
function removePreset() {
|
|
const preset = layersPreset.value;
|
|
delete presets[preset];
|
|
const index = Array.from(layersPreset.options).findIndex(o => o.value === preset);
|
|
layersPreset.options.remove(index);
|
|
layersPreset.value = "custom";
|
|
removePresetButton.style.display = "none";
|
|
savePresetButton.style.display = "inline-block";
|
|
|
|
localStorage.setItem("presets", JSON.stringify(presets));
|
|
localStorage.removeItem("preset");
|
|
}
|
|
|
|
function getCurrentPreset() {
|
|
const layers = Array.from(document.getElementById("mapLayers").querySelectorAll("li:not(.buttonoff)"))
|
|
.map(node => node.id)
|
|
.sort();
|
|
const defaultPresets = getDefaultPresets();
|
|
|
|
for (const preset in presets) {
|
|
if (JSON.stringify(presets[preset]) !== JSON.stringify(layers)) continue;
|
|
layersPreset.value = preset;
|
|
removePresetButton.style.display = defaultPresets[preset] ? "none" : "inline-block";
|
|
savePresetButton.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
layersPreset.value = "custom";
|
|
removePresetButton.style.display = "none";
|
|
savePresetButton.style.display = "inline-block";
|
|
}
|
|
|
|
// run on map regeneration
|
|
function restoreLayers() {
|
|
if (layerIsOn("toggleTexture")) drawTexture();
|
|
if (layerIsOn("toggleHeight")) drawHeightmap();
|
|
if (layerIsOn("toggleCells")) drawCells();
|
|
if (layerIsOn("toggleGrid")) drawGrid();
|
|
if (layerIsOn("toggleCoordinates")) drawCoordinates();
|
|
if (layerIsOn("toggleCompass")) compass.style("display", "block");
|
|
if (layerIsOn("toggleTemp")) drawTemp();
|
|
if (layerIsOn("togglePrec")) drawPrec();
|
|
if (layerIsOn("togglePopulation")) drawPopulation();
|
|
if (layerIsOn("toggleBiomes")) drawBiomes();
|
|
if (layerIsOn("toggleRelief")) ReliefIcons();
|
|
if (layerIsOn("toggleCultures")) drawCultures();
|
|
if (layerIsOn("toggleProvinces")) drawProvinces();
|
|
if (layerIsOn("toggleReligions")) drawReligions();
|
|
if (layerIsOn("toggleIce")) drawIce();
|
|
if (layerIsOn("toggleEmblems")) drawEmblems();
|
|
if (layerIsOn("toggleMarkers")) drawMarkers();
|
|
|
|
// some layers are rendered each time, remove them if they are not on
|
|
if (!layerIsOn("toggleBorders")) borders.selectAll("path").remove();
|
|
if (!layerIsOn("toggleStates")) regions.selectAll("path").remove();
|
|
if (!layerIsOn("toggleRivers")) rivers.selectAll("*").remove();
|
|
}
|
|
|
|
function toggleHeight(event) {
|
|
if (customization === 1) return tip("You cannot turn off the layer when heightmap is in edit mode", false, "error");
|
|
|
|
const children = terrs.selectAll("#oceanHeights > *, #landHeights > *");
|
|
if (!children.size()) {
|
|
turnButtonOn("toggleHeight");
|
|
drawHeightmap();
|
|
if (event && isCtrlClick(event)) editStyle("terrs");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("terrs");
|
|
turnButtonOff("toggleHeight");
|
|
children.remove();
|
|
}
|
|
}
|
|
|
|
function drawHeightmap() {
|
|
TIME && console.time("drawHeightmap");
|
|
|
|
const ocean = terrs.select("#oceanHeights");
|
|
const land = terrs.select("#landHeights");
|
|
|
|
ocean.selectAll("*").remove();
|
|
land.selectAll("*").remove();
|
|
|
|
const paths = new Array(101);
|
|
|
|
// ocean cells
|
|
const renderOceanCells = Boolean(+ocean.attr("data-render"));
|
|
if (renderOceanCells) {
|
|
const {cells, vertices} = grid;
|
|
const used = new Uint8Array(cells.i.length);
|
|
|
|
const skip = +ocean.attr("skip") + 1 || 1;
|
|
const relax = +ocean.attr("relax") || 0;
|
|
lineGen.curve(d3[ocean.attr("curve") || "curveBasisClosed"]);
|
|
|
|
let currentLayer = 0;
|
|
const heights = Array.from(cells.i).sort((a, b) => cells.h[a] - cells.h[b]);
|
|
|
|
for (const i of heights) {
|
|
const h = cells.h[i];
|
|
if (h > currentLayer) currentLayer += skip;
|
|
if (h < currentLayer) continue;
|
|
if (currentLayer >= 20) break;
|
|
if (used[i]) continue; // already marked
|
|
const onborder = cells.c[i].some(n => cells.h[n] < h);
|
|
if (!onborder) continue;
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.h[i] < h));
|
|
const chain = connectVertices(cells, vertices, vertex, h, used);
|
|
if (chain.length < 3) continue;
|
|
const points = simplifyLine(chain, relax).map(v => vertices.p[v]);
|
|
if (!paths[h]) paths[h] = "";
|
|
paths[h] += round(lineGen(points));
|
|
}
|
|
}
|
|
|
|
// land cells
|
|
{
|
|
const {cells, vertices} = pack;
|
|
const used = new Uint8Array(cells.i.length);
|
|
|
|
const skip = +land.attr("skip") + 1 || 1;
|
|
const relax = +land.attr("relax") || 0;
|
|
lineGen.curve(d3[land.attr("curve") || "curveBasisClosed"]);
|
|
|
|
let currentLayer = 20;
|
|
const heights = Array.from(cells.i).sort((a, b) => cells.h[a] - cells.h[b]);
|
|
for (const i of heights) {
|
|
const h = cells.h[i];
|
|
if (h > currentLayer) currentLayer += skip;
|
|
if (h < currentLayer) continue;
|
|
if (currentLayer > 100) break; // no layers possible with height > 100
|
|
if (used[i]) continue; // already marked
|
|
const onborder = cells.c[i].some(n => cells.h[n] < h);
|
|
if (!onborder) continue;
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.h[i] < h));
|
|
const chain = connectVertices(cells, vertices, vertex, h, used);
|
|
if (chain.length < 3) continue;
|
|
const points = simplifyLine(chain, relax).map(v => vertices.p[v]);
|
|
if (!paths[h]) paths[h] = "";
|
|
paths[h] += round(lineGen(points));
|
|
}
|
|
}
|
|
|
|
// render paths
|
|
for (const height of d3.range(0, 101)) {
|
|
const group = height < 20 ? ocean : land;
|
|
const scheme = getColorScheme(group.attr("scheme"));
|
|
|
|
if (height === 0 && renderOceanCells) {
|
|
// draw base ocean layer
|
|
group
|
|
.append("rect")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("width", graphWidth)
|
|
.attr("height", graphHeight)
|
|
.attr("fill", scheme(1));
|
|
}
|
|
|
|
if (height === 20) {
|
|
// draw base land layer
|
|
group
|
|
.append("rect")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("width", graphWidth)
|
|
.attr("height", graphHeight)
|
|
.attr("fill", scheme(0.8));
|
|
}
|
|
|
|
if (paths[height] && paths[height].length >= 10) {
|
|
const terracing = group.attr("terracing") / 10 || 0;
|
|
const color = getColor(height, scheme);
|
|
|
|
if (terracing) {
|
|
group
|
|
.append("path")
|
|
.attr("d", paths[height])
|
|
.attr("transform", "translate(.7,1.4)")
|
|
.attr("fill", d3.color(color).darker(terracing))
|
|
.attr("data-height", height);
|
|
}
|
|
group.append("path").attr("d", paths[height]).attr("fill", color).attr("data-height", height);
|
|
}
|
|
}
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(cells, vertices, start, h, used) {
|
|
const n = cells.i.length;
|
|
const chain = []; // vertices chain to form a path
|
|
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); i++) {
|
|
const prev = chain[chain.length - 1]; // previous vertex in chain
|
|
chain.push(current); // add current vertex to sequence
|
|
const c = vertices.c[current]; // cells adjacent to vertex
|
|
c.filter(c => cells.h[c] === h).forEach(c => (used[c] = 1));
|
|
const c0 = c[0] >= n || cells.h[c[0]] < h;
|
|
const c1 = c[1] >= n || cells.h[c[1]] < h;
|
|
const c2 = c[2] >= n || cells.h[c[2]] < h;
|
|
const v = vertices.v[current]; // neighboring vertices
|
|
if (v[0] !== prev && c0 !== c1) current = v[0];
|
|
else if (v[1] !== prev && c1 !== c2) current = v[1];
|
|
else if (v[2] !== prev && c0 !== c2) current = v[2];
|
|
if (current === chain[chain.length - 1]) {
|
|
ERROR && console.error("Next vertex is not found");
|
|
break;
|
|
}
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
function simplifyLine(chain, simplification) {
|
|
if (!simplification) return chain;
|
|
const n = simplification + 1; // filter each nth element
|
|
return chain.filter((d, i) => i % n === 0);
|
|
}
|
|
|
|
TIME && console.timeEnd("drawHeightmap");
|
|
}
|
|
|
|
function getColor(value, scheme = getColorScheme("bright")) {
|
|
return scheme(1 - (value < 20 ? value - 5 : value) / 100);
|
|
}
|
|
|
|
function toggleTemp(event) {
|
|
if (!temperature.selectAll("*").size()) {
|
|
turnButtonOn("toggleTemp");
|
|
drawTemp();
|
|
if (event && isCtrlClick(event)) editStyle("temperature");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("temperature");
|
|
return;
|
|
}
|
|
turnButtonOff("toggleTemp");
|
|
temperature.selectAll("*").remove();
|
|
}
|
|
}
|
|
|
|
function drawTemp() {
|
|
TIME && console.time("drawTemp");
|
|
temperature.selectAll("*").remove();
|
|
lineGen.curve(d3.curveBasisClosed);
|
|
const scheme = d3.scaleSequential(d3.interpolateSpectral);
|
|
const tMax = +temperatureEquatorOutput.max,
|
|
tMin = +temperatureEquatorOutput.min,
|
|
delta = tMax - tMin;
|
|
|
|
const cells = grid.cells,
|
|
vertices = grid.vertices,
|
|
n = cells.i.length;
|
|
const used = new Uint8Array(n); // to detect already passed cells
|
|
const min = d3.min(cells.temp),
|
|
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 = [],
|
|
labels = []; // store label coordinates
|
|
|
|
for (const i of cells.i) {
|
|
const t = cells.temp[i];
|
|
if (used[i] || !isolines.includes(t)) continue;
|
|
const start = findStart(i, t);
|
|
if (!start) continue;
|
|
used[i] = 1;
|
|
//debug.append("circle").attr("r", 3).attr("cx", vertices.p[start][0]).attr("cy", vertices.p[start][1]).attr("fill", "red").attr("stroke", "black").attr("stroke-width", .3);
|
|
|
|
const chain = connectVertices(start, t); // vertices chain to form a path
|
|
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 c = svgWidth / 2; // map center x coordinate
|
|
// add label on isoline top center
|
|
const tc = points[d3.scan(points, (a, b) => a[1] - b[1] + (Math.abs(a[0] - c) - Math.abs(b[0] - c)) / 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] - c) - Math.abs(b[0] - c)) / 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]);
|
|
}
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(start, t) {
|
|
const chain = []; // vertices chain to form a path
|
|
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); 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.temp[c] === t).forEach(c => (used[c] = 1));
|
|
const c0 = c[0] >= n || cells.temp[c[0]] < t;
|
|
const c1 = c[1] >= n || cells.temp[c[1]] < t;
|
|
const c2 = c[2] >= n || cells.temp[c[2]] < t;
|
|
const v = vertices.v[current]; // neighboring vertices
|
|
if (v[0] !== prev && c0 !== c1) current = v[0];
|
|
else if (v[1] !== prev && c1 !== c2) current = v[1];
|
|
else if (v[2] !== prev && c0 !== c2) current = v[2];
|
|
if (current === chain[chain.length - 1]) {
|
|
ERROR && console.error("Next vertex is not found");
|
|
break;
|
|
}
|
|
}
|
|
chain.push(start);
|
|
return chain;
|
|
}
|
|
TIME && console.timeEnd("drawTemp");
|
|
}
|
|
|
|
function toggleBiomes(event) {
|
|
if (!biomes.selectAll("path").size()) {
|
|
turnButtonOn("toggleBiomes");
|
|
drawBiomes();
|
|
if (event && isCtrlClick(event)) editStyle("biomes");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("biomes");
|
|
return;
|
|
}
|
|
biomes.selectAll("path").remove();
|
|
turnButtonOff("toggleBiomes");
|
|
}
|
|
}
|
|
|
|
function drawBiomes() {
|
|
biomes.selectAll("path").remove();
|
|
const cells = pack.cells,
|
|
vertices = pack.vertices,
|
|
n = cells.i.length;
|
|
const used = new Uint8Array(cells.i.length);
|
|
const paths = new Array(biomesData.i.length).fill("");
|
|
|
|
for (const i of cells.i) {
|
|
if (!cells.biome[i]) continue; // no need to mark marine biome (liquid water)
|
|
if (used[i]) continue; // already marked
|
|
const b = cells.biome[i];
|
|
const onborder = cells.c[i].some(n => cells.biome[n] !== b);
|
|
if (!onborder) continue;
|
|
const edgeVerticle = cells.v[i].find(v => vertices.c[v].some(i => cells.biome[i] !== b));
|
|
const chain = connectVertices(edgeVerticle, b);
|
|
if (chain.length < 3) continue;
|
|
const points = clipPoly(
|
|
chain.map(v => vertices.p[v]),
|
|
1
|
|
);
|
|
paths[b] += "M" + points.join("L") + "Z";
|
|
}
|
|
|
|
paths.forEach(function (d, i) {
|
|
if (d.length < 10) return;
|
|
biomes
|
|
.append("path")
|
|
.attr("d", d)
|
|
.attr("fill", biomesData.color[i])
|
|
.attr("stroke", biomesData.color[i])
|
|
.attr("id", "biome" + i);
|
|
});
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(start, b) {
|
|
const chain = []; // vertices chain to form a path
|
|
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); 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.biome[c] === b).forEach(c => (used[c] = 1));
|
|
const c0 = c[0] >= n || cells.biome[c[0]] !== b;
|
|
const c1 = c[1] >= n || cells.biome[c[1]] !== b;
|
|
const c2 = c[2] >= n || cells.biome[c[2]] !== b;
|
|
const v = vertices.v[current]; // neighboring vertices
|
|
if (v[0] !== prev && c0 !== c1) current = v[0];
|
|
else if (v[1] !== prev && c1 !== c2) current = v[1];
|
|
else if (v[2] !== prev && c0 !== c2) current = v[2];
|
|
if (current === chain[chain.length - 1]) {
|
|
ERROR && console.error("Next vertex is not found");
|
|
break;
|
|
}
|
|
}
|
|
return chain;
|
|
}
|
|
}
|
|
|
|
function togglePrec(event) {
|
|
if (!prec.selectAll("circle").size()) {
|
|
turnButtonOn("togglePrec");
|
|
drawPrec();
|
|
if (event && isCtrlClick(event)) editStyle("prec");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("prec");
|
|
return;
|
|
}
|
|
turnButtonOff("togglePrec");
|
|
const hide = d3.transition().duration(1000).ease(d3.easeSinIn);
|
|
prec.selectAll("text").attr("opacity", 1).transition(hide).attr("opacity", 0);
|
|
prec.selectAll("circle").transition(hide).attr("r", 0).remove();
|
|
prec.transition().delay(1000).style("display", "none");
|
|
}
|
|
}
|
|
|
|
function drawPrec() {
|
|
prec.selectAll("circle").remove();
|
|
const {cells, points} = grid;
|
|
|
|
prec.style("display", "block");
|
|
const show = d3.transition().duration(800).ease(d3.easeSinIn);
|
|
prec.selectAll("text").attr("opacity", 0).transition(show).attr("opacity", 1);
|
|
|
|
const cellsNumberModifier = (pointsInput.dataset.cells / 10000) ** 0.25;
|
|
const data = cells.i.filter(i => cells.h[i] >= 20 && cells.prec[i]);
|
|
const getRadius = prec => rn(Math.sqrt(prec / 4) / cellsNumberModifier, 2);
|
|
|
|
prec
|
|
.selectAll("circle")
|
|
.data(data)
|
|
.enter()
|
|
.append("circle")
|
|
.attr("cx", d => points[d][0])
|
|
.attr("cy", d => points[d][1])
|
|
.attr("r", 0)
|
|
.transition(show)
|
|
.attr("r", d => getRadius(cells.prec[d]));
|
|
}
|
|
|
|
function togglePopulation(event) {
|
|
if (!population.selectAll("line").size()) {
|
|
turnButtonOn("togglePopulation");
|
|
drawPopulation();
|
|
if (event && isCtrlClick(event)) editStyle("population");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("population");
|
|
return;
|
|
}
|
|
turnButtonOff("togglePopulation");
|
|
const isD3data = population.select("line").datum();
|
|
if (!isD3data) {
|
|
// just remove
|
|
population.selectAll("line").remove();
|
|
} else {
|
|
// remove with animation
|
|
const hide = d3.transition().duration(1000).ease(d3.easeSinIn);
|
|
population
|
|
.select("#rural")
|
|
.selectAll("line")
|
|
.transition(hide)
|
|
.attr("y2", d => d[1])
|
|
.remove();
|
|
population
|
|
.select("#urban")
|
|
.selectAll("line")
|
|
.transition(hide)
|
|
.delay(1000)
|
|
.attr("y2", d => d[1])
|
|
.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawPopulation(event) {
|
|
population.selectAll("line").remove();
|
|
const cells = pack.cells,
|
|
p = cells.p,
|
|
burgs = pack.burgs;
|
|
const show = d3.transition().duration(2000).ease(d3.easeSinIn);
|
|
|
|
const rural = Array.from(
|
|
cells.i.filter(i => cells.pop[i] > 0),
|
|
i => [p[i][0], p[i][1], p[i][1] - cells.pop[i] / 8]
|
|
);
|
|
population
|
|
.select("#rural")
|
|
.selectAll("line")
|
|
.data(rural)
|
|
.enter()
|
|
.append("line")
|
|
.attr("x1", d => d[0])
|
|
.attr("y1", d => d[1])
|
|
.attr("x2", d => d[0])
|
|
.attr("y2", d => d[1])
|
|
.transition(show)
|
|
.attr("y2", d => d[2]);
|
|
|
|
const urban = burgs.filter(b => b.i && !b.removed).map(b => [b.x, b.y, b.y - (b.population / 8) * urbanization]);
|
|
population
|
|
.select("#urban")
|
|
.selectAll("line")
|
|
.data(urban)
|
|
.enter()
|
|
.append("line")
|
|
.attr("x1", d => d[0])
|
|
.attr("y1", d => d[1])
|
|
.attr("x2", d => d[0])
|
|
.attr("y2", d => d[1])
|
|
.transition(show)
|
|
.delay(500)
|
|
.attr("y2", d => d[2]);
|
|
}
|
|
|
|
function toggleCells(event) {
|
|
if (!cells.selectAll("path").size()) {
|
|
turnButtonOn("toggleCells");
|
|
drawCells();
|
|
if (event && isCtrlClick(event)) editStyle("cells");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("cells");
|
|
return;
|
|
}
|
|
cells.selectAll("path").remove();
|
|
turnButtonOff("toggleCells");
|
|
}
|
|
}
|
|
|
|
function drawCells() {
|
|
cells.selectAll("path").remove();
|
|
const data = customization === 1 ? grid.cells.i : pack.cells.i;
|
|
const polygon = customization === 1 ? getGridPolygon : getPackPolygon;
|
|
let path = "";
|
|
data.forEach(i => (path += "M" + polygon(i)));
|
|
cells.append("path").attr("d", path);
|
|
}
|
|
|
|
function toggleIce(event) {
|
|
if (!layerIsOn("toggleIce")) {
|
|
turnButtonOn("toggleIce");
|
|
$("#ice").fadeIn();
|
|
if (!ice.selectAll("*").size()) drawIce();
|
|
if (event && isCtrlClick(event)) editStyle("ice");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("ice");
|
|
return;
|
|
}
|
|
$("#ice").fadeOut();
|
|
turnButtonOff("toggleIce");
|
|
}
|
|
}
|
|
|
|
function drawIce() {
|
|
const {cells, vertices} = grid;
|
|
const {temp, h} = cells;
|
|
const n = cells.i.length;
|
|
|
|
const used = new Uint8Array(cells.i.length);
|
|
Math.random = aleaPRNG(seed);
|
|
|
|
const shieldMin = -8; // max temp to form ice shield (glacier)
|
|
const icebergMax = 1; // max temp to form an iceberg
|
|
|
|
for (const i of grid.cells.i) {
|
|
const t = temp[i];
|
|
if (t > icebergMax) continue; // too warm: no ice
|
|
if (t > shieldMin && h[i] >= 20) continue; // non-glacier land: no ice
|
|
|
|
if (t <= shieldMin) {
|
|
// very cold: ice shield
|
|
if (used[i]) continue; // already rendered
|
|
const onborder = cells.c[i].some(n => temp[n] > shieldMin);
|
|
if (!onborder) continue; // need to start from onborder cell
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => temp[i] > shieldMin));
|
|
const chain = connectVertices(vertex);
|
|
if (chain.length < 3) continue;
|
|
const points = clipPoly(chain.map(v => vertices.p[v]));
|
|
ice.append("polygon").attr("points", points).attr("type", "iceShield");
|
|
continue;
|
|
}
|
|
|
|
const tNormalized = normalize(t, -8, 2);
|
|
const randomFactor = t > -5 ? 0.4 + rand() * 1.2 : 1;
|
|
|
|
// mildly cold: iceberd
|
|
if (P(tNormalized ** 0.5 * randomFactor)) continue; // cold: skip some cells
|
|
if (grid.features[cells.f[i]].type === "lake") continue; // lake: no icebers
|
|
|
|
let size = 1 - tNormalized; // iceberg size: 0 = zero size, 1 = full size
|
|
if (cells.t[i] === -1) size /= 1.3; // coasline: smaller icebers
|
|
resizePolygon(i, minmax(rn(size * randomFactor, 2), 0.08, 1));
|
|
}
|
|
|
|
function resizePolygon(i, size) {
|
|
const [cx, cy] = grid.points[i];
|
|
const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]);
|
|
ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size);
|
|
}
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(start) {
|
|
const chain = []; // vertices chain to form a path
|
|
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); i++) {
|
|
const prev = last(chain); // previous vertex in chain
|
|
chain.push(current); // add current vertex to sequence
|
|
const c = vertices.c[current]; // cells adjacent to vertex
|
|
c.filter(c => temp[c] <= shieldMin).forEach(c => (used[c] = 1));
|
|
const c0 = c[0] >= n || temp[c[0]] > shieldMin;
|
|
const c1 = c[1] >= n || temp[c[1]] > shieldMin;
|
|
const c2 = c[2] >= n || temp[c[2]] > shieldMin;
|
|
const v = vertices.v[current]; // neighboring vertices
|
|
if (v[0] !== prev && c0 !== c1) current = v[0];
|
|
else if (v[1] !== prev && c1 !== c2) current = v[1];
|
|
else if (v[2] !== prev && c0 !== c2) current = v[2];
|
|
if (current === chain[chain.length - 1]) {
|
|
ERROR && console.error("Next vertex is not found");
|
|
break;
|
|
}
|
|
}
|
|
return chain;
|
|
}
|
|
}
|
|
|
|
function toggleCultures(event) {
|
|
const cultures = pack.cultures.filter(c => c.i && !c.removed);
|
|
const empty = !cults.selectAll("path").size();
|
|
if (empty && cultures.length) {
|
|
turnButtonOn("toggleCultures");
|
|
drawCultures();
|
|
if (event && isCtrlClick(event)) editStyle("cults");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("cults");
|
|
return;
|
|
}
|
|
cults.selectAll("path").remove();
|
|
turnButtonOff("toggleCultures");
|
|
}
|
|
}
|
|
|
|
function drawCultures() {
|
|
TIME && console.time("drawCultures");
|
|
|
|
cults.selectAll("path").remove();
|
|
const {cells, vertices, cultures} = pack;
|
|
const n = cells.i.length;
|
|
const used = new Uint8Array(cells.i.length);
|
|
const paths = new Array(cultures.length).fill("");
|
|
|
|
for (const i of cells.i) {
|
|
if (!cells.culture[i]) continue;
|
|
if (used[i]) continue;
|
|
used[i] = 1;
|
|
const c = cells.culture[i];
|
|
const onborder = cells.c[i].some(n => cells.culture[n] !== c);
|
|
if (!onborder) continue;
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.culture[i] !== c));
|
|
const chain = connectVertices(vertex, c);
|
|
if (chain.length < 3) continue;
|
|
const points = chain.map(v => vertices.p[v]);
|
|
paths[c] += "M" + points.join("L") + "Z";
|
|
}
|
|
|
|
const data = paths.map((p, i) => [p, i]).filter(d => d[0].length > 10);
|
|
cults
|
|
.selectAll("path")
|
|
.data(data)
|
|
.enter()
|
|
.append("path")
|
|
.attr("d", d => d[0])
|
|
.attr("fill", d => cultures[d[1]].color)
|
|
.attr("id", d => "culture" + d[1]);
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(start, t) {
|
|
const chain = []; // vertices chain to form a path
|
|
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); 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.culture[c] === t).forEach(c => (used[c] = 1));
|
|
const c0 = c[0] >= n || cells.culture[c[0]] !== t;
|
|
const c1 = c[1] >= n || cells.culture[c[1]] !== t;
|
|
const c2 = c[2] >= n || cells.culture[c[2]] !== t;
|
|
const v = vertices.v[current]; // neighboring vertices
|
|
if (v[0] !== prev && c0 !== c1) current = v[0];
|
|
else if (v[1] !== prev && c1 !== c2) current = v[1];
|
|
else if (v[2] !== prev && c0 !== c2) current = v[2];
|
|
if (current === chain[chain.length - 1]) {
|
|
ERROR && console.error("Next vertex is not found");
|
|
break;
|
|
}
|
|
}
|
|
return chain;
|
|
}
|
|
TIME && console.timeEnd("drawCultures");
|
|
}
|
|
|
|
function toggleReligions(event) {
|
|
const religions = pack.religions.filter(r => r.i && !r.removed);
|
|
if (!relig.selectAll("path").size() && religions.length) {
|
|
turnButtonOn("toggleReligions");
|
|
drawReligions();
|
|
if (event && isCtrlClick(event)) editStyle("relig");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("relig");
|
|
return;
|
|
}
|
|
relig.selectAll("path").remove();
|
|
turnButtonOff("toggleReligions");
|
|
}
|
|
}
|
|
|
|
function drawReligions() {
|
|
TIME && console.time("drawReligions");
|
|
|
|
relig.selectAll("path").remove();
|
|
const {cells, vertices, religions} = pack;
|
|
const n = cells.i.length;
|
|
|
|
const used = new Uint8Array(cells.i.length);
|
|
const vArray = new Array(religions.length); // store vertices array
|
|
const body = new Array(religions.length).fill(""); // store path around each religion
|
|
const gap = new Array(religions.length).fill(""); // store path along water for each religion to fill the gaps
|
|
|
|
for (const i of cells.i) {
|
|
if (!cells.religion[i]) continue;
|
|
if (used[i]) continue;
|
|
used[i] = 1;
|
|
const r = cells.religion[i];
|
|
const onborder = cells.c[i].filter(n => cells.religion[n] !== r);
|
|
if (!onborder.length) continue;
|
|
const borderWith = cells.c[i].map(c => cells.religion[c]).find(n => n !== r);
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.religion[i] === borderWith));
|
|
const chain = connectVertices(vertex, r, borderWith);
|
|
if (chain.length < 3) continue;
|
|
const points = chain.map(v => vertices.p[v[0]]);
|
|
if (!vArray[r]) vArray[r] = [];
|
|
vArray[r].push(points);
|
|
body[r] += "M" + points.join("L") + "Z";
|
|
gap[r] +=
|
|
"M" +
|
|
vertices.p[chain[0][0]] +
|
|
chain.reduce(
|
|
(r2, v, i, d) =>
|
|
!i ? r2 : !v[2] ? r2 + "L" + vertices.p[v[0]] : d[i + 1] && !d[i + 1][2] ? r2 + "M" + vertices.p[v[0]] : r2,
|
|
""
|
|
);
|
|
}
|
|
|
|
const bodyData = body.map((p, i) => [p.length > 10 ? p : null, i, religions[i].color]).filter(d => d[0]);
|
|
relig
|
|
.selectAll("path")
|
|
.data(bodyData)
|
|
.enter()
|
|
.append("path")
|
|
.attr("d", d => d[0])
|
|
.attr("fill", d => d[2])
|
|
.attr("id", d => "religion" + d[1]);
|
|
const gapData = gap.map((p, i) => [p.length > 10 ? p : null, i, religions[i].color]).filter(d => d[0]);
|
|
relig
|
|
.selectAll(".path")
|
|
.data(gapData)
|
|
.enter()
|
|
.append("path")
|
|
.attr("d", d => d[0])
|
|
.attr("fill", "none")
|
|
.attr("stroke", d => d[2])
|
|
.attr("id", d => "religion-gap" + d[1])
|
|
.attr("stroke-width", "10px");
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(start, t, religion) {
|
|
const chain = []; // vertices chain to form a path
|
|
let land = vertices.c[start].some(c => cells.h[c] >= 20 && cells.religion[c] !== t);
|
|
function check(i) {
|
|
religion = cells.religion[i];
|
|
land = cells.h[i] >= 20;
|
|
}
|
|
|
|
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); i++) {
|
|
const prev = chain[chain.length - 1] ? chain[chain.length - 1][0] : -1; // previous vertex in chain
|
|
chain.push([current, religion, land]); // add current vertex to sequence
|
|
const c = vertices.c[current]; // cells adjacent to vertex
|
|
c.filter(c => cells.religion[c] === t).forEach(c => (used[c] = 1));
|
|
const c0 = c[0] >= n || cells.religion[c[0]] !== t;
|
|
const c1 = c[1] >= n || cells.religion[c[1]] !== t;
|
|
const c2 = c[2] >= n || cells.religion[c[2]] !== t;
|
|
const v = vertices.v[current]; // neighboring vertices
|
|
if (v[0] !== prev && c0 !== c1) {
|
|
current = v[0];
|
|
check(c0 ? c[0] : c[1]);
|
|
} else if (v[1] !== prev && c1 !== c2) {
|
|
current = v[1];
|
|
check(c1 ? c[1] : c[2]);
|
|
} else if (v[2] !== prev && c0 !== c2) {
|
|
current = v[2];
|
|
check(c2 ? c[2] : c[0]);
|
|
}
|
|
if (current === chain[chain.length - 1][0]) {
|
|
ERROR && console.error("Next vertex is not found");
|
|
break;
|
|
}
|
|
}
|
|
return chain;
|
|
}
|
|
TIME && console.timeEnd("drawReligions");
|
|
}
|
|
|
|
function toggleStates(event) {
|
|
if (!layerIsOn("toggleStates")) {
|
|
turnButtonOn("toggleStates");
|
|
regions.style("display", null);
|
|
drawStates();
|
|
if (event && isCtrlClick(event)) editStyle("regions");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("regions");
|
|
return;
|
|
}
|
|
regions.style("display", "none").selectAll("path").remove();
|
|
turnButtonOff("toggleStates");
|
|
}
|
|
}
|
|
|
|
function drawStates() {
|
|
TIME && console.time("drawStates");
|
|
regions.selectAll("path").remove();
|
|
|
|
const {cells, vertices, features} = pack;
|
|
const states = pack.states;
|
|
const n = cells.i.length;
|
|
|
|
const used = new Uint8Array(cells.i.length);
|
|
const vArray = new Array(states.length); // store vertices array
|
|
const body = new Array(states.length).fill(""); // path around each state
|
|
const gap = new Array(states.length).fill(""); // path along water for each state to fill the gaps
|
|
const halo = new Array(states.length).fill(""); // path around states, but not lakes
|
|
|
|
const getStringPoint = v => vertices.p[v[0]].join(",");
|
|
|
|
// define inner-state lakes to omit on border render
|
|
const innerLakes = features.map(feature => {
|
|
if (feature.type !== "lake") return false;
|
|
if (!feature.shoreline) Lakes.getShoreline(feature);
|
|
|
|
const states = feature.shoreline.map(i => cells.state[i]);
|
|
return new Set(states).size > 1 ? false : true;
|
|
});
|
|
|
|
for (const i of cells.i) {
|
|
if (!cells.state[i] || used[i]) continue;
|
|
const state = cells.state[i];
|
|
|
|
const onborder = cells.c[i].some(n => cells.state[n] !== state);
|
|
if (!onborder) continue;
|
|
|
|
const borderWith = cells.c[i].map(c => cells.state[c]).find(n => n !== state);
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.state[i] === borderWith));
|
|
const chain = connectVertices(vertex, state);
|
|
|
|
const noInnerLakes = chain.filter(v => v[1] !== "innerLake");
|
|
if (noInnerLakes.length < 3) continue;
|
|
|
|
// get path around the state
|
|
if (!vArray[state]) vArray[state] = [];
|
|
const points = noInnerLakes.map(v => vertices.p[v[0]]);
|
|
vArray[state].push(points);
|
|
body[state] += "M" + points.join("L");
|
|
|
|
// connect path for halo
|
|
let discontinued = true;
|
|
halo[state] += noInnerLakes
|
|
.map(v => {
|
|
if (v[1] === "border") {
|
|
discontinued = true;
|
|
return "";
|
|
}
|
|
|
|
const operation = discontinued ? "M" : "L";
|
|
discontinued = false;
|
|
return `${operation}${getStringPoint(v)}`;
|
|
})
|
|
.join("");
|
|
|
|
// connect gaps between state and water into a single path
|
|
discontinued = true;
|
|
gap[state] += chain
|
|
.map(v => {
|
|
if (v[1] === "land") {
|
|
discontinued = true;
|
|
return "";
|
|
}
|
|
|
|
const operation = discontinued ? "M" : "L";
|
|
discontinued = false;
|
|
return `${operation}${getStringPoint(v)}`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
// find state visual center
|
|
vArray.forEach((ar, i) => {
|
|
const sorted = ar.sort((a, b) => b.length - a.length); // sort by points number
|
|
states[i].pole = polylabel(sorted, 1.0); // pole of inaccessibility
|
|
});
|
|
|
|
const bodyData = body.map((p, s) => [p.length > 10 ? p : null, s, states[s].color]).filter(d => d[0]);
|
|
const gapData = gap.map((p, s) => [p.length > 10 ? p : null, s, states[s].color]).filter(d => d[0]);
|
|
const haloData = halo.map((p, s) => [p.length > 10 ? p : null, s, states[s].color]).filter(d => d[0]);
|
|
|
|
const bodyString = bodyData.map(d => `<path id="state${d[1]}" d="${d[0]}" fill="${d[2]}" stroke="none"/>`).join("");
|
|
const gapString = gapData.map(d => `<path id="state-gap${d[1]}" d="${d[0]}" fill="none" stroke="${d[2]}"/>`).join("");
|
|
const clipString = bodyData
|
|
.map(d => `<clipPath id="state-clip${d[1]}"><use href="#state${d[1]}"/></clipPath>`)
|
|
.join("");
|
|
const haloString = haloData
|
|
.map(
|
|
d =>
|
|
`<path id="state-border${d[1]}" d="${d[0]}" clip-path="url(#state-clip${d[1]})" stroke="${
|
|
d3.color(d[2]) ? d3.color(d[2]).darker().hex() : "#666666"
|
|
}"/>`
|
|
)
|
|
.join("");
|
|
|
|
statesBody.html(bodyString + gapString);
|
|
defs.select("#statePaths").html(clipString);
|
|
statesHalo.html(haloString);
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(start, state) {
|
|
const chain = []; // vertices chain to form a path
|
|
const getType = c => {
|
|
const borderCell = c.find(i => cells.b[i]);
|
|
if (borderCell) return "border";
|
|
|
|
const waterCell = c.find(i => cells.h[i] < 20);
|
|
if (!waterCell) return "land";
|
|
if (innerLakes[cells.f[waterCell]]) return "innerLake";
|
|
return features[cells.f[waterCell]].type;
|
|
};
|
|
|
|
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); i++) {
|
|
const prev = chain.length ? chain[chain.length - 1][0] : -1; // previous vertex in chain
|
|
|
|
const c = vertices.c[current]; // cells adjacent to vertex
|
|
chain.push([current, getType(c)]); // add current vertex to sequence
|
|
|
|
c.filter(c => cells.state[c] === state).forEach(c => (used[c] = 1));
|
|
const c0 = c[0] >= n || cells.state[c[0]] !== state;
|
|
const c1 = c[1] >= n || cells.state[c[1]] !== state;
|
|
const c2 = c[2] >= n || cells.state[c[2]] !== state;
|
|
|
|
const v = vertices.v[current]; // neighboring vertices
|
|
|
|
if (v[0] !== prev && c0 !== c1) current = v[0];
|
|
else if (v[1] !== prev && c1 !== c2) current = v[1];
|
|
else if (v[2] !== prev && c0 !== c2) current = v[2];
|
|
|
|
if (current === prev) {
|
|
ERROR && console.error("Next vertex is not found");
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (chain.length) chain.push(chain[0]);
|
|
return chain;
|
|
}
|
|
|
|
invokeActiveZooming();
|
|
TIME && console.timeEnd("drawStates");
|
|
}
|
|
|
|
function toggleBorders(event) {
|
|
if (!layerIsOn("toggleBorders")) {
|
|
turnButtonOn("toggleBorders");
|
|
drawBorders();
|
|
if (event && isCtrlClick(event)) editStyle("borders");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("borders");
|
|
return;
|
|
}
|
|
turnButtonOff("toggleBorders");
|
|
borders.selectAll("path").remove();
|
|
}
|
|
}
|
|
|
|
// draw state and province borders
|
|
function drawBorders() {
|
|
TIME && console.time("drawBorders");
|
|
borders.selectAll("path").remove();
|
|
|
|
const {cells, vertices} = pack;
|
|
const n = cells.i.length;
|
|
|
|
const sPath = [];
|
|
const pPath = [];
|
|
|
|
const sUsed = new Array(pack.states.length).fill("").map(_ => []);
|
|
const pUsed = new Array(pack.provinces.length).fill("").map(_ => []);
|
|
|
|
for (let i = 0; i < cells.i.length; i++) {
|
|
if (!cells.state[i]) continue;
|
|
const p = cells.province[i];
|
|
const s = cells.state[i];
|
|
|
|
// if cell is on province border
|
|
const provToCell = cells.c[i].find(
|
|
n => cells.state[n] === s && p > cells.province[n] && pUsed[p][n] !== cells.province[n]
|
|
);
|
|
|
|
if (provToCell) {
|
|
const provTo = cells.province[provToCell];
|
|
pUsed[p][provToCell] = provTo;
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.province[i] === provTo));
|
|
const chain = connectVertices(vertex, p, cells.province, provTo, pUsed);
|
|
|
|
if (chain.length > 1) {
|
|
pPath.push("M" + chain.map(c => vertices.p[c]).join(" "));
|
|
i--;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// if cell is on state border
|
|
const stateToCell = cells.c[i].find(n => cells.h[n] >= 20 && s > cells.state[n] && sUsed[s][n] !== cells.state[n]);
|
|
if (stateToCell !== undefined) {
|
|
const stateTo = cells.state[stateToCell];
|
|
sUsed[s][stateToCell] = stateTo;
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.h[i] >= 20 && cells.state[i] === stateTo));
|
|
const chain = connectVertices(vertex, s, cells.state, stateTo, sUsed);
|
|
|
|
if (chain.length > 1) {
|
|
sPath.push("M" + chain.map(c => vertices.p[c]).join(" "));
|
|
i--;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
stateBorders.append("path").attr("d", sPath.join(" "));
|
|
provinceBorders.append("path").attr("d", pPath.join(" "));
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(current, f, array, t, used) {
|
|
let chain = [];
|
|
const checkCell = c => c >= n || array[c] !== f;
|
|
const checkVertex = v =>
|
|
vertices.c[v].some(c => array[c] === f) && vertices.c[v].some(c => array[c] === t && cells.h[c] >= 20);
|
|
|
|
// find starting vertex
|
|
for (let i = 0; i < 1000; i++) {
|
|
if (i === 999) ERROR && console.error("Find starting vertex: limit is reached", current, f, t);
|
|
const p = chain[chain.length - 2] || -1; // previous vertex
|
|
const v = vertices.v[current],
|
|
c = vertices.c[current];
|
|
|
|
const v0 = checkCell(c[0]) !== checkCell(c[1]) && checkVertex(v[0]);
|
|
const v1 = checkCell(c[1]) !== checkCell(c[2]) && checkVertex(v[1]);
|
|
const v2 = checkCell(c[0]) !== checkCell(c[2]) && checkVertex(v[2]);
|
|
if (v0 + v1 + v2 === 1) break;
|
|
current = v0 && p !== v[0] ? v[0] : v1 && p !== v[1] ? v[1] : v[2];
|
|
|
|
if (current === chain[0]) break;
|
|
if (current === p) return [];
|
|
chain.push(current);
|
|
}
|
|
|
|
chain = [current]; // vertices chain to form a path
|
|
// find path
|
|
for (let i = 0; i < 1000; i++) {
|
|
if (i === 999) ERROR && console.error("Find path: limit is reached", current, f, t);
|
|
const p = chain[chain.length - 2] || -1; // previous vertex
|
|
const v = vertices.v[current],
|
|
c = vertices.c[current];
|
|
c.filter(c => array[c] === t).forEach(c => (used[f][c] = t));
|
|
|
|
const v0 = checkCell(c[0]) !== checkCell(c[1]) && checkVertex(v[0]);
|
|
const v1 = checkCell(c[1]) !== checkCell(c[2]) && checkVertex(v[1]);
|
|
const v2 = checkCell(c[0]) !== checkCell(c[2]) && checkVertex(v[2]);
|
|
current = v0 && p !== v[0] ? v[0] : v1 && p !== v[1] ? v[1] : v[2];
|
|
|
|
if (current === p) break;
|
|
if (current === chain[chain.length - 1]) break;
|
|
if (chain.length > 1 && v0 + v1 + v2 < 2) break;
|
|
chain.push(current);
|
|
if (current === chain[0]) break;
|
|
}
|
|
|
|
return chain;
|
|
}
|
|
|
|
TIME && console.timeEnd("drawBorders");
|
|
}
|
|
|
|
function toggleProvinces(event) {
|
|
if (!layerIsOn("toggleProvinces")) {
|
|
turnButtonOn("toggleProvinces");
|
|
drawProvinces();
|
|
if (event && isCtrlClick(event)) editStyle("provs");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("provs");
|
|
return;
|
|
}
|
|
provs.selectAll("*").remove();
|
|
turnButtonOff("toggleProvinces");
|
|
}
|
|
}
|
|
|
|
function drawProvinces() {
|
|
TIME && console.time("drawProvinces");
|
|
const labelsOn = provs.attr("data-labels") == 1;
|
|
provs.selectAll("*").remove();
|
|
|
|
const provinces = pack.provinces;
|
|
const {body, gap} = getProvincesVertices();
|
|
|
|
const g = provs.append("g").attr("id", "provincesBody");
|
|
const bodyData = body.map((p, i) => [p.length > 10 ? p : null, i, provinces[i].color]).filter(d => d[0]);
|
|
g.selectAll("path")
|
|
.data(bodyData)
|
|
.enter()
|
|
.append("path")
|
|
.attr("d", d => d[0])
|
|
.attr("fill", d => d[2])
|
|
.attr("stroke", "none")
|
|
.attr("id", d => "province" + d[1]);
|
|
const gapData = gap.map((p, i) => [p.length > 10 ? p : null, i, provinces[i].color]).filter(d => d[0]);
|
|
g.selectAll(".path")
|
|
.data(gapData)
|
|
.enter()
|
|
.append("path")
|
|
.attr("d", d => d[0])
|
|
.attr("fill", "none")
|
|
.attr("stroke", d => d[2])
|
|
.attr("id", d => "province-gap" + d[1]);
|
|
|
|
const labels = provs.append("g").attr("id", "provinceLabels");
|
|
labels.style("display", `${labelsOn ? "block" : "none"}`);
|
|
const labelData = provinces.filter(p => p.i && !p.removed && p.pole);
|
|
labels
|
|
.selectAll(".path")
|
|
.data(labelData)
|
|
.enter()
|
|
.append("text")
|
|
.attr("x", d => d.pole[0])
|
|
.attr("y", d => d.pole[1])
|
|
.attr("id", d => "provinceLabel" + d.i)
|
|
.text(d => d.name);
|
|
|
|
TIME && console.timeEnd("drawProvinces");
|
|
}
|
|
|
|
function getProvincesVertices() {
|
|
const cells = pack.cells,
|
|
vertices = pack.vertices,
|
|
provinces = pack.provinces,
|
|
n = cells.i.length;
|
|
const used = new Uint8Array(cells.i.length);
|
|
const vArray = new Array(provinces.length); // store vertices array
|
|
const body = new Array(provinces.length).fill(""); // store path around each province
|
|
const gap = new Array(provinces.length).fill(""); // store path along water for each province to fill the gaps
|
|
|
|
for (const i of cells.i) {
|
|
if (!cells.province[i] || used[i]) continue;
|
|
const p = cells.province[i];
|
|
const onborder = cells.c[i].some(n => cells.province[n] !== p);
|
|
if (!onborder) continue;
|
|
|
|
const borderWith = cells.c[i].map(c => cells.province[c]).find(n => n !== p);
|
|
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.province[i] === borderWith));
|
|
const chain = connectVertices(vertex, p, borderWith);
|
|
if (chain.length < 3) continue;
|
|
const points = chain.map(v => vertices.p[v[0]]);
|
|
if (!vArray[p]) vArray[p] = [];
|
|
vArray[p].push(points);
|
|
body[p] += "M" + points.join("L");
|
|
gap[p] +=
|
|
"M" +
|
|
vertices.p[chain[0][0]] +
|
|
chain.reduce(
|
|
(r, v, i, d) =>
|
|
!i ? r : !v[2] ? r + "L" + vertices.p[v[0]] : d[i + 1] && !d[i + 1][2] ? r + "M" + vertices.p[v[0]] : r,
|
|
""
|
|
);
|
|
}
|
|
|
|
// find province visual center
|
|
vArray.forEach((ar, i) => {
|
|
const sorted = ar.sort((a, b) => b.length - a.length); // sort by points number
|
|
provinces[i].pole = polylabel(sorted, 1.0); // pole of inaccessibility
|
|
});
|
|
|
|
return {body, gap};
|
|
|
|
// connect vertices to chain
|
|
function connectVertices(start, t, province) {
|
|
const chain = []; // vertices chain to form a path
|
|
let land = vertices.c[start].some(c => cells.h[c] >= 20 && cells.province[c] !== t);
|
|
function check(i) {
|
|
province = cells.province[i];
|
|
land = cells.h[i] >= 20;
|
|
}
|
|
|
|
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); i++) {
|
|
const prev = chain[chain.length - 1] ? chain[chain.length - 1][0] : -1; // previous vertex in chain
|
|
chain.push([current, province, land]); // add current vertex to sequence
|
|
const c = vertices.c[current]; // cells adjacent to vertex
|
|
c.filter(c => cells.province[c] === t).forEach(c => (used[c] = 1));
|
|
const c0 = c[0] >= n || cells.province[c[0]] !== t;
|
|
const c1 = c[1] >= n || cells.province[c[1]] !== t;
|
|
const c2 = c[2] >= n || cells.province[c[2]] !== t;
|
|
const v = vertices.v[current]; // neighboring vertices
|
|
if (v[0] !== prev && c0 !== c1) {
|
|
current = v[0];
|
|
check(c0 ? c[0] : c[1]);
|
|
} else if (v[1] !== prev && c1 !== c2) {
|
|
current = v[1];
|
|
check(c1 ? c[1] : c[2]);
|
|
} else if (v[2] !== prev && c0 !== c2) {
|
|
current = v[2];
|
|
check(c2 ? c[2] : c[0]);
|
|
}
|
|
if (current === chain[chain.length - 1][0]) {
|
|
ERROR && console.error("Next vertex is not found");
|
|
break;
|
|
}
|
|
}
|
|
chain.push([start, province, land]); // add starting vertex to sequence to close the path
|
|
return chain;
|
|
}
|
|
}
|
|
|
|
function toggleGrid(event) {
|
|
if (!gridOverlay.selectAll("*").size()) {
|
|
turnButtonOn("toggleGrid");
|
|
drawGrid();
|
|
calculateFriendlyGridSize();
|
|
|
|
if (event && isCtrlClick(event)) editStyle("gridOverlay");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("gridOverlay");
|
|
return;
|
|
}
|
|
turnButtonOff("toggleGrid");
|
|
gridOverlay.selectAll("*").remove();
|
|
}
|
|
}
|
|
|
|
function drawGrid() {
|
|
gridOverlay.selectAll("*").remove();
|
|
const pattern = "#pattern_" + (gridOverlay.attr("type") || "pointyHex");
|
|
const stroke = gridOverlay.attr("stroke") || "#808080";
|
|
const width = gridOverlay.attr("stroke-width") || 0.5;
|
|
const dasharray = gridOverlay.attr("stroke-dasharray") || null;
|
|
const linecap = gridOverlay.attr("stroke-linecap") || null;
|
|
const scale = gridOverlay.attr("scale") || 1;
|
|
const dx = gridOverlay.attr("dx") || 0;
|
|
const dy = gridOverlay.attr("dy") || 0;
|
|
const tr = `scale(${scale}) translate(${dx} ${dy})`;
|
|
|
|
const maxWidth = Math.max(+mapWidthInput.value, graphWidth);
|
|
const maxHeight = Math.max(+mapHeightInput.value, graphHeight);
|
|
|
|
d3.select(pattern)
|
|
.attr("stroke", stroke)
|
|
.attr("stroke-width", width)
|
|
.attr("stroke-dasharray", dasharray)
|
|
.attr("stroke-linecap", linecap)
|
|
.attr("patternTransform", tr);
|
|
gridOverlay
|
|
.append("rect")
|
|
.attr("width", maxWidth)
|
|
.attr("height", maxHeight)
|
|
.attr("fill", "url(" + pattern + ")")
|
|
.attr("stroke", "none");
|
|
}
|
|
|
|
function toggleCoordinates(event) {
|
|
if (!coordinates.selectAll("*").size()) {
|
|
turnButtonOn("toggleCoordinates");
|
|
drawCoordinates();
|
|
if (event && isCtrlClick(event)) editStyle("coordinates");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("coordinates");
|
|
return;
|
|
}
|
|
turnButtonOff("toggleCoordinates");
|
|
coordinates.selectAll("*").remove();
|
|
}
|
|
}
|
|
|
|
function drawCoordinates() {
|
|
if (!layerIsOn("toggleCoordinates")) return;
|
|
coordinates.selectAll("*").remove(); // remove every time
|
|
const steps = [0.5, 1, 2, 5, 10, 15, 30]; // possible steps
|
|
const goal = mapCoordinates.lonT / scale / 10;
|
|
const step = steps.reduce((p, c) => (Math.abs(c - goal) < Math.abs(p - goal) ? c : p));
|
|
|
|
const desired = +coordinates.attr("data-size"); // desired label size
|
|
coordinates.attr("font-size", Math.max(rn(desired / scale ** 0.8, 2), 0.1)); // actual label size
|
|
const graticule = d3
|
|
.geoGraticule()
|
|
.extent([
|
|
[mapCoordinates.lonW, mapCoordinates.latN],
|
|
[mapCoordinates.lonE + 0.1, mapCoordinates.latS + 0.1]
|
|
])
|
|
.stepMajor([400, 400])
|
|
.stepMinor([step, step]);
|
|
const projection = d3.geoEquirectangular().fitSize([graphWidth, graphHeight], graticule());
|
|
|
|
const grid = coordinates.append("g").attr("id", "coordinateGrid");
|
|
const labels = coordinates.append("g").attr("id", "coordinateLabels");
|
|
|
|
const p = getViewPoint(scale + desired + 2, scale + desired / 2); // on border point on viexBox
|
|
const data = graticule.lines().map(d => {
|
|
const lat = d.coordinates[0][1] === d.coordinates[1][1]; // check if line is latitude or longitude
|
|
const c = d.coordinates[0],
|
|
pos = projection(c); // map coordinates
|
|
const [x, y] = lat ? [rn(p.x, 2), rn(pos[1], 2)] : [rn(pos[0], 2), rn(p.y, 2)]; // labels position
|
|
const v = lat ? c[1] : c[0]; // label
|
|
const text = !v
|
|
? v
|
|
: Number.isInteger(v)
|
|
? lat
|
|
? c[1] < 0
|
|
? -c[1] + "°S"
|
|
: c[1] + "°N"
|
|
: c[0] < 0
|
|
? -c[0] + "°W"
|
|
: c[0] + "°E"
|
|
: "";
|
|
return {lat, x, y, text};
|
|
});
|
|
|
|
const d = round(d3.geoPath(projection)(graticule()));
|
|
grid.append("path").attr("d", d).attr("vector-effect", "non-scaling-stroke");
|
|
labels
|
|
.selectAll("text")
|
|
.data(data)
|
|
.enter()
|
|
.append("text")
|
|
.attr("x", d => d.x)
|
|
.attr("y", d => d.y)
|
|
.text(d => d.text);
|
|
}
|
|
|
|
// conver svg point into viewBox point
|
|
function getViewPoint(x, y) {
|
|
const view = document.getElementById("viewbox");
|
|
const svg = document.getElementById("map");
|
|
const pt = svg.createSVGPoint();
|
|
(pt.x = x), (pt.y = y);
|
|
return pt.matrixTransform(view.getScreenCTM().inverse());
|
|
}
|
|
|
|
function toggleCompass(event) {
|
|
if (!layerIsOn("toggleCompass")) {
|
|
turnButtonOn("toggleCompass");
|
|
$("#compass").fadeIn();
|
|
if (!compass.selectAll("*").size()) {
|
|
compass.append("use").attr("xlink:href", "#rose");
|
|
shiftCompass();
|
|
}
|
|
if (event && isCtrlClick(event)) editStyle("compass");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("compass");
|
|
return;
|
|
}
|
|
$("#compass").fadeOut();
|
|
turnButtonOff("toggleCompass");
|
|
}
|
|
}
|
|
|
|
function toggleRelief(event) {
|
|
if (!layerIsOn("toggleRelief")) {
|
|
turnButtonOn("toggleRelief");
|
|
if (!terrain.selectAll("*").size()) ReliefIcons();
|
|
$("#terrain").fadeIn();
|
|
if (event && isCtrlClick(event)) editStyle("terrain");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("terrain");
|
|
return;
|
|
}
|
|
$("#terrain").fadeOut();
|
|
turnButtonOff("toggleRelief");
|
|
}
|
|
}
|
|
|
|
function toggleTexture(event) {
|
|
if (!layerIsOn("toggleTexture")) {
|
|
turnButtonOn("toggleTexture");
|
|
drawTexture();
|
|
if (event && isCtrlClick(event)) editStyle("texture");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("texture");
|
|
turnButtonOff("toggleTexture");
|
|
texture.select("image").remove();
|
|
}
|
|
}
|
|
|
|
function drawTexture() {
|
|
const x = Number(texture.attr("data-x") || 0);
|
|
const y = Number(texture.attr("data-y") || 0);
|
|
const href = texture.attr("data-href");
|
|
|
|
texture
|
|
.append("image")
|
|
.attr("preserveAspectRatio", "xMidYMid slice")
|
|
.attr("x", x)
|
|
.attr("y", y)
|
|
.attr("width", graphWidth - x)
|
|
.attr("height", graphHeight - y)
|
|
.attr("href", href);
|
|
}
|
|
|
|
function toggleRivers(event) {
|
|
if (!layerIsOn("toggleRivers")) {
|
|
turnButtonOn("toggleRivers");
|
|
drawRivers();
|
|
if (event && isCtrlClick(event)) editStyle("rivers");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("rivers");
|
|
rivers.selectAll("*").remove();
|
|
turnButtonOff("toggleRivers");
|
|
}
|
|
}
|
|
|
|
function drawRivers() {
|
|
TIME && console.time("drawRivers");
|
|
rivers.selectAll("*").remove();
|
|
|
|
const {addMeandering, getRiverPath} = Rivers;
|
|
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
|
|
|
|
const riverPaths = pack.rivers.map(({cells, points, i, widthFactor, sourceWidth}) => {
|
|
if (!cells || cells.length < 2) return;
|
|
|
|
if (points && points.length !== cells.length) {
|
|
console.error(
|
|
`River ${i} has ${cells.length} cells, but only ${points.length} points defined. Resetting points data`
|
|
);
|
|
points = undefined;
|
|
}
|
|
|
|
const meanderedPoints = addMeandering(cells, points);
|
|
const path = getRiverPath(meanderedPoints, widthFactor, sourceWidth);
|
|
return `<path id="river${i}" d="${path}"/>`;
|
|
});
|
|
rivers.html(riverPaths.join(""));
|
|
|
|
TIME && console.timeEnd("drawRivers");
|
|
}
|
|
|
|
function toggleRoutes(event) {
|
|
if (!layerIsOn("toggleRoutes")) {
|
|
turnButtonOn("toggleRoutes");
|
|
$("#routes").fadeIn();
|
|
if (event && isCtrlClick(event)) editStyle("routes");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("routes");
|
|
return;
|
|
}
|
|
$("#routes").fadeOut();
|
|
turnButtonOff("toggleRoutes");
|
|
}
|
|
}
|
|
|
|
function toggleMilitary() {
|
|
if (!layerIsOn("toggleMilitary")) {
|
|
turnButtonOn("toggleMilitary");
|
|
$("#armies").fadeIn();
|
|
if (event && isCtrlClick(event)) editStyle("armies");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("armies");
|
|
return;
|
|
}
|
|
$("#armies").fadeOut();
|
|
turnButtonOff("toggleMilitary");
|
|
}
|
|
}
|
|
|
|
function toggleMarkers(event) {
|
|
if (!layerIsOn("toggleMarkers")) {
|
|
turnButtonOn("toggleMarkers");
|
|
drawMarkers();
|
|
if (event && isCtrlClick(event)) editStyle("markers");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("markers");
|
|
markers.selectAll("*").remove();
|
|
turnButtonOff("toggleMarkers");
|
|
}
|
|
}
|
|
|
|
function 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(""));
|
|
}
|
|
|
|
const getPin = (shape = "bubble", fill = "#fff", stroke = "#000") => {
|
|
if (shape === "bubble")
|
|
return `<path d="M6,19 l9,10 L24,19" fill="${stroke}" stroke="none" /><circle cx="15" cy="15" r="10" fill="${fill}" stroke="${stroke}"/>`;
|
|
if (shape === "pin")
|
|
return `<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}"/>`;
|
|
if (shape === "square")
|
|
return `<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}"/>`;
|
|
if (shape === "squarish")
|
|
return `<path d="m 5,5 h 20 v 20 h -6 l -4,4 -4,-4 H 5 Z" fill="${fill}" stroke="${stroke}" />`;
|
|
if (shape === "diamond") return `<path d="M 2,15 15,1 28,15 15,29 Z" fill="${fill}" stroke="${stroke}" />`;
|
|
if (shape === "hex") return `<path d="M 15,29 4.61,21 V 9 L 15,3 25.4,9 v 12 z" fill="${fill}" stroke="${stroke}" />`;
|
|
if (shape === "hexy") return `<path d="M 15,29 6,21 5,8 15,4 25,8 24,21 Z" fill="${fill}" stroke="${stroke}" />`;
|
|
if (shape === "shieldy")
|
|
return `<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}" />`;
|
|
if (shape === "shield")
|
|
return `<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}" />`;
|
|
if (shape === "pentagon") return `<path d="M 4,16 9,4 h 12 l 5,12 -11,13 z" fill="${fill}" stroke="${stroke}" />`;
|
|
if (shape === "heptagon")
|
|
return `<path d="M 15,29 6,22 4,12 10,4 h 10 l 6,8 -2,10 z" fill="${fill}" stroke="${stroke}" />`;
|
|
if (shape === "circle") return `<circle cx="15" cy="15" r="11" fill="${fill}" stroke="${stroke}" />`;
|
|
if (shape === "no") return "";
|
|
};
|
|
|
|
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 pinHTML = getPin(pin, fill, stroke);
|
|
|
|
return `<svg id="${id}" viewbox="0 0 30 30" width="${zoomSize}" height="${zoomSize}" x="${viewX}" y="${viewY}"><g>${pinHTML}</g><text x="${dx}%" y="${dy}%" font-size="${px}px" >${icon}</text></svg>`;
|
|
}
|
|
|
|
function toggleLabels(event) {
|
|
if (!layerIsOn("toggleLabels")) {
|
|
turnButtonOn("toggleLabels");
|
|
labels.style("display", null);
|
|
invokeActiveZooming();
|
|
if (event && isCtrlClick(event)) editStyle("labels");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("labels");
|
|
turnButtonOff("toggleLabels");
|
|
labels.style("display", "none");
|
|
}
|
|
}
|
|
|
|
function toggleIcons(event) {
|
|
if (!layerIsOn("toggleIcons")) {
|
|
turnButtonOn("toggleIcons");
|
|
$("#icons").fadeIn();
|
|
if (event && isCtrlClick(event)) editStyle("burgIcons");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("burgIcons");
|
|
turnButtonOff("toggleIcons");
|
|
$("#icons").fadeOut();
|
|
}
|
|
}
|
|
|
|
function toggleRulers(event) {
|
|
if (!layerIsOn("toggleRulers")) {
|
|
turnButtonOn("toggleRulers");
|
|
if (event && isCtrlClick(event)) editStyle("ruler");
|
|
rulers.draw();
|
|
ruler.style("display", null);
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("ruler");
|
|
turnButtonOff("toggleRulers");
|
|
ruler.selectAll("*").remove();
|
|
ruler.style("display", "none");
|
|
}
|
|
}
|
|
|
|
function toggleScaleBar(event) {
|
|
if (!layerIsOn("toggleScaleBar")) {
|
|
turnButtonOn("toggleScaleBar");
|
|
$("#scaleBar").fadeIn();
|
|
if (event && isCtrlClick(event)) editStyle("scaleBar");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("scaleBar");
|
|
$("#scaleBar").fadeOut();
|
|
turnButtonOff("toggleScaleBar");
|
|
}
|
|
}
|
|
|
|
function drawScaleBar(scaleBar, scaleLevel) {
|
|
if (!scaleBar.size() || scaleBar.style("display") === "none") return;
|
|
|
|
const distanceScale = +distanceScaleInput.value;
|
|
const unit = distanceUnitInput.value;
|
|
const size = +scaleBar.attr("data-bar-size");
|
|
|
|
const length = (function () {
|
|
const init = 100;
|
|
let val = (init * size * distanceScale) / scaleLevel; // bar length in distance unit
|
|
if (val > 900) val = rn(val, -3); // round to 1000
|
|
else if (val > 90) val = rn(val, -2); // round to 100
|
|
else if (val > 9) val = rn(val, -1); // round to 10
|
|
else val = rn(val); // round to 1
|
|
const length = (val * scaleLevel) / distanceScale; // actual length in pixels on this scale
|
|
return length;
|
|
})();
|
|
|
|
scaleBar.select("#scaleBarContent").remove(); // redraw content every time
|
|
const content = scaleBar.append("g").attr("id", "scaleBarContent");
|
|
|
|
const lines = content.append("g");
|
|
lines
|
|
.append("line")
|
|
.attr("x1", 0.5)
|
|
.attr("y1", 0)
|
|
.attr("x2", length + size - 0.5)
|
|
.attr("y2", 0)
|
|
.attr("stroke-width", size)
|
|
.attr("stroke", "white");
|
|
lines
|
|
.append("line")
|
|
.attr("x1", 0)
|
|
.attr("y1", size)
|
|
.attr("x2", length + size)
|
|
.attr("y2", size)
|
|
.attr("stroke-width", size)
|
|
.attr("stroke", "#3d3d3d");
|
|
lines
|
|
.append("line")
|
|
.attr("x1", 0)
|
|
.attr("y1", 0)
|
|
.attr("x2", length + size)
|
|
.attr("y2", 0)
|
|
.attr("stroke-width", rn(size * 3, 2))
|
|
.attr("stroke-dasharray", size + " " + rn(length / 5 - size, 2))
|
|
.attr("stroke", "#3d3d3d");
|
|
|
|
const texts = content.append("g").attr("text-anchor", "middle").attr("font-family", "var(--serif)");
|
|
texts
|
|
.selectAll("text")
|
|
.data(d3.range(0, 6))
|
|
.enter()
|
|
.append("text")
|
|
.attr("x", d => rn((d * length) / 5, 2))
|
|
.attr("y", 0)
|
|
.attr("dy", "-.6em")
|
|
.text(d => rn((((d * length) / 5) * distanceScale) / scaleLevel) + (d < 5 ? "" : " " + unit));
|
|
|
|
const label = scaleBar.attr("data-label");
|
|
if (label) {
|
|
texts
|
|
.append("text")
|
|
.attr("x", (length + 1) / 2)
|
|
.attr("dy", ".6em")
|
|
.attr("dominant-baseline", "text-before-edge")
|
|
.text(label);
|
|
}
|
|
|
|
const scaleBarBack = scaleBar.select("#scaleBarBack");
|
|
if (scaleBarBack.size()) {
|
|
const bbox = content.node().getBBox();
|
|
const paddingTop = +scaleBarBack.attr("data-top") || 0;
|
|
const paddingLeft = +scaleBarBack.attr("data-left") || 0;
|
|
const paddingRight = +scaleBarBack.attr("data-right") || 0;
|
|
const paddingBottom = +scaleBarBack.attr("data-bottom") || 0;
|
|
|
|
scaleBar
|
|
.select("#scaleBarBack")
|
|
.attr("x", -paddingLeft)
|
|
.attr("y", -paddingTop)
|
|
.attr("width", bbox.width + paddingRight)
|
|
.attr("height", bbox.height + paddingBottom);
|
|
}
|
|
}
|
|
|
|
// fit ScaleBar to screen size
|
|
function fitScaleBar(scaleBar, fullWidth, fullHeight) {
|
|
if (!scaleBar.select("rect").size() || scaleBar.style("display") === "none") return;
|
|
|
|
const posX = +scaleBar.attr("data-x") || 99;
|
|
const posY = +scaleBar.attr("data-y") || 99;
|
|
const bbox = scaleBar.select("rect").node().getBBox();
|
|
|
|
const x = rn((fullWidth * posX) / 100 - bbox.width + 10);
|
|
const y = rn((fullHeight * posY) / 100 - bbox.height + 20);
|
|
scaleBar.attr("transform", `translate(${x},${y})`);
|
|
}
|
|
|
|
function toggleZones(event) {
|
|
if (!layerIsOn("toggleZones")) {
|
|
turnButtonOn("toggleZones");
|
|
$("#zones").fadeIn();
|
|
if (event && isCtrlClick(event)) editStyle("zones");
|
|
} else {
|
|
if (event && isCtrlClick(event)) {
|
|
editStyle("zones");
|
|
return;
|
|
}
|
|
turnButtonOff("toggleZones");
|
|
$("#zones").fadeOut();
|
|
}
|
|
}
|
|
|
|
function toggleEmblems(event) {
|
|
if (!layerIsOn("toggleEmblems")) {
|
|
turnButtonOn("toggleEmblems");
|
|
if (!emblems.selectAll("use").size()) drawEmblems();
|
|
$("#emblems").fadeIn();
|
|
if (event && isCtrlClick(event)) editStyle("emblems");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("emblems");
|
|
$("#emblems").fadeOut();
|
|
turnButtonOff("toggleEmblems");
|
|
}
|
|
}
|
|
|
|
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 = +document.getElementById("emblemsStateSizeInput").value || 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 = +document.getElementById("emblemsProvinceSizeInput").value || 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 = +document.getElementById("emblemsBurgSizeInput").value || 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 => {
|
|
if (!province.pole) getProvincesVertices();
|
|
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");
|
|
}
|
|
|
|
function toggleVignette(event) {
|
|
if (!layerIsOn("toggleVignette")) {
|
|
turnButtonOn("toggleVignette");
|
|
$("#vignette").fadeIn();
|
|
if (event && isCtrlClick(event)) editStyle("vignette");
|
|
} else {
|
|
if (event && isCtrlClick(event)) return editStyle("vignette");
|
|
$("#vignette").fadeOut();
|
|
turnButtonOff("toggleVignette");
|
|
}
|
|
}
|
|
|
|
function layerIsOn(el) {
|
|
const buttonoff = document.getElementById(el).classList.contains("buttonoff");
|
|
return !buttonoff;
|
|
}
|
|
|
|
function turnButtonOff(el) {
|
|
document.getElementById(el).classList.add("buttonoff");
|
|
getCurrentPreset();
|
|
}
|
|
|
|
function turnButtonOn(el) {
|
|
document.getElementById(el).classList.remove("buttonoff");
|
|
getCurrentPreset();
|
|
}
|
|
|
|
// move layers on mapLayers dragging (jquery sortable)
|
|
$("#mapLayers").sortable({items: "li:not(.solid)", containment: "parent", cancel: ".solid", update: moveLayer});
|
|
function moveLayer(event, ui) {
|
|
const el = getLayer(ui.item.attr("id"));
|
|
if (!el) return;
|
|
const prev = getLayer(ui.item.prev().attr("id"));
|
|
const next = getLayer(ui.item.next().attr("id"));
|
|
if (prev) el.insertAfter(prev);
|
|
else if (next) el.insertBefore(next);
|
|
}
|
|
|
|
// define connection between option layer buttons and actual svg groups to move the element
|
|
function getLayer(id) {
|
|
if (id === "toggleHeight") return $("#terrs");
|
|
if (id === "toggleBiomes") return $("#biomes");
|
|
if (id === "toggleCells") return $("#cells");
|
|
if (id === "toggleGrid") return $("#gridOverlay");
|
|
if (id === "toggleCoordinates") return $("#coordinates");
|
|
if (id === "toggleCompass") return $("#compass");
|
|
if (id === "toggleRivers") return $("#rivers");
|
|
if (id === "toggleRelief") return $("#terrain");
|
|
if (id === "toggleReligions") return $("#relig");
|
|
if (id === "toggleCultures") return $("#cults");
|
|
if (id === "toggleStates") return $("#regions");
|
|
if (id === "toggleProvinces") return $("#provs");
|
|
if (id === "toggleBorders") return $("#borders");
|
|
if (id === "toggleRoutes") return $("#routes");
|
|
if (id === "toggleTemp") return $("#temperature");
|
|
if (id === "togglePrec") return $("#prec");
|
|
if (id === "togglePopulation") return $("#population");
|
|
if (id === "toggleIce") return $("#ice");
|
|
if (id === "toggleTexture") return $("#texture");
|
|
if (id === "toggleEmblems") return $("#emblems");
|
|
if (id === "toggleLabels") return $("#labels");
|
|
if (id === "toggleIcons") return $("#icons");
|
|
if (id === "toggleMarkers") return $("#markers");
|
|
if (id === "toggleRulers") return $("#ruler");
|
|
}
|