From c8b0f5cd2ebaa2e24baddcc8b424ddf990c2dc5e Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 30 Jan 2026 18:43:19 +0100 Subject: [PATCH] refactor: migrate renderers to ts --- public/modules/renderers/draw-borders.js | 120 ----- public/modules/renderers/draw-burg-icons.js | 108 ----- public/modules/renderers/draw-burg-labels.js | 84 ---- public/modules/renderers/draw-emblems.js | 129 ----- public/modules/renderers/draw-features.js | 66 --- public/modules/renderers/draw-ice.js | 70 --- public/modules/renderers/draw-markers.js | 53 --- public/modules/renderers/draw-military.js | 155 ------- public/modules/renderers/draw-relief-icons.js | 124 ----- public/modules/renderers/draw-state-labels.js | 312 ------------- public/modules/renderers/draw-temperature.js | 104 ----- src/index.html | 15 +- src/modules/burgs-generator.ts | 4 +- src/modules/states-generator.ts | 1 + src/renderers/draw-borders.ts | 181 ++++++++ src/renderers/draw-burg-icons.ts | 145 ++++++ src/renderers/draw-burg-labels.ts | 107 +++++ src/renderers/draw-emblems.ts | 200 ++++++++ src/renderers/draw-features.ts | 106 +++++ .../renderers/draw-heightmap.ts | 102 ++-- src/renderers/draw-ice.ts | 102 ++++ src/renderers/draw-markers.ts | 103 ++++ src/renderers/draw-military.ts | 216 +++++++++ src/renderers/draw-relief-icons.ts | 164 +++++++ .../renderers/draw-scalebar.ts | 80 +++- src/renderers/draw-state-labels.ts | 439 ++++++++++++++++++ src/renderers/draw-temperature.ts | 155 +++++++ src/renderers/index.ts | 13 + src/types/PackedGraph.ts | 4 + src/types/global.ts | 29 +- src/utils/graphUtils.ts | 2 +- 31 files changed, 2097 insertions(+), 1396 deletions(-) delete mode 100644 public/modules/renderers/draw-borders.js delete mode 100644 public/modules/renderers/draw-burg-icons.js delete mode 100644 public/modules/renderers/draw-burg-labels.js delete mode 100644 public/modules/renderers/draw-emblems.js delete mode 100644 public/modules/renderers/draw-features.js delete mode 100644 public/modules/renderers/draw-ice.js delete mode 100644 public/modules/renderers/draw-markers.js delete mode 100644 public/modules/renderers/draw-military.js delete mode 100644 public/modules/renderers/draw-relief-icons.js delete mode 100644 public/modules/renderers/draw-state-labels.js delete mode 100644 public/modules/renderers/draw-temperature.js create mode 100644 src/renderers/draw-borders.ts create mode 100644 src/renderers/draw-burg-icons.ts create mode 100644 src/renderers/draw-burg-labels.ts create mode 100644 src/renderers/draw-emblems.ts create mode 100644 src/renderers/draw-features.ts rename public/modules/renderers/draw-heightmap.js => src/renderers/draw-heightmap.ts (54%) create mode 100644 src/renderers/draw-ice.ts create mode 100644 src/renderers/draw-markers.ts create mode 100644 src/renderers/draw-military.ts create mode 100644 src/renderers/draw-relief-icons.ts rename public/modules/renderers/draw-scalebar.js => src/renderers/draw-scalebar.ts (59%) create mode 100644 src/renderers/draw-state-labels.ts create mode 100644 src/renderers/draw-temperature.ts create mode 100644 src/renderers/index.ts diff --git a/public/modules/renderers/draw-borders.js b/public/modules/renderers/draw-borders.js deleted file mode 100644 index f0f3006e..00000000 --- a/public/modules/renderers/draw-borders.js +++ /dev/null @@ -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"); -} diff --git a/public/modules/renderers/draw-burg-icons.js b/public/modules/renderers/draw-burg-icons.js deleted file mode 100644 index 66d2dfcb..00000000 --- a/public/modules/renderers/draw-burg-icons.js +++ /dev/null @@ -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 => ``) - .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 => ``) - .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); - } -} diff --git a/public/modules/renderers/draw-burg-labels.js b/public/modules/renderers/draw-burg-labels.js deleted file mode 100644 index c8a43bbb..00000000 --- a/public/modules/renderers/draw-burg-labels.js +++ /dev/null @@ -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); - } -} diff --git a/public/modules/renderers/draw-emblems.js b/public/modules/renderers/draw-emblems.js deleted file mode 100644 index 13781239..00000000 --- a/public/modules/renderers/draw-emblems.js +++ /dev/null @@ -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 => - `` - ) - .join(""); - emblems.select("#burgEmblems").attr("font-size", sizeBurgs).html(burgString); - - const provinceNodes = nodes.filter(node => node.type === "province"); - const provinceString = provinceNodes - .map( - d => - `` - ) - .join(""); - emblems.select("#provinceEmblems").attr("font-size", sizeProvinces).html(provinceString); - - const stateNodes = nodes.filter(node => node.type === "state"); - const stateString = stateNodes - .map( - d => - `` - ) - .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); - } -} diff --git a/public/modules/renderers/draw-features.js b/public/modules/renderers/draw-features.js deleted file mode 100644 index 0112a0ae..00000000 --- a/public/modules/renderers/draw-features.js +++ /dev/null @@ -1,66 +0,0 @@ -"use strict"; - -function drawFeatures() { - TIME && console.time("drawFeatures"); - - const html = { - paths: [], - landMask: [], - waterMask: [''], - coastline: {}, - lakes: {} - }; - - for (const feature of pack.features) { - if (!feature || feature.type === "ocean") continue; - - html.paths.push(``); - - if (feature.type === "lake") { - html.landMask.push(``); - - const lakeGroup = feature.group || "freshwater"; - if (!html.lakes[lakeGroup]) html.lakes[lakeGroup] = []; - html.lakes[lakeGroup].push(``); - } else { - html.landMask.push(``); - html.waterMask.push(``); - - const coastlineGroup = feature.group === "lake_island" ? "lake_island" : "sea_island"; - if (!html.coastline[coastlineGroup]) html.coastline[coastlineGroup] = []; - html.coastline[coastlineGroup].push(``); - } - } - - 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; -} diff --git a/public/modules/renderers/draw-ice.js b/public/modules/renderers/draw-ice.js deleted file mode 100644 index 4b35f75c..00000000 --- a/public/modules/renderers/draw-ice.js +++ /dev/null @@ -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 ``; -} - -function getIcebergHtml(iceberg) { - return ``; -} \ No newline at end of file diff --git a/public/modules/renderers/draw-markers.js b/public/modules/renderers/draw-markers.js deleted file mode 100644 index f7466a55..00000000 --- a/public/modules/renderers/draw-markers.js +++ /dev/null @@ -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) => ``, - pin: (fill, stroke) => ``, - square: (fill, stroke) => ``, - squarish: (fill, stroke) => ``, - diamond: (fill, stroke) => ``, - hex: (fill, stroke) => ``, - hexy: (fill, stroke) => ``, - shieldy: (fill, stroke) => ``, - shield: (fill, stroke) => ``, - pentagon: (fill, stroke) => ``, - heptagon: (fill, stroke) => ``, - circle: (fill, 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 */ ` - - ${getPin(pin, fill, stroke)} - ${isExternal ? "" : icon} - - `; -} diff --git a/public/modules/renderers/draw-military.js b/public/modules/renderers/draw-military.js deleted file mode 100644 index a332130f..00000000 --- a/public/modules/renderers/draw-military.js +++ /dev/null @@ -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"); -}; diff --git a/public/modules/renderers/draw-relief-icons.js b/public/modules/renderers/draw-relief-icons.js deleted file mode 100644 index ffa0b69c..00000000 --- a/public/modules/renderers/draw-relief-icons.js +++ /dev/null @@ -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(``); - } - 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 - } -} diff --git a/public/modules/renderers/draw-state-labels.js b/public/modules/renderers/draw-state-labels.js deleted file mode 100644 index 9586a9c1..00000000 --- a/public/modules/renderers/draw-state-labels.js +++ /dev/null @@ -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) => `${line}`); - textElement.insertAdjacentHTML("afterbegin", spans.join("")); - - const {width, height} = textElement.getBBox(); - textElement.setAttribute("href", "#textPath_stateLabel" + stateId); - - if (mode === "full" || lines.length === 1) continue; - - // check if label fits state boundaries. If no, replace it with short name - const [[x1, y1], [x2, y2]] = [pathPoints.at(0), pathPoints.at(-1)]; - const angleRad = Math.atan2(y2 - y1, x2 - x1); - - const isInsideState = checkIfInsideState(textElement, angleRad, width / 2, height / 2, stateIds, stateId); - if (isInsideState) continue; - - // replace name to one-liner - const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name; - textElement.innerHTML = `${text}`; - - const correctedRatio = minmax(rn((pathLength / text.length) * 50), 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"); -} diff --git a/public/modules/renderers/draw-temperature.js b/public/modules/renderers/draw-temperature.js deleted file mode 100644 index 51dc32f5..00000000 --- a/public/modules/renderers/draw-temperature.js +++ /dev/null @@ -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"); -} diff --git a/src/index.html b/src/index.html index 14b61949..e1267b96 100644 --- a/src/index.html +++ b/src/index.html @@ -8490,6 +8490,7 @@ + @@ -8562,19 +8563,5 @@ - - - - - - - - - - - - - - diff --git a/src/modules/burgs-generator.ts b/src/modules/burgs-generator.ts index ca18a539..0b8033a3 100644 --- a/src/modules/burgs-generator.ts +++ b/src/modules/burgs-generator.ts @@ -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(); diff --git a/src/modules/states-generator.ts b/src/modules/states-generator.ts index cb13dd76..dd693da3 100644 --- a/src/modules/states-generator.ts +++ b/src/modules/states-generator.ts @@ -50,6 +50,7 @@ export interface State { formName?: string; fullName?: string; form?: string; + military?: any[]; } class StatesModule { diff --git a/src/renderers/draw-borders.ts b/src/renderers/draw-borders.ts new file mode 100644 index 00000000..0c78dd69 --- /dev/null +++ b/src/renderers/draw-borders.ts @@ -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; diff --git a/src/renderers/draw-burg-icons.ts b/src/renderers/draw-burg-icons.ts new file mode 100644 index 00000000..9e957fe3 --- /dev/null +++ b/src/renderers/draw-burg-icons.ts @@ -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( + `#burgIcons > g#${name}`, + ); + if (!iconsGroup) continue; + + const icon = iconsGroup.dataset.icon || "#icon-circle"; + iconsGroup.innerHTML = burgsInGroup + .map( + (b) => + ``, + ) + .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) => + ``, + ) + .join(""); + } + + TIME && console.timeEnd("drawBurgIcons"); +}; + +const drawBurgIconRenderer = (burg: Burg): void => { + const iconGroup = burgIcons.select(`#${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; diff --git a/src/renderers/draw-burg-labels.ts b/src/renderers/draw-burg-labels.ts new file mode 100644 index 00000000..5dc6cc71 --- /dev/null +++ b/src/renderers/draw-burg-labels.ts @@ -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(`#${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(`#${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; diff --git a/src/renderers/draw-emblems.ts b/src/renderers/draw-emblems.ts new file mode 100644 index 00000000..568fbd48 --- /dev/null +++ b/src/renderers/draw-emblems.ts @@ -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; +} + +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().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) => + ``, + ) + .join(""); + emblems + .select("#burgEmblems") + .attr("font-size", sizeBurgs) + .html(burgString); + + const provinceNodes = nodes.filter((node) => node.type === "province"); + const provinceString = provinceNodes + .map( + (d) => + ``, + ) + .join(""); + emblems + .select("#provinceEmblems") + .attr("font-size", sizeProvinces) + .html(provinceString); + + const stateNodes = nodes.filter((node) => node.type === "state"); + const stateString = stateNodes + .map( + (d) => + ``, + ) + .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 => { + 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; diff --git a/src/renderers/draw-features.ts b/src/renderers/draw-features.ts new file mode 100644 index 00000000..0f823691 --- /dev/null +++ b/src/renderers/draw-features.ts @@ -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; + var coastline: d3.Selection; + var lakes: d3.Selection; + 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: [''], + coastline: {}, + lakes: {}, + }; + + for (const feature of pack.features) { + if (!feature || feature.type === "ocean") continue; + + html.paths.push( + ``, + ); + + if (feature.type === "lake") { + html.landMask.push( + ``, + ); + + const lakeGroup = feature.group || "freshwater"; + if (!html.lakes[lakeGroup]) html.lakes[lakeGroup] = []; + html.lakes[lakeGroup].push( + ``, + ); + } else { + html.landMask.push( + ``, + ); + html.waterMask.push( + ``, + ); + + const coastlineGroup = + feature.group === "lake_island" ? "lake_island" : "sea_island"; + if (!html.coastline[coastlineGroup]) html.coastline[coastlineGroup] = []; + html.coastline[coastlineGroup].push( + ``, + ); + } + } + + 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] || []; + select(this).html(paths.join("")); + }); + + lakes.selectAll("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; diff --git a/public/modules/renderers/draw-heightmap.js b/src/renderers/draw-heightmap.ts similarity index 54% rename from public/modules/renderers/draw-heightmap.js rename to src/renderers/draw-heightmap.ts index cefed230..7ccabd47 100644 --- a/public/modules/renderers/draw-heightmap.js +++ b/src/renderers/draw-heightmap.ts @@ -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("#oceanHeights"); + const land = terrs.select("#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; diff --git a/src/renderers/draw-ice.ts b/src/renderers/draw-ice.ts new file mode 100644 index 00000000..ce238d08 --- /dev/null +++ b/src/renderers/draw-ice.ts @@ -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( + `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( + `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( + `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( + `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 ``; +} + +function getIcebergHtml(iceberg: IceElement): string { + return ``; +} + +window.drawIce = iceRenderer; +window.redrawIceberg = redrawIcebergRenderer; +window.redrawGlacier = redrawGlacierRenderer; diff --git a/src/renderers/draw-markers.ts b/src/renderers/draw-markers.ts new file mode 100644 index 00000000..27ce3136 --- /dev/null +++ b/src/renderers/draw-markers.ts @@ -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) => + ``, + pin: (fill: string, stroke: string) => + ``, + square: (fill: string, stroke: string) => + ``, + squarish: (fill: string, stroke: string) => + ``, + diamond: (fill: string, stroke: string) => + ``, + hex: (fill: string, stroke: string) => + ``, + hexy: (fill: string, stroke: string) => + ``, + shieldy: (fill: string, stroke: string) => + ``, + shield: (fill: string, stroke: string) => + ``, + pentagon: (fill: string, stroke: string) => + ``, + heptagon: (fill: string, stroke: string) => + ``, + circle: (fill: string, stroke: string) => + ``, + 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 */ ` + + ${getPin(pin, fill, stroke)} + ${isExternal ? "" : icon} + + `; +} + +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; diff --git a/src/renderers/draw-military.ts b/src/renderers/draw-military.ts new file mode 100644 index 00000000..dc5f1da2 --- /dev/null +++ b/src/renderers/draw-military.ts @@ -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; + 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(`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; diff --git a/src/renderers/draw-relief-icons.ts b/src/renderers/draw-relief-icons.ts new file mode 100644 index 00000000..c4960b25 --- /dev/null +++ b/src/renderers/draw-relief-icons.ts @@ -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; + 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( + ``, + ); + } + 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; diff --git a/public/modules/renderers/draw-scalebar.js b/src/renderers/draw-scalebar.ts similarity index 59% rename from public/modules/renderers/draw-scalebar.js rename to src/renderers/draw-scalebar.ts index b318f0be..12c46d55 100644 --- a/public/modules/renderers/draw-scalebar.js +++ b/src/renderers/draw-scalebar.ts @@ -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, + scaleLevel: number, + ) => void; + var fitScaleBar: ( + scaleBar: Selection, + 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("#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; diff --git a/src/renderers/draw-state-labels.ts b/src/renderers/draw-state-labels.ts new file mode 100644 index 00000000..24528d45 --- /dev/null +++ b/src/renderers/draw-state-labels.ts @@ -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("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("g#labels > g#states"); + const pathGroup = 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() 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) => + `${lineText}`, + ); + textElement.insertAdjacentHTML("afterbegin", spans.join("")); + + const { width, height } = textElement.getBBox(); + textElement.setAttribute("href", `#textPath_stateLabel${stateId}`); + + if (mode === "full" || lines.length === 1) continue; + + // check if label fits state boundaries. If no, replace it with short name + const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!]; + const angleRad = Math.atan2(y2 - y1, x2 - x1); + + const isInsideState = checkIfInsideState( + textElement, + angleRad, + width / 2, + height / 2, + stateIds, + stateId, + ); + if (isInsideState) continue; + + // replace name to one-liner + const text = + pathLength > state.fullName!.length * 1.8 + ? state.fullName! + : state.name!; + textElement.innerHTML = `${text}`; + + const correctedRatio = minmax( + rn((pathLength / text.length) * 50), + 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; diff --git a/src/renderers/draw-temperature.ts b/src/renderers/draw-temperature.ts new file mode 100644 index 00000000..538a7da2 --- /dev/null +++ b/src/renderers/draw-temperature.ts @@ -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; diff --git a/src/renderers/index.ts b/src/renderers/index.ts new file mode 100644 index 00000000..5ea6e502 --- /dev/null +++ b/src/renderers/index.ts @@ -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"; diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 26bfab5d..5bd2cd7a 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -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[]; } diff --git a/src/types/global.ts b/src/types/global.ts index fc4cfba9..d7ffff49 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -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; var oceanLayers: Selection; var emblems: Selection; + var svg: Selection; + var ice: Selection; + var labels: Selection; + var burgLabels: Selection; + var burgIcons: Selection; + var anchors: Selection; + var terrs: Selection; + var temperature: Selection; + var markers: Selection; + 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; } diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index 83ef0ae5..9b241780 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -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);