From b223dc62da49e82f4d176e4aa8d3e3917cda8e7b Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Thu, 22 Jan 2026 17:24:02 +0100 Subject: [PATCH 01/24] fix: implement quadtree search for points within a radius (#1274) --- src/utils/graphUtils.ts | 180 ++++++++++++++++++++++------------------ 1 file changed, 97 insertions(+), 83 deletions(-) diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index 875445fb..274d69f9 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -206,13 +206,109 @@ export const findClosestCell = (x: number, y: number, radius = Infinity, packedG return found ? found[2] : undefined; } +/** + * Searches a quadtree for all points within a given radius + * Based on https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e + * @param {number} x - The x coordinate of the search center + * @param {number} y - The y coordinate of the search center + * @param {number} radius - The search radius + * @param {Object} quadtree - The D3 quadtree to search + * @returns {Array} - An array of found data points within the radius + */ +export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => { + const radiusSearchInit = (t: any, radius: number) => { + t.result = []; + (t.x0 = t.x - radius), (t.y0 = t.y - radius); + (t.x3 = t.x + radius), (t.y3 = t.y + radius); + t.radius = radius * radius; + }; + + const radiusSearchVisit = (t: any, d2: number) => { + t.node.data.scanned = true; + if (d2 < t.radius) { + do { + t.result.push(t.node.data); + t.node.data.selected = true; + } while ((t.node = t.node.next)); + } + }; + + class Quad { + node: any; + x0: number; + y0: number; + x1: number; + y1: number; + constructor(node: any, x0: number, y0: number, x1: number, y1: number) { + this.node = node; + this.x0 = x0; + this.y0 = y0; + this.x1 = x1; + this.y1 = y1; + } + } + + const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root}; + if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3)); + radiusSearchInit(t, radius); + + var i = 0; + while ((t.q = t.quads.pop())) { + i++; + + // Stop searching if this quadrant can't contain a closer node. + if ( + !(t.node = t.q.node) || + (t.x1 = t.q.x0) > t.x3 || + (t.y1 = t.q.y0) > t.y3 || + (t.x2 = t.q.x1) < t.x0 || + (t.y2 = t.q.y1) < t.y0 + ) + continue; + + // Bisect the current quadrant. + if (t.node.length) { + t.node.explored = true; + var xm: number = (t.x1 + t.x2) / 2, + ym: number = (t.y1 + t.y2) / 2; + + t.quads.push( + new Quad(t.node[3], xm, ym, t.x2, t.y2), + new Quad(t.node[2], t.x1, ym, xm, t.y2), + new Quad(t.node[1], xm, t.y1, t.x2, ym), + new Quad(t.node[0], t.x1, t.y1, xm, ym) + ); + + // Visit the closest quadrant first. + if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) { + t.q = t.quads[t.quads.length - 1]; + t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; + t.quads[t.quads.length - 1 - t.i] = t.q; + } + } + + // Visit this point. (Visiting coincident points isn't necessary!) + else { + var dx = x - +quadtree._x.call(null, t.node.data), + dy = y - +quadtree._y.call(null, t.node.data), + d2 = dx * dx + dy * dy; + radiusSearchVisit(t, d2); + } + } + return t.result; +} + /** * Returns an array of packed cell indexes within a specified radius from given x and y coordinates * @param {number} x - The x coordinate * @param {number} y - The y coordinate + * @param {number} radius - The search radius + * @param {Object} packedGraph - The packed graph containing cells with quadtree + * @returns {number[]} - An array of cell indexes within the radius */ export const findAllCellsInRadius = (x: number, y: number, radius: number, packedGraph: any): number[] => { - const found = packedGraph.cells.q.findAll(x, y, radius); + // Use findAllInQuadtree directly instead of relying on prototype extension + const found = findAllInQuadtree(x, y, radius, packedGraph.cells.q); return found.map((r: any) => r[2]); } @@ -325,88 +421,6 @@ export const isWater = (i: number, packedGraph: any) => { return packedGraph.cells.h[i] < 20; } -export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => { - const radiusSearchInit = (t: any, radius: number) => { - t.result = []; - (t.x0 = t.x - radius), (t.y0 = t.y - radius); - (t.x3 = t.x + radius), (t.y3 = t.y + radius); - t.radius = radius * radius; - }; - - const radiusSearchVisit = (t: any, d2: number) => { - t.node.data.scanned = true; - if (d2 < t.radius) { - do { - t.result.push(t.node.data); - t.node.data.selected = true; - } while ((t.node = t.node.next)); - } - }; - - class Quad { - node: any; - x0: number; - y0: number; - x1: number; - y1: number; - constructor(node: any, x0: number, y0: number, x1: number, y1: number) { - this.node = node; - this.x0 = x0; - this.y0 = y0; - this.x1 = x1; - this.y1 = y1; - } - } - - const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root}; - if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3)); - radiusSearchInit(t, radius); - - var i = 0; - while ((t.q = t.quads.pop())) { - i++; - - // Stop searching if this quadrant can’t contain a closer node. - if ( - !(t.node = t.q.node) || - (t.x1 = t.q.x0) > t.x3 || - (t.y1 = t.q.y0) > t.y3 || - (t.x2 = t.q.x1) < t.x0 || - (t.y2 = t.q.y1) < t.y0 - ) - continue; - - // Bisect the current quadrant. - if (t.node.length) { - t.node.explored = true; - var xm: number = (t.x1 + t.x2) / 2, - ym: number = (t.y1 + t.y2) / 2; - - t.quads.push( - new Quad(t.node[3], xm, ym, t.x2, t.y2), - new Quad(t.node[2], t.x1, ym, xm, t.y2), - new Quad(t.node[1], xm, t.y1, t.x2, ym), - new Quad(t.node[0], t.x1, t.y1, xm, ym) - ); - - // Visit the closest quadrant first. - if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) { - t.q = t.quads[t.quads.length - 1]; - t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; - t.quads[t.quads.length - 1 - t.i] = t.q; - } - } - - // Visit this point. (Visiting coincident points isn’t necessary!) - else { - var dx = x - +quadtree._x.call(null, t.node.data), - dy = y - +quadtree._y.call(null, t.node.data), - d2 = dx * dx + dy * dy; - radiusSearchVisit(t, d2); - } - } - return t.result; -} // draw raster heightmap preview (not used in main generation) /** From e597d905eb6a96e11156d9016c14dd98d8c421e5 Mon Sep 17 00:00:00 2001 From: kruschen Date: Thu, 22 Jan 2026 17:33:30 +0100 Subject: [PATCH 02/24] Ice Layer Data Model (#1262) * prototype for ice seperation * feat: migrate ice data to new data model and update version to 1.110.0 * refactor: update ice data handling and rendering for improved performance * feat: integrate ice generation and recalculation in heightmap editing * fix ice selection(hopefully) * fix ice selection better(pls) * refactor: remove redundant element selection in ice editing functions * fix: clear ice data before generating glaciers and icebergs * sparse array implementation with reduced updates * fix logic chech in modules/dynamic/auto-update.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: migrate ice data to new data model structure * refactor: streamline ice generation process and clean up rendering functions * refactor: simplify ice rendering logic by removing redundant clearing of old SVG * fix: update editIce function to accept element parameter and improve logic for glacier handling * ice drawing with only type on less occuring glaciers * feat: add compactPackData function to filter out undefined glaciers and icebergs * fix: clear existing ice elements before redrawing in editHeightmap function * fix compact problems on autosave * refactor: unify ice data structure and streamline ice element handling * refactor: improve getNextId function to fill gaps in ice element IDs(optional commit) * just to be sure * bump version in html * fix index.html script import --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/main.js | 2 + public/modules/dynamic/auto-update.js | 102 +++++++++++++--- public/modules/ice.js | 170 ++++++++++++++++++++++++++ public/modules/io/load.js | 3 +- public/modules/io/save.js | 15 ++- public/modules/renderers/draw-ice.js | 70 +++++++++++ public/modules/ui/editors.js | 2 +- public/modules/ui/heightmap-editor.js | 6 + public/modules/ui/ice-editor.js | 80 ++++++------ public/modules/ui/layers.js | 43 ------- public/modules/ui/tools.js | 2 +- public/versioning.js | 2 +- src/index.html | 23 ++-- 13 files changed, 402 insertions(+), 118 deletions(-) create mode 100644 public/modules/ice.js create mode 100644 public/modules/renderers/draw-ice.js diff --git a/public/main.js b/public/main.js index 6da462d5..7dbb9585 100644 --- a/public/main.js +++ b/public/main.js @@ -632,6 +632,8 @@ async function generate(options) { Biomes.define(); Features.defineGroups(); + Ice.generate(); + rankCells(); Cultures.generate(); Cultures.expand(); diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index a3190e3b..6e252330 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -253,8 +253,8 @@ export function resolveVersionConflicts(mapVersion) { const source = findCell(s.x, s.y); const mouth = findCell(e.x, e.y); const name = Rivers.getName(mouth); - const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River"; - pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type}); + const type = length < 25 ? rw({ Creek: 9, River: 3, Brook: 3, Stream: 1 }) : "River"; + pack.rivers.push({ i, parent: 0, length, source, mouth, basin: i, name, type }); }); } @@ -270,7 +270,7 @@ export function resolveVersionConflicts(mapVersion) { const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era"; const eraShort = era[0] + "E"; const military = Military.getDefaultOptions(); - options = {winds, year, era, eraShort, military}; + options = { winds, year, era, eraShort, military }; // v1.3 added campaings data for all states States.generateCampaigns(); @@ -481,7 +481,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.65.0")) { // v1.65 changed rivers data d3.select("#rivers").attr("style", null); // remove style to unhide layer - const {cells, rivers} = pack; + const { cells, rivers } = pack; const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); for (const river of rivers) { @@ -497,8 +497,8 @@ export function resolveVersionConflicts(mapVersion) { for (let i = 0; i <= segments; i++) { const shift = increment * i; - const {x: x1, y: y1} = node.getPointAtLength(length + shift); - const {x: x2, y: y2} = node.getPointAtLength(length - shift); + const { x: x1, y: y1 } = node.getPointAtLength(length + shift); + const { x: x2, y: y2 } = node.getPointAtLength(length - shift); const x = rn((x1 + x2) / 2, 1); const y = rn((y1 + y2) / 2, 1); @@ -565,7 +565,7 @@ export function resolveVersionConflicts(mapVersion) { const fill = circle && circle.getAttribute("fill"); const stroke = circle && circle.getAttribute("stroke"); - const marker = {i, icon, type, x, y, size, cell}; + const marker = { i, icon, type, x, y, size, cell }; if (size && size !== 30) marker.size = size; if (!isNaN(px) && px !== 12) marker.px = px; if (!isNaN(dx) && dx !== 50) marker.dx = dx; @@ -631,7 +631,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.88.0")) { // v1.87 may have incorrect shield for some reason - pack.states.forEach(({coa}) => { + pack.states.forEach(({ coa }) => { if (coa?.shield === "state") delete coa.shield; }); } @@ -639,13 +639,13 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.91.0")) { // from 1.91.00 custom coa is moved to coa object pack.states.forEach(state => { - if (state.coa === "custom") state.coa = {custom: true}; + if (state.coa === "custom") state.coa = { custom: true }; }); pack.provinces.forEach(province => { - if (province.coa === "custom") province.coa = {custom: true}; + if (province.coa === "custom") province.coa = { custom: true }; }); pack.burgs.forEach(burg => { - if (burg.coa === "custom") burg.coa = {custom: true}; + if (burg.coa === "custom") burg.coa = { custom: true }; }); // from 1.91.00 emblems don't have transform attribute @@ -747,7 +747,7 @@ export function resolveVersionConflicts(mapVersion) { const skip = terrs.attr("skip"); const relax = terrs.attr("relax"); - const curveTypes = {0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep"}; + const curveTypes = { 0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep" }; const curve = curveTypes[terrs.attr("curve")] || "curveBasisClosed"; terrs @@ -882,7 +882,7 @@ export function resolveVersionConflicts(mapVersion) { const secondCellId = points[1][2]; const feature = pack.cells.f[secondCellId]; - pack.routes.push({i: pack.routes.length, group, feature, points}); + pack.routes.push({ i: pack.routes.length, group, feature, points }); } } routes.selectAll("path").remove(); @@ -914,7 +914,7 @@ export function resolveVersionConflicts(mapVersion) { const type = this.dataset.type; const color = this.getAttribute("fill"); const cells = this.dataset.cells.split(",").map(Number); - pack.zones.push({i, name, type, cells, color}); + pack.zones.push({ i, name, type, cells, color }); }); zones.style("display", null).selectAll("*").remove(); if (layerIsOn("toggleZones")) drawZones(); @@ -975,7 +975,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.109.0")) { // v1.109.0 added customizable burg groups and icons - options.burgs = {groups: []}; + options.burgs = { groups: [] }; burgIcons.selectAll("circle, use").each(function () { const group = this.parentNode.id; @@ -987,7 +987,7 @@ export function resolveVersionConflicts(mapVersion) { burgIcons.selectAll("g").each(function (_el, index) { const name = this.id; const isDefault = name === "towns"; - options.burgs.groups.push({name, active: true, order: index + 1, isDefault, preview: "watabou-city"}); + options.burgs.groups.push({ name, active: true, order: index + 1, isDefault, preview: "watabou-city" }); if (!this.dataset.icon) this.dataset.icon = "#icon-circle"; const size = Number(this.getAttribute("size") || 2) * 2; @@ -1036,4 +1036,74 @@ export function resolveVersionConflicts(mapVersion) { delete options.showMFCGMap; delete options.villageMaxPopulation; } + + if (isOlderThan("1.111.0")) { + // v1.111.0 moved ice data from SVG to data model + // Migrate old ice SVG elements to new pack.ice structure + if (!pack.ice) { + pack.ice = []; + let iceId = 0; + + const iceLayer = document.getElementById("ice"); + if (iceLayer) { + // Migrate glaciers (type="iceShield") + iceLayer.querySelectorAll("polygon[type='iceShield']").forEach(polygon => { + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "glacier" + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Migrate icebergs + iceLayer.querySelectorAll("polygon:not([type])").forEach(polygon => { + const cellId = +polygon.getAttribute("cell"); + const size = +polygon.getAttribute("size"); + + // points string must exist, cell attribute must be present, and size must be non-zero + if (polygon.getAttribute("cell") === null || !size) return; + + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "iceberg", + cellId, + size + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Clear old SVG elements + iceLayer.querySelectorAll("*").forEach(el => el.remove()); + } else { + // If ice layer element doesn't exist, create it + ice = viewbox.insert("g", "#coastline").attr("id", "ice"); + ice + .attr("opacity", null) + .attr("fill", "#e8f0f6") + .attr("stroke", "#e8f0f6") + .attr("stroke-width", 1) + .attr("filter", "url(#dropShadow05)"); + } + + // Re-render ice from migrated data + if (layerIsOn("toggleIce")) drawIce(); + } + + } } diff --git a/public/modules/ice.js b/public/modules/ice.js new file mode 100644 index 00000000..90c7c3e6 --- /dev/null +++ b/public/modules/ice.js @@ -0,0 +1,170 @@ +"use strict"; + +// Ice layer data model - separates ice data from SVG rendering +window.Ice = (function () { + + // Find next available id for new ice element idealy filling gaps + function getNextId() { + if (pack.ice.length === 0) return 0; + // find gaps in existing ids + const existingIds = pack.ice.map(e => e.i).sort((a, b) => a - b); + for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { + if (!existingIds.includes(id)) return id; + } + return existingIds[existingIds.length - 1] + 1; + } + + // Generate glaciers and icebergs based on temperature and height + function generate() { + clear(); + const { cells, features } = grid; + const { temp, h } = cells; + Math.random = aleaPRNG(seed); + + const ICEBERG_MAX_TEMP = 0; + const GLACIER_MAX_TEMP = -8; + const minMaxTemp = d3.min(temp); + + // Generate glaciers on cold land + { + const type = "iceShield"; + const getType = cellId => + h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null; + const isolines = getIsolines(grid, getType, { polygons: true }); + + if (isolines[type]?.polygons) { + isolines[type].polygons.forEach(points => { + const clipped = clipPoly(points); + pack.ice.push({ + i: getNextId(), + points: clipped, + type: "glacier" + }); + }); + } + } + + // Generate icebergs on cold water + for (const cellId of grid.cells.i) { + const t = temp[cellId]; + if (h[cellId] >= 20) continue; // no icebergs on land + if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs + if (features[cells.f[cellId]].type === "lake") continue; // no icebergs on lakes + if (P(0.8)) continue; // skip most of eligible cells + + const randomFactor = 0.8 + rand() * 0.4; // random size factor + let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero, 1 = full + if (cells.t[cellId] === -1) baseSize /= 1.3; // coastline: smaller icebergs + const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); + + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + + pack.ice.push({ + i: getNextId(), + points, + type: "iceberg", + cellId, + size + }); + } + } + + function addIceberg(cellId, size) { + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + const id = getNextId(); + pack.ice.push({ + i: id, + points, + type: "iceberg", + cellId, + size + }); + redrawIceberg(id); + } + + function removeIce(id) { + const index = pack.ice.findIndex(element => element.i === id); + if (index !== -1) { + const type = pack.ice.find(element => element.i === id).type; + pack.ice.splice(index, 1); + if (type === "glacier") { + redrawGlacier(id); + } else { + redrawIceberg(id); + } + + } + } + + function updateIceberg(id, points, size) { + const iceberg = pack.ice.find(element => element.i === id); + if (iceberg) { + iceberg.points = points; + iceberg.size = size; + } + } + + function randomizeIcebergShape(id) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const size = iceberg.size; + const [cx, cy] = grid.points[cellId]; + + // Get a different random cell for the polygon template + const i = ra(grid.cells.i); + const cn = grid.points[i]; + const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); + const points = poly.map(p => [ + rn(cx + p[0] * size, 2), + rn(cy + p[1] * size, 2) + ]); + + iceberg.points = points; + } + + function changeIcebergSize(id, newSize) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const [cx, cy] = grid.points[cellId]; + const oldSize = iceberg.size; + + const flat = iceberg.points.flat(); + const pairs = []; + while (flat.length) pairs.push(flat.splice(0, 2)); + const poly = pairs.map(p => [(p[0] - cx) / oldSize, (p[1] - cy) / oldSize]); + const points = poly.map(p => [ + rn(cx + p[0] * newSize, 2), + rn(cy + p[1] * newSize, 2) + ]); + + iceberg.points = points; + iceberg.size = newSize; + } + + // Clear all ice + function clear() { + pack.ice = []; + } + + return { + generate, + addIceberg, + removeIce, + updateIceberg, + randomizeIcebergShape, + changeIcebergSize, + clear + }; +})(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 689757b2..9b401733 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -406,6 +406,7 @@ async function parseLoadedData(data, mapVersion) { pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length); // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; + pack.ice = data[39] ? JSON.parse(data[39]) : []; if (data[31]) { const namesDL = data[31].split("/"); @@ -449,7 +450,7 @@ async function parseLoadedData(data, mapVersion) { if (isVisible(routes) && hasChild(routes, "path")) turnOn("toggleRoutes"); if (hasChildren(temperature)) turnOn("toggleTemperature"); if (hasChild(population, "line")) turnOn("togglePopulation"); - if (hasChildren(ice)) turnOn("toggleIce"); + if (isVisible(ice)) turnOn("toggleIce"); if (hasChild(prec, "circle")) turnOn("togglePrecipitation"); if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems"); if (isVisible(labels)) turnOn("toggleLabels"); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 304fef59..25cd7493 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -32,12 +32,13 @@ async function saveMap(method) { $(this).dialog("close"); } }, - position: {my: "center", at: "center", of: "svg"} + position: { my: "center", at: "center", of: "svg" } }); } } function prepareMapData() { + const date = new Date(); const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator"; @@ -89,8 +90,8 @@ function prepareMapData() { const serializedSVG = new XMLSerializer().serializeToString(cloneEl); - const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid; - const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired}); + const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid; + const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired }); const packFeatures = JSON.stringify(pack.features); const cultures = JSON.stringify(pack.cultures); const states = JSON.stringify(pack.states); @@ -102,6 +103,7 @@ function prepareMapData() { const cellRoutes = JSON.stringify(pack.cells.routes); const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); + const ice = JSON.stringify(pack.ice); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -155,21 +157,22 @@ function prepareMapData() { markers, cellRoutes, routes, - zones + zones, + ice ].join("\r\n"); return mapData; } // save map file to indexedDB async function saveToStorage(mapData, showTip = false) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); await ldb.set("lastMap", blob); showTip && tip("Map is saved to the browser storage", false, "success"); } // download map file function saveToMachine(mapData, filename) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); const URL = window.URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/public/modules/renderers/draw-ice.js b/public/modules/renderers/draw-ice.js new file mode 100644 index 00000000..4b35f75c --- /dev/null +++ b/public/modules/renderers/draw-ice.js @@ -0,0 +1,70 @@ +"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/ui/editors.js b/public/modules/ui/editors.js index 77c391ee..50eaf1c7 100644 --- a/public/modules/ui/editors.js +++ b/public/modules/ui/editors.js @@ -26,7 +26,7 @@ function clicked() { else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel(); else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); - else if (parent.id === "ice") editIce(); + else if (parent.id === "ice") editIce(el); else if (parent.id === "terrain") editReliefIcon(); else if (grand.id === "markers" || great.id === "markers") editMarker(); else if (grand.id === "coastline") editCoastline(); diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index d655e39d..5c3f1fc3 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -259,6 +259,8 @@ function editHeightmap(options) { Rivers.specify(); Lakes.defineNames(); + Ice.generate(); + Military.generate(); Markers.generate(); Zones.generate(); @@ -465,6 +467,10 @@ function editHeightmap(options) { .attr("id", d => base + d); }); + // recalculate ice + Ice.generate(); + ice.selectAll("*").remove(); + TIME && console.timeEnd("restoreRiskedData"); INFO && console.groupEnd("Edit Heightmap"); } diff --git a/public/modules/ui/ice-editor.js b/public/modules/ui/ice-editor.js index a9e6ff28..16818b4c 100644 --- a/public/modules/ui/ice-editor.js +++ b/public/modules/ui/ice-editor.js @@ -1,26 +1,32 @@ "use strict"; -function editIce() { +function editIce(element) { if (customization) return; + if (elSelected && element === elSelected.node()) return; + closeDialogs(".stable"); if (!layerIsOn("toggleIce")) toggleIce(); elSelected = d3.select(d3.event.target); - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; - document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block"; - document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block"; - if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size"); + const id = +elSelected.attr("data-id"); + const iceElement = pack.ice.find(el => el.i === id); + const isGlacier = elSelected.attr("type") === "glacier"; + const type = isGlacier ? "Glacier" : "Iceberg"; + + document.getElementById("iceRandomize").style.display = isGlacier ? "none" : "inline-block"; + document.getElementById("iceSize").style.display = isGlacier ? "none" : "inline-block"; + if (!isGlacier) document.getElementById("iceSize").value = iceElement?.size || ""; + ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement)); $("#iceEditor").dialog({ title: "Edit " + type, resizable: false, - position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"}, + position: { my: "center top+60", at: "top", of: d3.event, collision: "fit" }, close: closeEditor }); if (modules.editIce) return; modules.editIce = true; - // add listeners document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice")); document.getElementById("iceRandomize").addEventListener("click", randomizeShape); @@ -28,29 +34,18 @@ function editIce() { document.getElementById("iceNew").addEventListener("click", toggleAdd); document.getElementById("iceRemove").addEventListener("click", removeIce); + function randomizeShape() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const i = ra(grid.cells.i), - cn = grid.points[i]; - const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); - const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]); - elSelected.attr("points", points); + const selectedId = +elSelected.attr("data-id"); + Ice.randomizeIcebergShape(selectedId); + redrawIceberg(selectedId); } function changeSize() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const flat = elSelected - .attr("points") - .split(",") - .map(el => +el); - const pairs = []; - while (flat.length) pairs.push(flat.splice(0, 2)); - const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]); - const size = +this.value; - const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]); - elSelected.attr("points", points).attr("size", size); + const newSize = +this.value; + const selectedId = +elSelected.attr("data-id"); + Ice.changeIcebergSize(selectedId, newSize); + redrawIceberg(selectedId); } function toggleAdd() { @@ -67,17 +62,15 @@ function editIce() { function addIcebergOnClick() { const [x, y] = d3.mouse(this); const i = findGridCell(x, y, grid); - const [cx, cy] = grid.points[i]; const size = +document.getElementById("iceSize")?.value || 1; - const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size); - iceberg.call(d3.drag().on("drag", dragElement)); + Ice.addIceberg(i, size); + if (d3.event.shiftKey === false) toggleAdd(); } function removeIce() { - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; + const type = elSelected.attr("type") === "glacier" ? "Glacier" : "Iceberg"; alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`; $("#alert").dialog({ resizable: false, @@ -85,7 +78,7 @@ function editIce() { buttons: { Remove: function () { $(this).dialog("close"); - elSelected.remove(); + Ice.removeIce(+elSelected.attr("data-id")); $("#iceEditor").dialog("close"); }, Cancel: function () { @@ -96,14 +89,24 @@ function editIce() { } function dragElement() { - const tr = parseTransform(this.getAttribute("transform")); - const dx = +tr[0] - d3.event.x, - dy = +tr[1] - d3.event.y; + const selectedId = +elSelected.attr("data-id"); + const initialTransform = parseTransform(this.getAttribute("transform")); + const dx = initialTransform[0] - d3.event.x; + const dy = initialTransform[1] - d3.event.y; d3.event.on("drag", function () { - const x = d3.event.x, - y = d3.event.y; - this.setAttribute("transform", `translate(${dx + x},${dy + y})`); + const x = d3.event.x; + const y = d3.event.y; + const transform = `translate(${dx + x},${dy + y})`; + this.setAttribute("transform", transform); + + // Update data model with new position + const offset = [dx + x, dy + y]; + const iceData = pack.ice.find(element => element.i === selectedId); + if (iceData) { + // Store offset for visual positioning, actual geometry stays in points + iceData.offset = offset; + } }); } @@ -114,3 +117,4 @@ function editIce() { unselect(); } } + diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 5037a5ee..f2f04a4b 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -417,49 +417,6 @@ function toggleIce(event) { } } -function drawIce() { - TIME && console.time("drawIce"); - - const {cells, features} = grid; - const {temp, h} = cells; - Math.random = aleaPRNG(seed); - - const ICEBERG_MAX_TEMP = 0; - const GLACIER_MAX_TEMP = -8; - const minMaxTemp = d3.min(temp); - - // cold land: draw glaciers - { - const type = "iceShield"; - const getType = cellId => (h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null); - const isolines = getIsolines(grid, getType, {polygons: true}); - isolines[type]?.polygons?.forEach(points => { - const clipped = clipPoly(points); - ice.append("polygon").attr("points", clipped).attr("type", type); - }); - } - - // cold water: draw icebergs - for (const cellId of grid.cells.i) { - const t = temp[cellId]; - if (h[cellId] >= 20) continue; // no icebergs on land - if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs - if (features[cells.f[cellId]].type === "lake") continue; // no icebers on lakes - if (P(0.8)) continue; // skip most of eligible cells - - const randomFactor = 0.8 + rand() * 0.4; // random size factor - let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero size, 1 = full size - if (cells.t[cellId] === -1) baseSize /= 1.3; // coasline: smaller icebergs - const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); - - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - ice.append("polygon").attr("points", points).attr("cell", cellId).attr("size", size); - } - - TIME && console.timeEnd("drawIce"); -} - function toggleCultures(event) { const cultures = pack.cultures.filter(c => c.i && !c.removed); const empty = !cults.selectAll("path").size(); diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index a3df5c00..eade993f 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -555,7 +555,7 @@ function regenerateMilitary() { function regenerateIce() { if (!layerIsOn("toggleIce")) toggleIce(); - ice.selectAll("*").remove(); + Ice.generate(); drawIce(); } diff --git a/public/versioning.js b/public/versioning.js index 11fcde66..8069c818 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.110.0"; +const VERSION = "1.111.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index 21d84187..f75cbbfc 100644 --- a/src/index.html +++ b/src/index.html @@ -8515,16 +8515,16 @@ - + - + - - + + @@ -8535,7 +8535,7 @@ - + @@ -8549,12 +8549,12 @@ - - - + + + - + @@ -8566,8 +8566,8 @@ - - + + @@ -8583,5 +8583,6 @@ + From b228a8f6100810aec33ed7de47e47156f3c40033 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Thu, 22 Jan 2026 17:51:20 +0100 Subject: [PATCH 03/24] Revert "Ice Layer Data Model (#1262)" (#1275) This reverts commit e597d905eb6a96e11156d9016c14dd98d8c421e5. --- public/main.js | 2 - public/modules/dynamic/auto-update.js | 102 +++------------- public/modules/ice.js | 170 -------------------------- public/modules/io/load.js | 3 +- public/modules/io/save.js | 15 +-- public/modules/renderers/draw-ice.js | 70 ----------- public/modules/ui/editors.js | 2 +- public/modules/ui/heightmap-editor.js | 6 - public/modules/ui/ice-editor.js | 80 ++++++------ public/modules/ui/layers.js | 43 +++++++ public/modules/ui/tools.js | 2 +- public/versioning.js | 2 +- src/index.html | 23 ++-- 13 files changed, 118 insertions(+), 402 deletions(-) delete mode 100644 public/modules/ice.js delete mode 100644 public/modules/renderers/draw-ice.js diff --git a/public/main.js b/public/main.js index 7dbb9585..6da462d5 100644 --- a/public/main.js +++ b/public/main.js @@ -632,8 +632,6 @@ async function generate(options) { Biomes.define(); Features.defineGroups(); - Ice.generate(); - rankCells(); Cultures.generate(); Cultures.expand(); diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index 6e252330..a3190e3b 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -253,8 +253,8 @@ export function resolveVersionConflicts(mapVersion) { const source = findCell(s.x, s.y); const mouth = findCell(e.x, e.y); const name = Rivers.getName(mouth); - const type = length < 25 ? rw({ Creek: 9, River: 3, Brook: 3, Stream: 1 }) : "River"; - pack.rivers.push({ i, parent: 0, length, source, mouth, basin: i, name, type }); + const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River"; + pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type}); }); } @@ -270,7 +270,7 @@ export function resolveVersionConflicts(mapVersion) { const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era"; const eraShort = era[0] + "E"; const military = Military.getDefaultOptions(); - options = { winds, year, era, eraShort, military }; + options = {winds, year, era, eraShort, military}; // v1.3 added campaings data for all states States.generateCampaigns(); @@ -481,7 +481,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.65.0")) { // v1.65 changed rivers data d3.select("#rivers").attr("style", null); // remove style to unhide layer - const { cells, rivers } = pack; + const {cells, rivers} = pack; const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); for (const river of rivers) { @@ -497,8 +497,8 @@ export function resolveVersionConflicts(mapVersion) { for (let i = 0; i <= segments; i++) { const shift = increment * i; - const { x: x1, y: y1 } = node.getPointAtLength(length + shift); - const { x: x2, y: y2 } = node.getPointAtLength(length - shift); + const {x: x1, y: y1} = node.getPointAtLength(length + shift); + const {x: x2, y: y2} = node.getPointAtLength(length - shift); const x = rn((x1 + x2) / 2, 1); const y = rn((y1 + y2) / 2, 1); @@ -565,7 +565,7 @@ export function resolveVersionConflicts(mapVersion) { const fill = circle && circle.getAttribute("fill"); const stroke = circle && circle.getAttribute("stroke"); - const marker = { i, icon, type, x, y, size, cell }; + const marker = {i, icon, type, x, y, size, cell}; if (size && size !== 30) marker.size = size; if (!isNaN(px) && px !== 12) marker.px = px; if (!isNaN(dx) && dx !== 50) marker.dx = dx; @@ -631,7 +631,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.88.0")) { // v1.87 may have incorrect shield for some reason - pack.states.forEach(({ coa }) => { + pack.states.forEach(({coa}) => { if (coa?.shield === "state") delete coa.shield; }); } @@ -639,13 +639,13 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.91.0")) { // from 1.91.00 custom coa is moved to coa object pack.states.forEach(state => { - if (state.coa === "custom") state.coa = { custom: true }; + if (state.coa === "custom") state.coa = {custom: true}; }); pack.provinces.forEach(province => { - if (province.coa === "custom") province.coa = { custom: true }; + if (province.coa === "custom") province.coa = {custom: true}; }); pack.burgs.forEach(burg => { - if (burg.coa === "custom") burg.coa = { custom: true }; + if (burg.coa === "custom") burg.coa = {custom: true}; }); // from 1.91.00 emblems don't have transform attribute @@ -747,7 +747,7 @@ export function resolveVersionConflicts(mapVersion) { const skip = terrs.attr("skip"); const relax = terrs.attr("relax"); - const curveTypes = { 0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep" }; + const curveTypes = {0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep"}; const curve = curveTypes[terrs.attr("curve")] || "curveBasisClosed"; terrs @@ -882,7 +882,7 @@ export function resolveVersionConflicts(mapVersion) { const secondCellId = points[1][2]; const feature = pack.cells.f[secondCellId]; - pack.routes.push({ i: pack.routes.length, group, feature, points }); + pack.routes.push({i: pack.routes.length, group, feature, points}); } } routes.selectAll("path").remove(); @@ -914,7 +914,7 @@ export function resolveVersionConflicts(mapVersion) { const type = this.dataset.type; const color = this.getAttribute("fill"); const cells = this.dataset.cells.split(",").map(Number); - pack.zones.push({ i, name, type, cells, color }); + pack.zones.push({i, name, type, cells, color}); }); zones.style("display", null).selectAll("*").remove(); if (layerIsOn("toggleZones")) drawZones(); @@ -975,7 +975,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.109.0")) { // v1.109.0 added customizable burg groups and icons - options.burgs = { groups: [] }; + options.burgs = {groups: []}; burgIcons.selectAll("circle, use").each(function () { const group = this.parentNode.id; @@ -987,7 +987,7 @@ export function resolveVersionConflicts(mapVersion) { burgIcons.selectAll("g").each(function (_el, index) { const name = this.id; const isDefault = name === "towns"; - options.burgs.groups.push({ name, active: true, order: index + 1, isDefault, preview: "watabou-city" }); + options.burgs.groups.push({name, active: true, order: index + 1, isDefault, preview: "watabou-city"}); if (!this.dataset.icon) this.dataset.icon = "#icon-circle"; const size = Number(this.getAttribute("size") || 2) * 2; @@ -1036,74 +1036,4 @@ export function resolveVersionConflicts(mapVersion) { delete options.showMFCGMap; delete options.villageMaxPopulation; } - - if (isOlderThan("1.111.0")) { - // v1.111.0 moved ice data from SVG to data model - // Migrate old ice SVG elements to new pack.ice structure - if (!pack.ice) { - pack.ice = []; - let iceId = 0; - - const iceLayer = document.getElementById("ice"); - if (iceLayer) { - // Migrate glaciers (type="iceShield") - iceLayer.querySelectorAll("polygon[type='iceShield']").forEach(polygon => { - // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] - const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); - - const transform = polygon.getAttribute("transform"); - const iceElement = { - i: iceId++, - points, - type: "glacier" - }; - if (transform) { - iceElement.offset = parseTransform(transform); - } - pack.ice.push(iceElement); - }); - - // Migrate icebergs - iceLayer.querySelectorAll("polygon:not([type])").forEach(polygon => { - const cellId = +polygon.getAttribute("cell"); - const size = +polygon.getAttribute("size"); - - // points string must exist, cell attribute must be present, and size must be non-zero - if (polygon.getAttribute("cell") === null || !size) return; - - // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] - const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); - - const transform = polygon.getAttribute("transform"); - const iceElement = { - i: iceId++, - points, - type: "iceberg", - cellId, - size - }; - if (transform) { - iceElement.offset = parseTransform(transform); - } - pack.ice.push(iceElement); - }); - - // Clear old SVG elements - iceLayer.querySelectorAll("*").forEach(el => el.remove()); - } else { - // If ice layer element doesn't exist, create it - ice = viewbox.insert("g", "#coastline").attr("id", "ice"); - ice - .attr("opacity", null) - .attr("fill", "#e8f0f6") - .attr("stroke", "#e8f0f6") - .attr("stroke-width", 1) - .attr("filter", "url(#dropShadow05)"); - } - - // Re-render ice from migrated data - if (layerIsOn("toggleIce")) drawIce(); - } - - } } diff --git a/public/modules/ice.js b/public/modules/ice.js deleted file mode 100644 index 90c7c3e6..00000000 --- a/public/modules/ice.js +++ /dev/null @@ -1,170 +0,0 @@ -"use strict"; - -// Ice layer data model - separates ice data from SVG rendering -window.Ice = (function () { - - // Find next available id for new ice element idealy filling gaps - function getNextId() { - if (pack.ice.length === 0) return 0; - // find gaps in existing ids - const existingIds = pack.ice.map(e => e.i).sort((a, b) => a - b); - for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { - if (!existingIds.includes(id)) return id; - } - return existingIds[existingIds.length - 1] + 1; - } - - // Generate glaciers and icebergs based on temperature and height - function generate() { - clear(); - const { cells, features } = grid; - const { temp, h } = cells; - Math.random = aleaPRNG(seed); - - const ICEBERG_MAX_TEMP = 0; - const GLACIER_MAX_TEMP = -8; - const minMaxTemp = d3.min(temp); - - // Generate glaciers on cold land - { - const type = "iceShield"; - const getType = cellId => - h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null; - const isolines = getIsolines(grid, getType, { polygons: true }); - - if (isolines[type]?.polygons) { - isolines[type].polygons.forEach(points => { - const clipped = clipPoly(points); - pack.ice.push({ - i: getNextId(), - points: clipped, - type: "glacier" - }); - }); - } - } - - // Generate icebergs on cold water - for (const cellId of grid.cells.i) { - const t = temp[cellId]; - if (h[cellId] >= 20) continue; // no icebergs on land - if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs - if (features[cells.f[cellId]].type === "lake") continue; // no icebergs on lakes - if (P(0.8)) continue; // skip most of eligible cells - - const randomFactor = 0.8 + rand() * 0.4; // random size factor - let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero, 1 = full - if (cells.t[cellId] === -1) baseSize /= 1.3; // coastline: smaller icebergs - const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); - - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [ - rn(lerp(cx, x, size), 2), - rn(lerp(cy, y, size), 2) - ]); - - pack.ice.push({ - i: getNextId(), - points, - type: "iceberg", - cellId, - size - }); - } - } - - function addIceberg(cellId, size) { - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [ - rn(lerp(cx, x, size), 2), - rn(lerp(cy, y, size), 2) - ]); - const id = getNextId(); - pack.ice.push({ - i: id, - points, - type: "iceberg", - cellId, - size - }); - redrawIceberg(id); - } - - function removeIce(id) { - const index = pack.ice.findIndex(element => element.i === id); - if (index !== -1) { - const type = pack.ice.find(element => element.i === id).type; - pack.ice.splice(index, 1); - if (type === "glacier") { - redrawGlacier(id); - } else { - redrawIceberg(id); - } - - } - } - - function updateIceberg(id, points, size) { - const iceberg = pack.ice.find(element => element.i === id); - if (iceberg) { - iceberg.points = points; - iceberg.size = size; - } - } - - function randomizeIcebergShape(id) { - const iceberg = pack.ice.find(element => element.i === id); - if (!iceberg) return; - - const cellId = iceberg.cellId; - const size = iceberg.size; - const [cx, cy] = grid.points[cellId]; - - // Get a different random cell for the polygon template - const i = ra(grid.cells.i); - const cn = grid.points[i]; - const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); - const points = poly.map(p => [ - rn(cx + p[0] * size, 2), - rn(cy + p[1] * size, 2) - ]); - - iceberg.points = points; - } - - function changeIcebergSize(id, newSize) { - const iceberg = pack.ice.find(element => element.i === id); - if (!iceberg) return; - - const cellId = iceberg.cellId; - const [cx, cy] = grid.points[cellId]; - const oldSize = iceberg.size; - - const flat = iceberg.points.flat(); - const pairs = []; - while (flat.length) pairs.push(flat.splice(0, 2)); - const poly = pairs.map(p => [(p[0] - cx) / oldSize, (p[1] - cy) / oldSize]); - const points = poly.map(p => [ - rn(cx + p[0] * newSize, 2), - rn(cy + p[1] * newSize, 2) - ]); - - iceberg.points = points; - iceberg.size = newSize; - } - - // Clear all ice - function clear() { - pack.ice = []; - } - - return { - generate, - addIceberg, - removeIce, - updateIceberg, - randomizeIcebergShape, - changeIcebergSize, - clear - }; -})(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 9b401733..689757b2 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -406,7 +406,6 @@ async function parseLoadedData(data, mapVersion) { pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length); // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; - pack.ice = data[39] ? JSON.parse(data[39]) : []; if (data[31]) { const namesDL = data[31].split("/"); @@ -450,7 +449,7 @@ async function parseLoadedData(data, mapVersion) { if (isVisible(routes) && hasChild(routes, "path")) turnOn("toggleRoutes"); if (hasChildren(temperature)) turnOn("toggleTemperature"); if (hasChild(population, "line")) turnOn("togglePopulation"); - if (isVisible(ice)) turnOn("toggleIce"); + if (hasChildren(ice)) turnOn("toggleIce"); if (hasChild(prec, "circle")) turnOn("togglePrecipitation"); if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems"); if (isVisible(labels)) turnOn("toggleLabels"); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 25cd7493..304fef59 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -32,13 +32,12 @@ async function saveMap(method) { $(this).dialog("close"); } }, - position: { my: "center", at: "center", of: "svg" } + position: {my: "center", at: "center", of: "svg"} }); } } function prepareMapData() { - const date = new Date(); const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator"; @@ -90,8 +89,8 @@ function prepareMapData() { const serializedSVG = new XMLSerializer().serializeToString(cloneEl); - const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid; - const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired }); + const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid; + const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired}); const packFeatures = JSON.stringify(pack.features); const cultures = JSON.stringify(pack.cultures); const states = JSON.stringify(pack.states); @@ -103,7 +102,6 @@ function prepareMapData() { const cellRoutes = JSON.stringify(pack.cells.routes); const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); - const ice = JSON.stringify(pack.ice); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -157,22 +155,21 @@ function prepareMapData() { markers, cellRoutes, routes, - zones, - ice + zones ].join("\r\n"); return mapData; } // save map file to indexedDB async function saveToStorage(mapData, showTip = false) { - const blob = new Blob([mapData], { type: "text/plain" }); + const blob = new Blob([mapData], {type: "text/plain"}); await ldb.set("lastMap", blob); showTip && tip("Map is saved to the browser storage", false, "success"); } // download map file function saveToMachine(mapData, filename) { - const blob = new Blob([mapData], { type: "text/plain" }); + const blob = new Blob([mapData], {type: "text/plain"}); const URL = window.URL.createObjectURL(blob); const link = document.createElement("a"); 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/ui/editors.js b/public/modules/ui/editors.js index 50eaf1c7..77c391ee 100644 --- a/public/modules/ui/editors.js +++ b/public/modules/ui/editors.js @@ -26,7 +26,7 @@ function clicked() { else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel(); else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); - else if (parent.id === "ice") editIce(el); + else if (parent.id === "ice") editIce(); else if (parent.id === "terrain") editReliefIcon(); else if (grand.id === "markers" || great.id === "markers") editMarker(); else if (grand.id === "coastline") editCoastline(); diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index 5c3f1fc3..d655e39d 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -259,8 +259,6 @@ function editHeightmap(options) { Rivers.specify(); Lakes.defineNames(); - Ice.generate(); - Military.generate(); Markers.generate(); Zones.generate(); @@ -467,10 +465,6 @@ function editHeightmap(options) { .attr("id", d => base + d); }); - // recalculate ice - Ice.generate(); - ice.selectAll("*").remove(); - TIME && console.timeEnd("restoreRiskedData"); INFO && console.groupEnd("Edit Heightmap"); } diff --git a/public/modules/ui/ice-editor.js b/public/modules/ui/ice-editor.js index 16818b4c..a9e6ff28 100644 --- a/public/modules/ui/ice-editor.js +++ b/public/modules/ui/ice-editor.js @@ -1,32 +1,26 @@ "use strict"; -function editIce(element) { +function editIce() { if (customization) return; - if (elSelected && element === elSelected.node()) return; - closeDialogs(".stable"); if (!layerIsOn("toggleIce")) toggleIce(); elSelected = d3.select(d3.event.target); - const id = +elSelected.attr("data-id"); - const iceElement = pack.ice.find(el => el.i === id); - const isGlacier = elSelected.attr("type") === "glacier"; - const type = isGlacier ? "Glacier" : "Iceberg"; - - document.getElementById("iceRandomize").style.display = isGlacier ? "none" : "inline-block"; - document.getElementById("iceSize").style.display = isGlacier ? "none" : "inline-block"; - if (!isGlacier) document.getElementById("iceSize").value = iceElement?.size || ""; - + const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; + document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block"; + document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block"; + if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size"); ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement)); $("#iceEditor").dialog({ title: "Edit " + type, resizable: false, - position: { my: "center top+60", at: "top", of: d3.event, collision: "fit" }, + position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"}, close: closeEditor }); if (modules.editIce) return; modules.editIce = true; + // add listeners document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice")); document.getElementById("iceRandomize").addEventListener("click", randomizeShape); @@ -34,18 +28,29 @@ function editIce(element) { document.getElementById("iceNew").addEventListener("click", toggleAdd); document.getElementById("iceRemove").addEventListener("click", removeIce); - function randomizeShape() { - const selectedId = +elSelected.attr("data-id"); - Ice.randomizeIcebergShape(selectedId); - redrawIceberg(selectedId); + const c = grid.points[+elSelected.attr("cell")]; + const s = +elSelected.attr("size"); + const i = ra(grid.cells.i), + cn = grid.points[i]; + const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); + const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]); + elSelected.attr("points", points); } function changeSize() { - const newSize = +this.value; - const selectedId = +elSelected.attr("data-id"); - Ice.changeIcebergSize(selectedId, newSize); - redrawIceberg(selectedId); + const c = grid.points[+elSelected.attr("cell")]; + const s = +elSelected.attr("size"); + const flat = elSelected + .attr("points") + .split(",") + .map(el => +el); + const pairs = []; + while (flat.length) pairs.push(flat.splice(0, 2)); + const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]); + const size = +this.value; + const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]); + elSelected.attr("points", points).attr("size", size); } function toggleAdd() { @@ -62,15 +67,17 @@ function editIce(element) { function addIcebergOnClick() { const [x, y] = d3.mouse(this); const i = findGridCell(x, y, grid); + const [cx, cy] = grid.points[i]; const size = +document.getElementById("iceSize")?.value || 1; - Ice.addIceberg(i, size); - + const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); + const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size); + iceberg.call(d3.drag().on("drag", dragElement)); if (d3.event.shiftKey === false) toggleAdd(); } function removeIce() { - const type = elSelected.attr("type") === "glacier" ? "Glacier" : "Iceberg"; + const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`; $("#alert").dialog({ resizable: false, @@ -78,7 +85,7 @@ function editIce(element) { buttons: { Remove: function () { $(this).dialog("close"); - Ice.removeIce(+elSelected.attr("data-id")); + elSelected.remove(); $("#iceEditor").dialog("close"); }, Cancel: function () { @@ -89,24 +96,14 @@ function editIce(element) { } function dragElement() { - const selectedId = +elSelected.attr("data-id"); - const initialTransform = parseTransform(this.getAttribute("transform")); - const dx = initialTransform[0] - d3.event.x; - const dy = initialTransform[1] - d3.event.y; + const tr = parseTransform(this.getAttribute("transform")); + const dx = +tr[0] - d3.event.x, + dy = +tr[1] - d3.event.y; d3.event.on("drag", function () { - const x = d3.event.x; - const y = d3.event.y; - const transform = `translate(${dx + x},${dy + y})`; - this.setAttribute("transform", transform); - - // Update data model with new position - const offset = [dx + x, dy + y]; - const iceData = pack.ice.find(element => element.i === selectedId); - if (iceData) { - // Store offset for visual positioning, actual geometry stays in points - iceData.offset = offset; - } + const x = d3.event.x, + y = d3.event.y; + this.setAttribute("transform", `translate(${dx + x},${dy + y})`); }); } @@ -117,4 +114,3 @@ function editIce(element) { unselect(); } } - diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index f2f04a4b..5037a5ee 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -417,6 +417,49 @@ function toggleIce(event) { } } +function drawIce() { + TIME && console.time("drawIce"); + + const {cells, features} = grid; + const {temp, h} = cells; + Math.random = aleaPRNG(seed); + + const ICEBERG_MAX_TEMP = 0; + const GLACIER_MAX_TEMP = -8; + const minMaxTemp = d3.min(temp); + + // cold land: draw glaciers + { + const type = "iceShield"; + const getType = cellId => (h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null); + const isolines = getIsolines(grid, getType, {polygons: true}); + isolines[type]?.polygons?.forEach(points => { + const clipped = clipPoly(points); + ice.append("polygon").attr("points", clipped).attr("type", type); + }); + } + + // cold water: draw icebergs + for (const cellId of grid.cells.i) { + const t = temp[cellId]; + if (h[cellId] >= 20) continue; // no icebergs on land + if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs + if (features[cells.f[cellId]].type === "lake") continue; // no icebers on lakes + if (P(0.8)) continue; // skip most of eligible cells + + const randomFactor = 0.8 + rand() * 0.4; // random size factor + let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero size, 1 = full size + if (cells.t[cellId] === -1) baseSize /= 1.3; // coasline: smaller icebergs + const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); + + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); + ice.append("polygon").attr("points", points).attr("cell", cellId).attr("size", size); + } + + TIME && console.timeEnd("drawIce"); +} + function toggleCultures(event) { const cultures = pack.cultures.filter(c => c.i && !c.removed); const empty = !cults.selectAll("path").size(); diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index eade993f..a3df5c00 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -555,7 +555,7 @@ function regenerateMilitary() { function regenerateIce() { if (!layerIsOn("toggleIce")) toggleIce(); - Ice.generate(); + ice.selectAll("*").remove(); drawIce(); } diff --git a/public/versioning.js b/public/versioning.js index 8069c818..11fcde66 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.111.0"; +const VERSION = "1.110.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index f75cbbfc..21d84187 100644 --- a/src/index.html +++ b/src/index.html @@ -8515,16 +8515,16 @@ - + - + - - + + @@ -8535,7 +8535,7 @@ - + @@ -8549,12 +8549,12 @@ - - - + + + - + @@ -8566,8 +8566,8 @@ - - + + @@ -8583,6 +8583,5 @@ - From 81c1ba2963bda4a030c62ba57d0c86a9a916511a Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Thu, 22 Jan 2026 18:28:09 +0100 Subject: [PATCH 04/24] fix: initialize heights array if not already set in HeightmapGenerator (#1277) --- src/modules/heightmap-generator.ts | 4 +--- src/utils/stringUtils.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index eb48f9f4..27fb063b 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -560,9 +560,7 @@ class HeightmapGenerator { if(!ctx) { throw new Error("Could not get canvas context"); } - if(!this.heights) { - throw new Error("Heights array is not initialized"); - } + this.heights = this.heights || new Uint8Array(cellsX * cellsY); ctx.drawImage(img, 0, 0, cellsX, cellsY); const imageData = ctx.getImageData(0, 0, cellsX, cellsY); this.setGraph(graph); diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts index 02c4d42a..dc00a23a 100644 --- a/src/utils/stringUtils.ts +++ b/src/utils/stringUtils.ts @@ -6,7 +6,7 @@ import { rn } from "./numberUtils"; * @param {number} decimals - Number of decimal places (default is 1) * @returns {string} - The string with rounded numbers */ -export const round = (inputString: string, decimals: number = 1) => { +export const round = (inputString: string = "", decimals: number = 1) => { return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => { return rn(parseFloat(n), decimals).toString(); }); From 4b341a65905773bfe2330a9985f42c7d793fcccc Mon Sep 17 00:00:00 2001 From: kruschen Date: Thu, 22 Jan 2026 22:24:34 +0100 Subject: [PATCH 05/24] Data model ice (#1279) * prototype for ice seperation * feat: migrate ice data to new data model and update version to 1.110.0 * refactor: update ice data handling and rendering for improved performance * feat: integrate ice generation and recalculation in heightmap editing * fix ice selection(hopefully) * fix ice selection better(pls) * refactor: remove redundant element selection in ice editing functions * fix: clear ice data before generating glaciers and icebergs * sparse array implementation with reduced updates * fix logic chech in modules/dynamic/auto-update.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: migrate ice data to new data model structure * refactor: streamline ice generation process and clean up rendering functions * refactor: simplify ice rendering logic by removing redundant clearing of old SVG * fix: update editIce function to accept element parameter and improve logic for glacier handling * ice drawing with only type on less occuring glaciers * feat: add compactPackData function to filter out undefined glaciers and icebergs * fix: clear existing ice elements before redrawing in editHeightmap function * fix compact problems on autosave * refactor: unify ice data structure and streamline ice element handling * refactor: improve getNextId function to fill gaps in ice element IDs(optional commit) * just to be sure * bump version in html * fix index.html script import * feat: add ice module script to index.html * fix migration check --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/main.js | 2 + public/modules/dynamic/auto-update.js | 102 +++++++++++++--- public/modules/ice.js | 170 ++++++++++++++++++++++++++ public/modules/io/load.js | 3 +- public/modules/io/save.js | 15 ++- public/modules/renderers/draw-ice.js | 70 +++++++++++ public/modules/ui/editors.js | 2 +- public/modules/ui/heightmap-editor.js | 6 + public/modules/ui/ice-editor.js | 80 ++++++------ public/modules/ui/layers.js | 43 ------- public/modules/ui/tools.js | 2 +- public/versioning.js | 2 +- src/index.html | 24 ++-- 13 files changed, 403 insertions(+), 118 deletions(-) create mode 100644 public/modules/ice.js create mode 100644 public/modules/renderers/draw-ice.js diff --git a/public/main.js b/public/main.js index 6da462d5..7dbb9585 100644 --- a/public/main.js +++ b/public/main.js @@ -632,6 +632,8 @@ async function generate(options) { Biomes.define(); Features.defineGroups(); + Ice.generate(); + rankCells(); Cultures.generate(); Cultures.expand(); diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index a3190e3b..0b1cd227 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -253,8 +253,8 @@ export function resolveVersionConflicts(mapVersion) { const source = findCell(s.x, s.y); const mouth = findCell(e.x, e.y); const name = Rivers.getName(mouth); - const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River"; - pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type}); + const type = length < 25 ? rw({ Creek: 9, River: 3, Brook: 3, Stream: 1 }) : "River"; + pack.rivers.push({ i, parent: 0, length, source, mouth, basin: i, name, type }); }); } @@ -270,7 +270,7 @@ export function resolveVersionConflicts(mapVersion) { const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era"; const eraShort = era[0] + "E"; const military = Military.getDefaultOptions(); - options = {winds, year, era, eraShort, military}; + options = { winds, year, era, eraShort, military }; // v1.3 added campaings data for all states States.generateCampaigns(); @@ -481,7 +481,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.65.0")) { // v1.65 changed rivers data d3.select("#rivers").attr("style", null); // remove style to unhide layer - const {cells, rivers} = pack; + const { cells, rivers } = pack; const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); for (const river of rivers) { @@ -497,8 +497,8 @@ export function resolveVersionConflicts(mapVersion) { for (let i = 0; i <= segments; i++) { const shift = increment * i; - const {x: x1, y: y1} = node.getPointAtLength(length + shift); - const {x: x2, y: y2} = node.getPointAtLength(length - shift); + const { x: x1, y: y1 } = node.getPointAtLength(length + shift); + const { x: x2, y: y2 } = node.getPointAtLength(length - shift); const x = rn((x1 + x2) / 2, 1); const y = rn((y1 + y2) / 2, 1); @@ -565,7 +565,7 @@ export function resolveVersionConflicts(mapVersion) { const fill = circle && circle.getAttribute("fill"); const stroke = circle && circle.getAttribute("stroke"); - const marker = {i, icon, type, x, y, size, cell}; + const marker = { i, icon, type, x, y, size, cell }; if (size && size !== 30) marker.size = size; if (!isNaN(px) && px !== 12) marker.px = px; if (!isNaN(dx) && dx !== 50) marker.dx = dx; @@ -631,7 +631,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.88.0")) { // v1.87 may have incorrect shield for some reason - pack.states.forEach(({coa}) => { + pack.states.forEach(({ coa }) => { if (coa?.shield === "state") delete coa.shield; }); } @@ -639,13 +639,13 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.91.0")) { // from 1.91.00 custom coa is moved to coa object pack.states.forEach(state => { - if (state.coa === "custom") state.coa = {custom: true}; + if (state.coa === "custom") state.coa = { custom: true }; }); pack.provinces.forEach(province => { - if (province.coa === "custom") province.coa = {custom: true}; + if (province.coa === "custom") province.coa = { custom: true }; }); pack.burgs.forEach(burg => { - if (burg.coa === "custom") burg.coa = {custom: true}; + if (burg.coa === "custom") burg.coa = { custom: true }; }); // from 1.91.00 emblems don't have transform attribute @@ -747,7 +747,7 @@ export function resolveVersionConflicts(mapVersion) { const skip = terrs.attr("skip"); const relax = terrs.attr("relax"); - const curveTypes = {0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep"}; + const curveTypes = { 0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep" }; const curve = curveTypes[terrs.attr("curve")] || "curveBasisClosed"; terrs @@ -882,7 +882,7 @@ export function resolveVersionConflicts(mapVersion) { const secondCellId = points[1][2]; const feature = pack.cells.f[secondCellId]; - pack.routes.push({i: pack.routes.length, group, feature, points}); + pack.routes.push({ i: pack.routes.length, group, feature, points }); } } routes.selectAll("path").remove(); @@ -914,7 +914,7 @@ export function resolveVersionConflicts(mapVersion) { const type = this.dataset.type; const color = this.getAttribute("fill"); const cells = this.dataset.cells.split(",").map(Number); - pack.zones.push({i, name, type, cells, color}); + pack.zones.push({ i, name, type, cells, color }); }); zones.style("display", null).selectAll("*").remove(); if (layerIsOn("toggleZones")) drawZones(); @@ -975,7 +975,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.109.0")) { // v1.109.0 added customizable burg groups and icons - options.burgs = {groups: []}; + options.burgs = { groups: [] }; burgIcons.selectAll("circle, use").each(function () { const group = this.parentNode.id; @@ -987,7 +987,7 @@ export function resolveVersionConflicts(mapVersion) { burgIcons.selectAll("g").each(function (_el, index) { const name = this.id; const isDefault = name === "towns"; - options.burgs.groups.push({name, active: true, order: index + 1, isDefault, preview: "watabou-city"}); + options.burgs.groups.push({ name, active: true, order: index + 1, isDefault, preview: "watabou-city" }); if (!this.dataset.icon) this.dataset.icon = "#icon-circle"; const size = Number(this.getAttribute("size") || 2) * 2; @@ -1036,4 +1036,74 @@ export function resolveVersionConflicts(mapVersion) { delete options.showMFCGMap; delete options.villageMaxPopulation; } + + if (isOlderThan("1.111.0")) { + // v1.111.0 moved ice data from SVG to data model + // Migrate old ice SVG elements to new pack.ice structure + if (!pack.ice.length) { + pack.ice = []; + let iceId = 0; + + const iceLayer = document.getElementById("ice"); + if (iceLayer) { + // Migrate glaciers (type="iceShield") + iceLayer.querySelectorAll("polygon[type='iceShield']").forEach(polygon => { + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "glacier" + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Migrate icebergs + iceLayer.querySelectorAll("polygon:not([type])").forEach(polygon => { + const cellId = +polygon.getAttribute("cell"); + const size = +polygon.getAttribute("size"); + + // points string must exist, cell attribute must be present, and size must be non-zero + if (polygon.getAttribute("cell") === null || !size) return; + + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "iceberg", + cellId, + size + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Clear old SVG elements + iceLayer.querySelectorAll("*").forEach(el => el.remove()); + } else { + // If ice layer element doesn't exist, create it + ice = viewbox.insert("g", "#coastline").attr("id", "ice"); + ice + .attr("opacity", null) + .attr("fill", "#e8f0f6") + .attr("stroke", "#e8f0f6") + .attr("stroke-width", 1) + .attr("filter", "url(#dropShadow05)"); + } + + // Re-render ice from migrated data + if (layerIsOn("toggleIce")) drawIce(); + } + + } } diff --git a/public/modules/ice.js b/public/modules/ice.js new file mode 100644 index 00000000..90c7c3e6 --- /dev/null +++ b/public/modules/ice.js @@ -0,0 +1,170 @@ +"use strict"; + +// Ice layer data model - separates ice data from SVG rendering +window.Ice = (function () { + + // Find next available id for new ice element idealy filling gaps + function getNextId() { + if (pack.ice.length === 0) return 0; + // find gaps in existing ids + const existingIds = pack.ice.map(e => e.i).sort((a, b) => a - b); + for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { + if (!existingIds.includes(id)) return id; + } + return existingIds[existingIds.length - 1] + 1; + } + + // Generate glaciers and icebergs based on temperature and height + function generate() { + clear(); + const { cells, features } = grid; + const { temp, h } = cells; + Math.random = aleaPRNG(seed); + + const ICEBERG_MAX_TEMP = 0; + const GLACIER_MAX_TEMP = -8; + const minMaxTemp = d3.min(temp); + + // Generate glaciers on cold land + { + const type = "iceShield"; + const getType = cellId => + h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null; + const isolines = getIsolines(grid, getType, { polygons: true }); + + if (isolines[type]?.polygons) { + isolines[type].polygons.forEach(points => { + const clipped = clipPoly(points); + pack.ice.push({ + i: getNextId(), + points: clipped, + type: "glacier" + }); + }); + } + } + + // Generate icebergs on cold water + for (const cellId of grid.cells.i) { + const t = temp[cellId]; + if (h[cellId] >= 20) continue; // no icebergs on land + if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs + if (features[cells.f[cellId]].type === "lake") continue; // no icebergs on lakes + if (P(0.8)) continue; // skip most of eligible cells + + const randomFactor = 0.8 + rand() * 0.4; // random size factor + let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero, 1 = full + if (cells.t[cellId] === -1) baseSize /= 1.3; // coastline: smaller icebergs + const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); + + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + + pack.ice.push({ + i: getNextId(), + points, + type: "iceberg", + cellId, + size + }); + } + } + + function addIceberg(cellId, size) { + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + const id = getNextId(); + pack.ice.push({ + i: id, + points, + type: "iceberg", + cellId, + size + }); + redrawIceberg(id); + } + + function removeIce(id) { + const index = pack.ice.findIndex(element => element.i === id); + if (index !== -1) { + const type = pack.ice.find(element => element.i === id).type; + pack.ice.splice(index, 1); + if (type === "glacier") { + redrawGlacier(id); + } else { + redrawIceberg(id); + } + + } + } + + function updateIceberg(id, points, size) { + const iceberg = pack.ice.find(element => element.i === id); + if (iceberg) { + iceberg.points = points; + iceberg.size = size; + } + } + + function randomizeIcebergShape(id) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const size = iceberg.size; + const [cx, cy] = grid.points[cellId]; + + // Get a different random cell for the polygon template + const i = ra(grid.cells.i); + const cn = grid.points[i]; + const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); + const points = poly.map(p => [ + rn(cx + p[0] * size, 2), + rn(cy + p[1] * size, 2) + ]); + + iceberg.points = points; + } + + function changeIcebergSize(id, newSize) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const [cx, cy] = grid.points[cellId]; + const oldSize = iceberg.size; + + const flat = iceberg.points.flat(); + const pairs = []; + while (flat.length) pairs.push(flat.splice(0, 2)); + const poly = pairs.map(p => [(p[0] - cx) / oldSize, (p[1] - cy) / oldSize]); + const points = poly.map(p => [ + rn(cx + p[0] * newSize, 2), + rn(cy + p[1] * newSize, 2) + ]); + + iceberg.points = points; + iceberg.size = newSize; + } + + // Clear all ice + function clear() { + pack.ice = []; + } + + return { + generate, + addIceberg, + removeIce, + updateIceberg, + randomizeIcebergShape, + changeIcebergSize, + clear + }; +})(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 689757b2..9b401733 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -406,6 +406,7 @@ async function parseLoadedData(data, mapVersion) { pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length); // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; + pack.ice = data[39] ? JSON.parse(data[39]) : []; if (data[31]) { const namesDL = data[31].split("/"); @@ -449,7 +450,7 @@ async function parseLoadedData(data, mapVersion) { if (isVisible(routes) && hasChild(routes, "path")) turnOn("toggleRoutes"); if (hasChildren(temperature)) turnOn("toggleTemperature"); if (hasChild(population, "line")) turnOn("togglePopulation"); - if (hasChildren(ice)) turnOn("toggleIce"); + if (isVisible(ice)) turnOn("toggleIce"); if (hasChild(prec, "circle")) turnOn("togglePrecipitation"); if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems"); if (isVisible(labels)) turnOn("toggleLabels"); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 304fef59..25cd7493 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -32,12 +32,13 @@ async function saveMap(method) { $(this).dialog("close"); } }, - position: {my: "center", at: "center", of: "svg"} + position: { my: "center", at: "center", of: "svg" } }); } } function prepareMapData() { + const date = new Date(); const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator"; @@ -89,8 +90,8 @@ function prepareMapData() { const serializedSVG = new XMLSerializer().serializeToString(cloneEl); - const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid; - const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired}); + const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid; + const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired }); const packFeatures = JSON.stringify(pack.features); const cultures = JSON.stringify(pack.cultures); const states = JSON.stringify(pack.states); @@ -102,6 +103,7 @@ function prepareMapData() { const cellRoutes = JSON.stringify(pack.cells.routes); const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); + const ice = JSON.stringify(pack.ice); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -155,21 +157,22 @@ function prepareMapData() { markers, cellRoutes, routes, - zones + zones, + ice ].join("\r\n"); return mapData; } // save map file to indexedDB async function saveToStorage(mapData, showTip = false) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); await ldb.set("lastMap", blob); showTip && tip("Map is saved to the browser storage", false, "success"); } // download map file function saveToMachine(mapData, filename) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); const URL = window.URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/public/modules/renderers/draw-ice.js b/public/modules/renderers/draw-ice.js new file mode 100644 index 00000000..4b35f75c --- /dev/null +++ b/public/modules/renderers/draw-ice.js @@ -0,0 +1,70 @@ +"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/ui/editors.js b/public/modules/ui/editors.js index 77c391ee..50eaf1c7 100644 --- a/public/modules/ui/editors.js +++ b/public/modules/ui/editors.js @@ -26,7 +26,7 @@ function clicked() { else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel(); else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); - else if (parent.id === "ice") editIce(); + else if (parent.id === "ice") editIce(el); else if (parent.id === "terrain") editReliefIcon(); else if (grand.id === "markers" || great.id === "markers") editMarker(); else if (grand.id === "coastline") editCoastline(); diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index d655e39d..5c3f1fc3 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -259,6 +259,8 @@ function editHeightmap(options) { Rivers.specify(); Lakes.defineNames(); + Ice.generate(); + Military.generate(); Markers.generate(); Zones.generate(); @@ -465,6 +467,10 @@ function editHeightmap(options) { .attr("id", d => base + d); }); + // recalculate ice + Ice.generate(); + ice.selectAll("*").remove(); + TIME && console.timeEnd("restoreRiskedData"); INFO && console.groupEnd("Edit Heightmap"); } diff --git a/public/modules/ui/ice-editor.js b/public/modules/ui/ice-editor.js index a9e6ff28..16818b4c 100644 --- a/public/modules/ui/ice-editor.js +++ b/public/modules/ui/ice-editor.js @@ -1,26 +1,32 @@ "use strict"; -function editIce() { +function editIce(element) { if (customization) return; + if (elSelected && element === elSelected.node()) return; + closeDialogs(".stable"); if (!layerIsOn("toggleIce")) toggleIce(); elSelected = d3.select(d3.event.target); - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; - document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block"; - document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block"; - if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size"); + const id = +elSelected.attr("data-id"); + const iceElement = pack.ice.find(el => el.i === id); + const isGlacier = elSelected.attr("type") === "glacier"; + const type = isGlacier ? "Glacier" : "Iceberg"; + + document.getElementById("iceRandomize").style.display = isGlacier ? "none" : "inline-block"; + document.getElementById("iceSize").style.display = isGlacier ? "none" : "inline-block"; + if (!isGlacier) document.getElementById("iceSize").value = iceElement?.size || ""; + ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement)); $("#iceEditor").dialog({ title: "Edit " + type, resizable: false, - position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"}, + position: { my: "center top+60", at: "top", of: d3.event, collision: "fit" }, close: closeEditor }); if (modules.editIce) return; modules.editIce = true; - // add listeners document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice")); document.getElementById("iceRandomize").addEventListener("click", randomizeShape); @@ -28,29 +34,18 @@ function editIce() { document.getElementById("iceNew").addEventListener("click", toggleAdd); document.getElementById("iceRemove").addEventListener("click", removeIce); + function randomizeShape() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const i = ra(grid.cells.i), - cn = grid.points[i]; - const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); - const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]); - elSelected.attr("points", points); + const selectedId = +elSelected.attr("data-id"); + Ice.randomizeIcebergShape(selectedId); + redrawIceberg(selectedId); } function changeSize() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const flat = elSelected - .attr("points") - .split(",") - .map(el => +el); - const pairs = []; - while (flat.length) pairs.push(flat.splice(0, 2)); - const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]); - const size = +this.value; - const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]); - elSelected.attr("points", points).attr("size", size); + const newSize = +this.value; + const selectedId = +elSelected.attr("data-id"); + Ice.changeIcebergSize(selectedId, newSize); + redrawIceberg(selectedId); } function toggleAdd() { @@ -67,17 +62,15 @@ function editIce() { function addIcebergOnClick() { const [x, y] = d3.mouse(this); const i = findGridCell(x, y, grid); - const [cx, cy] = grid.points[i]; const size = +document.getElementById("iceSize")?.value || 1; - const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size); - iceberg.call(d3.drag().on("drag", dragElement)); + Ice.addIceberg(i, size); + if (d3.event.shiftKey === false) toggleAdd(); } function removeIce() { - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; + const type = elSelected.attr("type") === "glacier" ? "Glacier" : "Iceberg"; alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`; $("#alert").dialog({ resizable: false, @@ -85,7 +78,7 @@ function editIce() { buttons: { Remove: function () { $(this).dialog("close"); - elSelected.remove(); + Ice.removeIce(+elSelected.attr("data-id")); $("#iceEditor").dialog("close"); }, Cancel: function () { @@ -96,14 +89,24 @@ function editIce() { } function dragElement() { - const tr = parseTransform(this.getAttribute("transform")); - const dx = +tr[0] - d3.event.x, - dy = +tr[1] - d3.event.y; + const selectedId = +elSelected.attr("data-id"); + const initialTransform = parseTransform(this.getAttribute("transform")); + const dx = initialTransform[0] - d3.event.x; + const dy = initialTransform[1] - d3.event.y; d3.event.on("drag", function () { - const x = d3.event.x, - y = d3.event.y; - this.setAttribute("transform", `translate(${dx + x},${dy + y})`); + const x = d3.event.x; + const y = d3.event.y; + const transform = `translate(${dx + x},${dy + y})`; + this.setAttribute("transform", transform); + + // Update data model with new position + const offset = [dx + x, dy + y]; + const iceData = pack.ice.find(element => element.i === selectedId); + if (iceData) { + // Store offset for visual positioning, actual geometry stays in points + iceData.offset = offset; + } }); } @@ -114,3 +117,4 @@ function editIce() { unselect(); } } + diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 5037a5ee..f2f04a4b 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -417,49 +417,6 @@ function toggleIce(event) { } } -function drawIce() { - TIME && console.time("drawIce"); - - const {cells, features} = grid; - const {temp, h} = cells; - Math.random = aleaPRNG(seed); - - const ICEBERG_MAX_TEMP = 0; - const GLACIER_MAX_TEMP = -8; - const minMaxTemp = d3.min(temp); - - // cold land: draw glaciers - { - const type = "iceShield"; - const getType = cellId => (h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null); - const isolines = getIsolines(grid, getType, {polygons: true}); - isolines[type]?.polygons?.forEach(points => { - const clipped = clipPoly(points); - ice.append("polygon").attr("points", clipped).attr("type", type); - }); - } - - // cold water: draw icebergs - for (const cellId of grid.cells.i) { - const t = temp[cellId]; - if (h[cellId] >= 20) continue; // no icebergs on land - if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs - if (features[cells.f[cellId]].type === "lake") continue; // no icebers on lakes - if (P(0.8)) continue; // skip most of eligible cells - - const randomFactor = 0.8 + rand() * 0.4; // random size factor - let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero size, 1 = full size - if (cells.t[cellId] === -1) baseSize /= 1.3; // coasline: smaller icebergs - const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); - - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - ice.append("polygon").attr("points", points).attr("cell", cellId).attr("size", size); - } - - TIME && console.timeEnd("drawIce"); -} - function toggleCultures(event) { const cultures = pack.cultures.filter(c => c.i && !c.removed); const empty = !cults.selectAll("path").size(); diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index a3df5c00..eade993f 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -555,7 +555,7 @@ function regenerateMilitary() { function regenerateIce() { if (!layerIsOn("toggleIce")) toggleIce(); - ice.selectAll("*").remove(); + Ice.generate(); drawIce(); } diff --git a/public/versioning.js b/public/versioning.js index 11fcde66..8069c818 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.110.0"; +const VERSION = "1.111.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index 21d84187..3fd7ba17 100644 --- a/src/index.html +++ b/src/index.html @@ -8498,6 +8498,7 @@ + @@ -8515,16 +8516,16 @@ - + - + - - + + @@ -8535,7 +8536,7 @@ - + @@ -8549,12 +8550,12 @@ - - - + + + - + @@ -8566,8 +8567,8 @@ - - + + @@ -8583,5 +8584,6 @@ + From 70ed9aec56445a5bf7053bf40901871feab6302e Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 23 Jan 2026 13:47:13 +0100 Subject: [PATCH 06/24] fix: bind HeightmapGenerator methods for correct context in editHeightmap (#1281) --- public/modules/ui/heightmap-editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index 5c3f1fc3..6e76c4bb 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -675,7 +675,7 @@ function editHeightmap(options) { if (power === 0) return tip("Power should not be zero", false, "error"); const heights = grid.cells.h; - const operation = power > 0 ? HeightmapGenerator.addRange : HeightmapGenerator.addTrough; + const operation = power > 0 ? HeightmapGenerator.addRange.bind(HeightmapGenerator) : HeightmapGenerator.addTrough.bind(HeightmapGenerator); HeightmapGenerator.setGraph(grid); operation("1", String(Math.abs(power)), null, null, fromCell, toCell); const changedHeights = HeightmapGenerator.getHeights(); From c590c168f4b41d8e37e09d064288d5d0b77ac28f Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 23 Jan 2026 13:47:35 +0100 Subject: [PATCH 07/24] fix: update colorUtils and probabilityUtils to use seeded randomness (#1280) --- src/utils/colorUtils.ts | 5 ++++- src/utils/probabilityUtils.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 047d6eae..e64636fc 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -1,4 +1,4 @@ -import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffle } from "d3"; +import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffler } from "d3"; /** * Convert RGB or RGBA color to HEX @@ -35,11 +35,14 @@ export const C_12 = [ /** * Get an array of distinct colors + * Uses shuffler with current Math.random to ensure seeded randomness works * @param {number} count - The count of colors to generate * @returns {string[]} - The array of HEX color strings */ export const getColors = (count: number): string[] => { const scaleRainbow = scaleSequential(interpolateRainbow); + // Use shuffler() to create a shuffle function that uses the current Math.random + const shuffle = shuffler(() => Math.random()); const colors = shuffle( range(count).map(i => (i < 12 ? C_12[i] : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex())) ); diff --git a/src/utils/probabilityUtils.ts b/src/utils/probabilityUtils.ts index 4f6fd66a..a526c981 100644 --- a/src/utils/probabilityUtils.ts +++ b/src/utils/probabilityUtils.ts @@ -38,6 +38,7 @@ export const each = (n: number) => { /** * Random Gaussian number generator + * Uses randomNormal.source(Math.random) to ensure it uses the current PRNG * @param {number} expected - expected value * @param {number} deviation - standard deviation * @param {number} min - minimum value @@ -46,7 +47,8 @@ export const each = (n: number) => { * @return {number} random number */ export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => { - return rn(minmax(randomNormal(expected, deviation)(), min, max), round); + // Use .source() to get a version that uses the current Math.random (which may be seeded) + return rn(minmax(randomNormal.source(() => Math.random())(expected, deviation)(), min, max), round); } /** From 9903f0b9aa50fdd2a3664ad99838b10a539d7843 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 23 Jan 2026 16:50:21 +0100 Subject: [PATCH 08/24] Test/add e2e and unit testing (#1282) * feat: add string utility tests and vitest browser configuration * feat: add Playwright for end-to-end testing and update snapshots - Introduced Playwright for E2E testing with a new configuration file. - Added test scripts to package.json for running E2E tests. - Updated package-lock.json and package.json with new dependencies for Playwright and types. - Created new SVG snapshot files for various layers (ruler, scaleBar, temperature, terrain, vignette, zones) to support visual testing. - Excluded e2e directory from TypeScript compilation. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add SVG layer snapshots for various components - Added ruler layer snapshot with hidden display. - Added scale bar layer snapshot with detailed structure and styling. - Added temperature layer snapshot with opacity and stroke settings. - Added terrain layer snapshot with ocean and land heights groups. - Added vignette layer snapshot with mask and opacity settings. - Added zones layer snapshot with specified opacity and stroke settings. * fix: update Playwright browser installation command to use npx * Update snapshots * refactor: remove unused layer tests and their corresponding snapshots as fonts are unpredictable --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/playwright.yml | 25 + .gitignore | 3 + package-lock.json | 592 +++++++++++++++++- package.json | 13 +- playwright.config.ts | 34 + public/main.js | 4 + src/utils/stringUtils.test.ts | 8 + tests/e2e/layers.spec.ts | 241 +++++++ .../e2e/layers.spec.ts-snapshots/anchors.html | 1 + .../e2e/layers.spec.ts-snapshots/armies.html | 1 + .../e2e/layers.spec.ts-snapshots/biomes.html | 1 + .../e2e/layers.spec.ts-snapshots/borders.html | 1 + tests/e2e/layers.spec.ts-snapshots/cells.html | 1 + .../layers.spec.ts-snapshots/coastline.html | 1 + .../e2e/layers.spec.ts-snapshots/compass.html | 1 + .../layers.spec.ts-snapshots/coordinates.html | 1 + .../layers.spec.ts-snapshots/cultures.html | 1 + .../e2e/layers.spec.ts-snapshots/emblems.html | 1 + tests/e2e/layers.spec.ts-snapshots/ice.html | 1 + tests/e2e/layers.spec.ts-snapshots/icons.html | 1 + .../e2e/layers.spec.ts-snapshots/labels.html | 1 + tests/e2e/layers.spec.ts-snapshots/lakes.html | 1 + .../layers.spec.ts-snapshots/landmass.html | 1 + .../e2e/layers.spec.ts-snapshots/markers.html | 1 + tests/e2e/layers.spec.ts-snapshots/ocean.html | 1 + .../layers.spec.ts-snapshots/population.html | 1 + .../precipitation.html | 1 + .../layers.spec.ts-snapshots/provinces.html | 1 + .../e2e/layers.spec.ts-snapshots/regions.html | 1 + .../e2e/layers.spec.ts-snapshots/relief.html | 1 + .../layers.spec.ts-snapshots/religions.html | 1 + .../e2e/layers.spec.ts-snapshots/rivers.html | 1 + .../e2e/layers.spec.ts-snapshots/routes.html | 1 + .../layers.spec.ts-snapshots/temperature.html | 1 + .../e2e/layers.spec.ts-snapshots/terrain.html | 1 + tests/e2e/layers.spec.ts-snapshots/zones.html | 1 + tsconfig.json | 3 +- vitest.browser.config.ts | 18 + 38 files changed, 963 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.ts create mode 100644 src/utils/stringUtils.test.ts create mode 100644 tests/e2e/layers.spec.ts create mode 100644 tests/e2e/layers.spec.ts-snapshots/anchors.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/armies.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/biomes.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/borders.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/cells.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/coastline.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/compass.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/coordinates.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/cultures.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/emblems.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/ice.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/icons.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/labels.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/lakes.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/landmass.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/markers.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/ocean.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/population.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/precipitation.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/provinces.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/regions.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/relief.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/religions.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/rivers.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/routes.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/temperature.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/terrain.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/zones.html create mode 100644 vitest.browser.config.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..b41b4ac2 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,25 @@ +name: Playwright Tests +on: + pull_request: + branches: [ master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 \ No newline at end of file diff --git a/.gitignore b/.gitignore index b0a273f0..c730ec13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .vscode .idea /node_modules +*/node_modules /dist /coverage +/playwright-report +/test-results \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cafbec00..67512031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fantasy-map-generator", - "version": "1.109.5", + "version": "1.110.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fantasy-map-generator", - "version": "1.109.5", + "version": "1.110.0", "license": "MIT", "dependencies": { "alea": "^1.0.1", @@ -15,11 +15,17 @@ "polylabel": "^2.0.1" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", + "@types/node": "^25.0.10", "@types/polylabel": "^1.1.3", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "playwright": "^1.57.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "engines": { "node": ">=24.0.0" @@ -467,6 +473,36 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -817,6 +853,24 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -1101,6 +1155,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/delaunator": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/delaunator/-/delaunator-5.0.3.tgz", @@ -1122,6 +1183,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/polylabel": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz", @@ -1129,12 +1201,191 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/browser": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", + "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.18", + "@vitest/utils": "4.0.18", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz", + "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/browser": "4.0.18", + "@vitest/mocker": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/alea": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/alea/-/alea-1.0.1.tgz", "integrity": "sha512-QU+wv+ziDXaMxRdsQg/aH7sVfWdhKps5YP97IIwFkHCsbDZA3k87JXoZ5/iuemf4ntytzIWeScrRpae8+lDrXA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -1555,6 +1806,13 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1597,6 +1855,26 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1651,6 +1929,26 @@ "node": ">=12" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1670,6 +1968,24 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1691,6 +2007,77 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/polylabel": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz", @@ -1792,6 +2179,28 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1802,6 +2211,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1825,6 +2265,26 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1839,12 +2299,20 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -1913,6 +2381,124 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 7a17e01b..9d3fbe11 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,23 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:browser": "vitest --config=vitest.browser.config.ts", + "test:e2e": "playwright test" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", + "@types/node": "^25.0.10", "@types/polylabel": "^1.1.3", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "playwright": "^1.57.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "dependencies": { "alea": "^1.0.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..86348c64 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test' + +const isCI = !!process.env.CI + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + reporter: 'html', + // Use OS-independent snapshot names (HTML content is the same across platforms) + snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', + use: { + baseURL: isCI ? 'http://localhost:4173' : 'http://localhost:5173', + trace: 'on-first-retry', + // Fixed viewport to ensure consistent map rendering + viewport: { width: 1280, height: 720 }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + // In CI: build and preview for production-like testing + // In dev: use vite dev server (faster, no rebuild needed) + command: isCI ? 'npm run build && npm run preview' : 'npm run dev', + url: isCI ? 'http://localhost:4173' : 'http://localhost:5173', + reuseExistingServer: !isCI, + timeout: 120000, + }, +}) diff --git a/public/main.js b/public/main.js index 7dbb9585..e922c44e 100644 --- a/public/main.js +++ b/public/main.js @@ -1229,8 +1229,12 @@ function showStatistics() { Cultures: ${pack.cultures.length - 1}`; mapId = Date.now(); // unique map id is it's creation date number + window.mapId = mapId; // expose for test automation mapHistory.push({seed, width: graphWidth, height: graphHeight, template: heightmap, created: mapId}); INFO && console.info(stats); + + // Dispatch event for test automation and external integrations + window.dispatchEvent(new CustomEvent('map:generated', { detail: { seed, mapId } })); } const regenerateMap = debounce(async function (options) { diff --git a/src/utils/stringUtils.test.ts b/src/utils/stringUtils.test.ts new file mode 100644 index 00000000..10da484f --- /dev/null +++ b/src/utils/stringUtils.test.ts @@ -0,0 +1,8 @@ +import { expect, describe, it } from 'vitest' +import { round } from './stringUtils' + +describe('round', () => { + it('should be able to handle undefined input', () => { + expect(round(undefined)).toBe(""); + }); +}) \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts b/tests/e2e/layers.spec.ts new file mode 100644 index 00000000..458ded73 --- /dev/null +++ b/tests/e2e/layers.spec.ts @@ -0,0 +1,241 @@ +import { test, expect } from '@playwright/test' + +test.describe('map layers', () => { + test.beforeEach(async ({ context, page }) => { + // Clear all storage to ensure clean state + await context.clearCookies() + + await page.goto('/') + await page.evaluate(() => { + localStorage.clear() + sessionStorage.clear() + }) + + // Navigate with seed parameter and wait for full load + // NOTE: + // - We use a fixed seed ("test-seed") to make map generation deterministic for snapshot tests. + // - Snapshots are OS-independent (configured in playwright.config.ts). + await page.goto('/?seed=test-seed&&width=1280&height=720') + + // Wait for map generation to complete by checking window.mapId + // mapId is exposed on window at the very end of showStatistics() + await page.waitForFunction(() => (window as any).mapId !== undefined, { timeout: 60000 }) + + // Additional wait for any rendering/animations to settle + await page.waitForTimeout(500) + }) + + // Ocean and water layers + test('ocean layer', async ({ page }) => { + const ocean = page.locator('#ocean') + await expect(ocean).toBeAttached() + const html = await ocean.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('ocean.html') + }) + + test('lakes layer', async ({ page }) => { + const lakes = page.locator('#lakes') + await expect(lakes).toBeAttached() + const html = await lakes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('lakes.html') + }) + + test('coastline layer', async ({ page }) => { + const coastline = page.locator('#coastline') + await expect(coastline).toBeAttached() + const html = await coastline.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('coastline.html') + }) + + // Terrain and heightmap layers + test('terrain layer', async ({ page }) => { + const terrs = page.locator('#terrs') + await expect(terrs).toBeAttached() + const html = await terrs.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('terrain.html') + }) + + test('landmass layer', async ({ page }) => { + const landmass = page.locator('#landmass') + await expect(landmass).toBeAttached() + const html = await landmass.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('landmass.html') + }) + + // Climate and environment layers + test('biomes layer', async ({ page }) => { + const biomes = page.locator('#biomes') + await expect(biomes).toBeAttached() + const html = await biomes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('biomes.html') + }) + + test('ice layer', async ({ page }) => { + const ice = page.locator('#ice') + await expect(ice).toBeAttached() + const html = await ice.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('ice.html') + }) + + test('temperature layer', async ({ page }) => { + const temperature = page.locator('#temperature') + await expect(temperature).toBeAttached() + const html = await temperature.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('temperature.html') + }) + + test('precipitation layer', async ({ page }) => { + const prec = page.locator('#prec') + await expect(prec).toBeAttached() + const html = await prec.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('precipitation.html') + }) + + // Geographic features + test('rivers layer', async ({ page }) => { + const rivers = page.locator('#rivers') + await expect(rivers).toBeAttached() + const html = await rivers.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('rivers.html') + }) + + test('relief layer', async ({ page }) => { + const terrain = page.locator('#terrain') + await expect(terrain).toBeAttached() + const html = await terrain.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('relief.html') + }) + + // Political layers + test('states/regions layer', async ({ page }) => { + const regions = page.locator('#regions') + await expect(regions).toBeAttached() + const html = await regions.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('regions.html') + }) + + test('provinces layer', async ({ page }) => { + const provs = page.locator('#provs') + await expect(provs).toBeAttached() + const html = await provs.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('provinces.html') + }) + + test('borders layer', async ({ page }) => { + const borders = page.locator('#borders') + await expect(borders).toBeAttached() + const html = await borders.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('borders.html') + }) + + // Cultural layers + test('cultures layer', async ({ page }) => { + const cults = page.locator('#cults') + await expect(cults).toBeAttached() + const html = await cults.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('cultures.html') + }) + + test('religions layer', async ({ page }) => { + const relig = page.locator('#relig') + await expect(relig).toBeAttached() + const html = await relig.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('religions.html') + }) + + // Infrastructure layers + test('routes layer', async ({ page }) => { + const routes = page.locator('#routes') + await expect(routes).toBeAttached() + const html = await routes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('routes.html') + }) + + // Settlement layers + test('burgs/icons layer', async ({ page }) => { + const icons = page.locator('#icons') + await expect(icons).toBeAttached() + const html = await icons.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('icons.html') + }) + + test('anchors layer', async ({ page }) => { + const anchors = page.locator('#anchors') + await expect(anchors).toBeAttached() + const html = await anchors.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('anchors.html') + }) + + // Labels layer (without text content due to font rendering) + test('labels layer', async ({ page }) => { + const labels = page.locator('#labels') + await expect(labels).toBeAttached() + // Remove text content but keep structure (text rendering varies) + const html = await labels.evaluate((el) => { + const clone = el.cloneNode(true) as Element + clone.querySelectorAll('text, tspan').forEach((t) => t.remove()) + return clone.outerHTML + }) + expect(html).toMatchSnapshot('labels.html') + }) + + // Military and markers + test('markers layer', async ({ page }) => { + const markers = page.locator('#markers') + await expect(markers).toBeAttached() + const html = await markers.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('markers.html') + }) + + test('armies layer', async ({ page }) => { + const armies = page.locator('#armies') + await expect(armies).toBeAttached() + const html = await armies.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('armies.html') + }) + + // Special features + test('zones layer', async ({ page }) => { + const zones = page.locator('#zones') + await expect(zones).toBeAttached() + const html = await zones.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('zones.html') + }) + + test('emblems layer', async ({ page }) => { + const emblems = page.locator('#emblems') + await expect(emblems).toBeAttached() + const html = await emblems.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('emblems.html') + }) + + // Grid and coordinates + test('cells layer', async ({ page }) => { + const cells = page.locator('g#cells') + await expect(cells).toBeAttached() + const html = await cells.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('cells.html') + }) + + test('coordinates layer', async ({ page }) => { + const coordinates = page.locator('#coordinates') + await expect(coordinates).toBeAttached() + const html = await coordinates.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('coordinates.html') + }) + + test('compass layer', async ({ page }) => { + const compass = page.locator('#compass') + await expect(compass).toBeAttached() + const html = await compass.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('compass.html') + }) + + // Population layer + test('population layer', async ({ page }) => { + const population = page.locator('#population') + await expect(population).toBeAttached() + const html = await population.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('population.html') + }) +}) diff --git a/tests/e2e/layers.spec.ts-snapshots/anchors.html b/tests/e2e/layers.spec.ts-snapshots/anchors.html new file mode 100644 index 00000000..3037abb5 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/anchors.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/armies.html b/tests/e2e/layers.spec.ts-snapshots/armies.html new file mode 100644 index 00000000..face6396 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/armies.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/biomes.html b/tests/e2e/layers.spec.ts-snapshots/biomes.html new file mode 100644 index 00000000..582a9c1d --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/biomes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/borders.html b/tests/e2e/layers.spec.ts-snapshots/borders.html new file mode 100644 index 00000000..6e5c5003 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/borders.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/cells.html b/tests/e2e/layers.spec.ts-snapshots/cells.html new file mode 100644 index 00000000..d73d9b2f --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/cells.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/coastline.html b/tests/e2e/layers.spec.ts-snapshots/coastline.html new file mode 100644 index 00000000..7a2c4c51 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/coastline.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/compass.html b/tests/e2e/layers.spec.ts-snapshots/compass.html new file mode 100644 index 00000000..3c0892a6 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/compass.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/coordinates.html b/tests/e2e/layers.spec.ts-snapshots/coordinates.html new file mode 100644 index 00000000..48e6c40b --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/coordinates.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/cultures.html b/tests/e2e/layers.spec.ts-snapshots/cultures.html new file mode 100644 index 00000000..193726a3 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/cultures.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/emblems.html b/tests/e2e/layers.spec.ts-snapshots/emblems.html new file mode 100644 index 00000000..1de7ef9d --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/emblems.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/ice.html b/tests/e2e/layers.spec.ts-snapshots/ice.html new file mode 100644 index 00000000..1729b6ff --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/ice.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/icons.html b/tests/e2e/layers.spec.ts-snapshots/icons.html new file mode 100644 index 00000000..c759dc38 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/icons.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/labels.html b/tests/e2e/layers.spec.ts-snapshots/labels.html new file mode 100644 index 00000000..6ffcf3b9 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/labels.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/lakes.html b/tests/e2e/layers.spec.ts-snapshots/lakes.html new file mode 100644 index 00000000..cce3f70e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/lakes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/landmass.html b/tests/e2e/layers.spec.ts-snapshots/landmass.html new file mode 100644 index 00000000..ec70a34e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/landmass.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/markers.html b/tests/e2e/layers.spec.ts-snapshots/markers.html new file mode 100644 index 00000000..100a1e3f --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/markers.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/ocean.html b/tests/e2e/layers.spec.ts-snapshots/ocean.html new file mode 100644 index 00000000..b950e1a7 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/ocean.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/population.html b/tests/e2e/layers.spec.ts-snapshots/population.html new file mode 100644 index 00000000..10175492 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/population.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/precipitation.html b/tests/e2e/layers.spec.ts-snapshots/precipitation.html new file mode 100644 index 00000000..8ab517cb --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/precipitation.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/provinces.html b/tests/e2e/layers.spec.ts-snapshots/provinces.html new file mode 100644 index 00000000..3fe87d6e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/provinces.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/regions.html b/tests/e2e/layers.spec.ts-snapshots/regions.html new file mode 100644 index 00000000..86187361 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/regions.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/relief.html b/tests/e2e/layers.spec.ts-snapshots/relief.html new file mode 100644 index 00000000..6883fe5b --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/relief.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/religions.html b/tests/e2e/layers.spec.ts-snapshots/religions.html new file mode 100644 index 00000000..85c96e30 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/religions.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/rivers.html b/tests/e2e/layers.spec.ts-snapshots/rivers.html new file mode 100644 index 00000000..087b4d8d --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/rivers.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/routes.html b/tests/e2e/layers.spec.ts-snapshots/routes.html new file mode 100644 index 00000000..16e6f5ec --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/routes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/temperature.html b/tests/e2e/layers.spec.ts-snapshots/temperature.html new file mode 100644 index 00000000..36464dbd --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/temperature.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/terrain.html b/tests/e2e/layers.spec.ts-snapshots/terrain.html new file mode 100644 index 00000000..bc13f8be --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/terrain.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/zones.html b/tests/e2e/layers.spec.ts-snapshots/zones.html new file mode 100644 index 00000000..14cd5141 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/zones.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8b583a9d..01672af5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/e2e"] } \ No newline at end of file diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts new file mode 100644 index 00000000..cf8528b8 --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' +import { playwright } from '@vitest/browser-playwright' + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright(), + // https://vitest.dev/config/browser/playwright + instances: [ + { name: 'chromium', browser: 'chromium' }, + ], + locators: { + testIdAttribute: 'id', + }, + }, + }, +}) From 29bc2832e09a75e3191fe20e54d8c93d940cbcf6 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Mon, 26 Jan 2026 17:07:54 +0100 Subject: [PATCH 09/24] Refactor/migrate first modules (#1273) * chore: add npm + vite for progressive enhancement * fix: update Dockerfile to copy only the dist folder contents * fix: update Dockerfile to use multi-stage build for optimized production image * fix: correct nginx config file copy command in Dockerfile * chore: add netlify configuration for build and redirects * fix: add NODE_VERSION to environment in Netlify configuration * remove wrong dist folder * Update package.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: split public and src * migrating all util files from js to ts * feat: Implement HeightmapGenerator and Voronoi module - Added HeightmapGenerator class for generating heightmaps with various tools (Hill, Pit, Range, Trough, Strait, etc.). - Introduced Voronoi class for creating Voronoi diagrams using Delaunator. - Updated index.html to include new modules. - Created index.ts to manage module imports. - Enhanced arrayUtils and graphUtils with type definitions and improved functionality. - Added utility functions for generating grids and calculating Voronoi cells. * chore: add GitHub Actions workflow for deploying to GitHub Pages * fix: update branch name in GitHub Actions workflow from 'main' to 'master' * chore: update package.json to specify Node.js engine version and remove unused launch.json * Initial plan * Update copilot guidelines to reflect NPM/Vite/TypeScript migration Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com> * Update src/modules/heightmap-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/utils/graphUtils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/heightmap-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: Add TIME and ERROR variables to global scope in HeightmapGenerator * fix: Update base path in vite.config.ts for Netlify deployment * refactor: Migrate features to a new module and remove legacy script reference * refactor: Update feature interfaces and improve type safety in FeatureModule * refactor: Add documentation for markupPack and defineGroups methods in FeatureModule * refactor: Remove legacy ocean-layers.js and migrate functionality to ocean-layers.ts * refactor: Remove river-generator.js script reference and migrate river generation logic to river-generator.ts * refactor: Remove river-generator.js reference and add biomes module * refactor: Migrate lakes functionality to lakes.ts and update related interfaces * refactor: clean up global variable declarations and improve type definitions * refactor: update shoreline calculation and improve type imports in PackedGraph * fix: e2e tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Azgaar Co-authored-by: Azgaar Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com> --- package-lock.json | 1 - public/modules/features.js | 267 -------------- public/modules/ocean-layers.js | 92 ----- src/index.html | 5 - .../biomes.js => src/modules/biomes.ts | 71 ++-- src/modules/features.ts | 328 ++++++++++++++++++ src/modules/heightmap-generator.ts | 68 ++-- src/modules/index.ts | 7 +- .../modules/lakes.js => src/modules/lakes.ts | 171 ++++----- src/modules/ocean-layers.ts | 110 ++++++ .../modules/river-generator.ts | 310 +++++++++-------- src/types/PackedGraph.ts | 36 ++ src/types/global.ts | 33 ++ src/utils/commonUtils.ts | 2 +- .../e2e/layers.spec.ts-snapshots/rivers.html | 2 +- 15 files changed, 826 insertions(+), 677 deletions(-) delete mode 100644 public/modules/features.js delete mode 100644 public/modules/ocean-layers.js rename public/modules/biomes.js => src/modules/biomes.ts (64%) create mode 100644 src/modules/features.ts rename public/modules/lakes.js => src/modules/lakes.ts (67%) create mode 100644 src/modules/ocean-layers.ts rename public/modules/river-generator.js => src/modules/river-generator.ts (63%) create mode 100644 src/types/PackedGraph.ts create mode 100644 src/types/global.ts diff --git a/package-lock.json b/package-lock.json index 67512031..55b1e80f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1999,7 +1999,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/public/modules/features.js b/public/modules/features.js deleted file mode 100644 index 714d4f38..00000000 --- a/public/modules/features.js +++ /dev/null @@ -1,267 +0,0 @@ -"use strict"; - -window.Features = (function () { - const DEEPER_LAND = 3; - const LANDLOCKED = 2; - const LAND_COAST = 1; - const UNMARKED = 0; - const WATER_COAST = -1; - const DEEP_WATER = -2; - - // calculate distance to coast for every cell - function markup({distanceField, neighbors, start, increment, limit = INT8_MAX}) { - for (let distance = start, marked = Infinity; marked > 0 && distance !== limit; distance += increment) { - marked = 0; - const prevDistance = distance - increment; - for (let cellId = 0; cellId < neighbors.length; cellId++) { - if (distanceField[cellId] !== prevDistance) continue; - - for (const neighborId of neighbors[cellId]) { - if (distanceField[neighborId] !== UNMARKED) continue; - distanceField[neighborId] = distance; - marked++; - } - } - } - } - - // mark Grid features (ocean, lakes, islands) and calculate distance field - function markupGrid() { - TIME && console.time("markupGrid"); - Math.random = aleaPRNG(seed); // get the same result on heightmap edit in Erase mode - - const {h: heights, c: neighbors, b: borderCells, i} = grid.cells; - const cellsNumber = i.length; - const distanceField = new Int8Array(cellsNumber); // gird.cells.t - const featureIds = new Uint16Array(cellsNumber); // gird.cells.f - const features = [0]; - - const queue = [0]; - for (let featureId = 1; queue[0] !== -1; featureId++) { - const firstCell = queue[0]; - featureIds[firstCell] = featureId; - - const land = heights[firstCell] >= 20; - let border = false; // set true if feature touches map edge - - while (queue.length) { - const cellId = queue.pop(); - if (!border && borderCells[cellId]) border = true; - - for (const neighborId of neighbors[cellId]) { - const isNeibLand = heights[neighborId] >= 20; - - if (land === isNeibLand && featureIds[neighborId] === UNMARKED) { - featureIds[neighborId] = featureId; - queue.push(neighborId); - } else if (land && !isNeibLand) { - distanceField[cellId] = LAND_COAST; - distanceField[neighborId] = WATER_COAST; - } - } - } - - const type = land ? "island" : border ? "ocean" : "lake"; - features.push({i: featureId, land, border, type}); - - queue[0] = featureIds.findIndex(f => f === UNMARKED); // find unmarked cell - } - - // markup deep ocean cells - markup({distanceField, neighbors, start: DEEP_WATER, increment: -1, limit: -10}); - - grid.cells.t = distanceField; - grid.cells.f = featureIds; - grid.features = features; - - TIME && console.timeEnd("markupGrid"); - } - - // mark Pack features (ocean, lakes, islands), calculate distance field and add properties - function markupPack() { - TIME && console.time("markupPack"); - - const {cells, vertices} = pack; - const {c: neighbors, b: borderCells, i} = cells; - const packCellsNumber = i.length; - if (!packCellsNumber) return; // no cells -> there is nothing to do - - const distanceField = new Int8Array(packCellsNumber); // pack.cells.t - const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f - const haven = createTypedArray({maxValue: packCellsNumber, length: packCellsNumber}); // haven: opposite water cell - const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells - const features = [0]; - - const queue = [0]; - for (let featureId = 1; queue[0] !== -1; featureId++) { - const firstCell = queue[0]; - featureIds[firstCell] = featureId; - - const land = isLand(firstCell); - let border = Boolean(borderCells[firstCell]); // true if feature touches map border - let totalCells = 1; // count cells in a feature - - while (queue.length) { - const cellId = queue.pop(); - if (borderCells[cellId]) border = true; - if (!border && borderCells[cellId]) border = true; - - for (const neighborId of neighbors[cellId]) { - const isNeibLand = isLand(neighborId); - - if (land && !isNeibLand) { - distanceField[cellId] = LAND_COAST; - distanceField[neighborId] = WATER_COAST; - if (!haven[cellId]) defineHaven(cellId); - } else if (land && isNeibLand) { - if (distanceField[neighborId] === UNMARKED && distanceField[cellId] === LAND_COAST) - distanceField[neighborId] = LANDLOCKED; - else if (distanceField[cellId] === UNMARKED && distanceField[neighborId] === LAND_COAST) - distanceField[cellId] = LANDLOCKED; - } - - if (!featureIds[neighborId] && land === isNeibLand) { - queue.push(neighborId); - featureIds[neighborId] = featureId; - totalCells++; - } - } - } - - features.push(addFeature({firstCell, land, border, featureId, totalCells})); - queue[0] = featureIds.findIndex(f => f === UNMARKED); // find unmarked cell - } - - markup({distanceField, neighbors, start: DEEPER_LAND, increment: 1}); // markup pack land - markup({distanceField, neighbors, start: DEEP_WATER, increment: -1, limit: -10}); // markup pack water - - pack.cells.t = distanceField; - pack.cells.f = featureIds; - pack.cells.haven = haven; - pack.cells.harbor = harbor; - pack.features = features; - - TIME && console.timeEnd("markupPack"); - - function defineHaven(cellId) { - const waterCells = neighbors[cellId].filter(isWater); - const distances = waterCells.map(neibCellId => dist2(cells.p[cellId], cells.p[neibCellId])); - const closest = distances.indexOf(Math.min.apply(Math, distances)); - - haven[cellId] = waterCells[closest]; - harbor[cellId] = waterCells.length; - } - - function addFeature({firstCell, land, border, featureId, totalCells}) { - const type = land ? "island" : border ? "ocean" : "lake"; - const [startCell, featureVertices] = getCellsData(type, firstCell); - const points = clipPoly(featureVertices.map(vertex => vertices.p[vertex])); - const area = d3.polygonArea(points); // feature perimiter area - const absArea = Math.abs(rn(area)); - - const feature = { - i: featureId, - type, - land, - border, - cells: totalCells, - firstCell: startCell, - vertices: featureVertices, - area: absArea - }; - - if (type === "lake") { - if (area > 0) feature.vertices = feature.vertices.reverse(); - feature.shoreline = unique(feature.vertices.map(vertex => vertices.c[vertex].filter(isLand)).flat()); - feature.height = Lakes.getHeight(feature); - } - - return feature; - - function getCellsData(featureType, firstCell) { - if (featureType === "ocean") return [firstCell, []]; - - const getType = cellId => featureIds[cellId]; - const type = getType(firstCell); - const ofSameType = cellId => getType(cellId) === type; - const ofDifferentType = cellId => getType(cellId) !== type; - - const startCell = findOnBorderCell(firstCell); - const featureVertices = getFeatureVertices(startCell); - return [startCell, featureVertices]; - - function findOnBorderCell(firstCell) { - const isOnBorder = cellId => borderCells[cellId] || neighbors[cellId].some(ofDifferentType); - if (isOnBorder(firstCell)) return firstCell; - - const startCell = cells.i.filter(ofSameType).find(isOnBorder); - if (startCell === undefined) - throw new Error(`Markup: firstCell ${firstCell} is not on the feature or map border`); - - return startCell; - } - - function getFeatureVertices(startCell) { - const startingVertex = cells.v[startCell].find(v => vertices.c[v].some(ofDifferentType)); - if (startingVertex === undefined) - throw new Error(`Markup: startingVertex for cell ${startCell} is not found`); - - return connectVertices({vertices, startingVertex, ofSameType, closeRing: false}); - } - } - } - } - - // add properties to pack features - function defineGroups() { - const gridCellsNumber = grid.cells.i.length; - const OCEAN_MIN_SIZE = gridCellsNumber / 25; - const SEA_MIN_SIZE = gridCellsNumber / 1000; - const CONTINENT_MIN_SIZE = gridCellsNumber / 10; - const ISLAND_MIN_SIZE = gridCellsNumber / 1000; - - for (const feature of pack.features) { - if (!feature || feature.type === "ocean") continue; - - if (feature.type === "lake") feature.height = Lakes.getHeight(feature); - feature.group = defineGroup(feature); - } - - function defineGroup(feature) { - if (feature.type === "island") return defineIslandGroup(feature); - if (feature.type === "ocean") return defineOceanGroup(); - if (feature.type === "lake") return defineLakeGroup(feature); - throw new Error(`Markup: unknown feature type ${feature.type}`); - } - - function defineOceanGroup(feature) { - if (feature.cells > OCEAN_MIN_SIZE) return "ocean"; - if (feature.cells > SEA_MIN_SIZE) return "sea"; - return "gulf"; - } - - function defineIslandGroup(feature) { - const prevFeature = pack.features[pack.cells.f[feature.firstCell - 1]]; - if (prevFeature && prevFeature.type === "lake") return "lake_island"; - if (feature.cells > CONTINENT_MIN_SIZE) return "continent"; - if (feature.cells > ISLAND_MIN_SIZE) return "island"; - return "isle"; - } - - function defineLakeGroup(feature) { - if (feature.temp < -3) return "frozen"; - if (feature.height > 60 && feature.cells < 10 && feature.firstCell % 10 === 0) return "lava"; - - if (!feature.inlets && !feature.outlet) { - if (feature.evaporation > feature.flux * 4) return "dry"; - if (feature.cells < 3 && feature.firstCell % 10 === 0) return "sinkhole"; - } - - if (!feature.outlet && feature.evaporation > feature.flux) return "salt"; - - return "freshwater"; - } - } - - return {markupGrid, markupPack, defineGroups}; -})(); diff --git a/public/modules/ocean-layers.js b/public/modules/ocean-layers.js deleted file mode 100644 index 281fad0a..00000000 --- a/public/modules/ocean-layers.js +++ /dev/null @@ -1,92 +0,0 @@ -"use strict"; - -window.OceanLayers = (function () { - let cells, vertices, pointsN, used; - - const OceanLayers = function OceanLayers() { - const outline = oceanLayers.attr("layers"); - if (outline === "none") return; - TIME && console.time("drawOceanLayers"); - - lineGen.curve(d3.curveBasisClosed); - (cells = grid.cells), (pointsN = grid.cells.i.length), (vertices = grid.vertices); - const limits = outline === "random" ? randomizeOutline() : outline.split(",").map(s => +s); - - const chains = []; - const opacity = rn(0.4 / limits.length, 2); - used = new Uint8Array(pointsN); // to detect already passed cells - - for (const i of cells.i) { - const t = cells.t[i]; - if (t > 0) continue; - if (used[i] || !limits.includes(t)) continue; - const start = findStart(i, t); - if (!start) continue; - used[i] = 1; - const chain = connectVertices(start, t); // vertices chain to form a path - if (chain.length < 4) continue; - const relax = 1 + t * -2; // select only n-th point - const relaxed = chain.filter((v, i) => !(i % relax) || vertices.c[v].some(c => c >= pointsN)); - if (relaxed.length < 4) continue; - const points = clipPoly( - relaxed.map(v => vertices.p[v]), - 1 - ); - chains.push([t, points]); - } - - for (const t of limits) { - const layer = chains.filter(c => c[0] === t); - let path = layer.map(c => round(lineGen(c[1]))).join(""); - if (path) oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").attr("fill-opacity", opacity); - } - - // find eligible cell 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 >= pointsN)); // map border cell - return cells.v[i][cells.c[i].findIndex(c => cells.t[c] < t || !cells.t[c])]; - } - - TIME && console.timeEnd("drawOceanLayers"); - }; - - function randomizeOutline() { - const limits = []; - let odd = 0.2; - for (let l = -9; l < 0; l++) { - if (P(odd)) { - odd = 0.2; - limits.push(l); - } else { - odd *= 2; - } - } - return limits; - } - - // connect vertices to chain - function connectVertices(start, t) { - const chain = []; // vertices chain to form a path - for (let i = 0, current = start; i === 0 || (current !== start && i < 10000); 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.t[c] === t).forEach(c => (used[c] = 1)); - const v = vertices.v[current]; // neighboring vertices - const c0 = !cells.t[c[0]] || cells.t[c[0]] === t - 1; - const c1 = !cells.t[c[1]] || cells.t[c[1]] === t - 1; - const c2 = !cells.t[c[2]] || cells.t[c[2]] === t - 1; - if (v[0] !== undefined && v[0] !== prev && c0 !== c1) current = v[0]; - else if (v[1] !== undefined && v[1] !== prev && c1 !== c2) current = v[1]; - else if (v[2] !== undefined && v[2] !== prev && c0 !== c2) current = v[2]; - if (current === chain[chain.length - 1]) { - ERROR && console.error("Next vertex is not found"); - break; - } - } - chain.push(chain[0]); // push first vertex as the last one - return chain; - } - - return OceanLayers; -})(); diff --git a/src/index.html b/src/index.html index 3fd7ba17..d14cea96 100644 --- a/src/index.html +++ b/src/index.html @@ -8493,11 +8493,6 @@ - - - - - diff --git a/public/modules/biomes.js b/src/modules/biomes.ts similarity index 64% rename from public/modules/biomes.js rename to src/modules/biomes.ts index 06280fad..321ea77a 100644 --- a/public/modules/biomes.js +++ b/src/modules/biomes.ts @@ -1,10 +1,15 @@ -"use strict"; +import { range, mean } from "d3"; +import { rn } from "../utils"; -window.Biomes = (function () { - const MIN_LAND_HEIGHT = 20; +declare global { + var Biomes: BiomesModule; +} - const getDefault = () => { - const name = [ +class BiomesModule { + private MIN_LAND_HEIGHT = 20; + + getDefault() { + const name: string[] = [ "Marine", "Hot desert", "Cold desert", @@ -20,7 +25,7 @@ window.Biomes = (function () { "Wetland" ]; - const color = [ + const color: string[] = [ "#466eab", "#fbe79f", "#b5b887", @@ -35,9 +40,9 @@ window.Biomes = (function () { "#d5e7eb", "#0b9131" ]; - const habitability = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12]; - const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250]; - const icons = [ + const habitability: number[] = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12]; + const iconsDensity: number[] = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250]; + const icons: Array<{[key: string]: number}> = [ {}, {dune: 3, cactus: 6, deadTree: 1}, {dune: 9, deadTree: 1}, @@ -52,8 +57,8 @@ window.Biomes = (function () { {}, {swamp: 1} ]; - const cost = [10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150]; // biome movement cost - const biomesMartix = [ + const cost: number[] = [10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150]; // biome movement cost + const biomesMatrix: Uint8Array[] = [ // hot ↔ cold [>19°C; <-4°C]; dry ↕ wet new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 10]), new Uint8Array([3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, 10, 10]), @@ -63,66 +68,66 @@ window.Biomes = (function () { ]; // parse icons weighted array into a simple array + const parsedIcons: string[][] = []; for (let i = 0; i < icons.length; i++) { - const parsed = []; + const parsed: string[] = []; for (const icon in icons[i]) { for (let j = 0; j < icons[i][icon]; j++) { parsed.push(icon); } } - icons[i] = parsed; + parsedIcons[i] = parsed; } - return {i: d3.range(0, name.length), name, color, biomesMartix, habitability, iconsDensity, icons, cost}; + return {i: range(0, name.length), name, color, biomesMatrix, habitability, iconsDensity, icons: parsedIcons, cost}; }; - // assign biome id for each cell - function define() { + define() { TIME && console.time("defineBiomes"); const {fl: flux, r: riverIds, h: heights, c: neighbors, g: gridReference} = pack.cells; const {temp, prec} = grid.cells; pack.cells.biome = new Uint8Array(pack.cells.i.length); // biomes array - for (let cellId = 0; cellId < heights.length; cellId++) { - const height = heights[cellId]; - const moisture = height < MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId); - const temperature = temp[gridReference[cellId]]; - pack.cells.biome[cellId] = getId(moisture, temperature, height, Boolean(riverIds[cellId])); - } - - function calculateMoisture(cellId) { + const calculateMoisture = (cellId: number) => { let moisture = prec[gridReference[cellId]]; if (riverIds[cellId]) moisture += Math.max(flux[cellId] / 10, 2); const moistAround = neighbors[cellId] - .filter(neibCellId => heights[neibCellId] >= MIN_LAND_HEIGHT) - .map(c => prec[gridReference[c]]) + .filter((neibCellId: number) => heights[neibCellId] >= this.MIN_LAND_HEIGHT) + .map((c: number) => prec[gridReference[c]]) .concat([moisture]); - return rn(4 + d3.mean(moistAround)); + return rn(4 + (mean(moistAround) as number)); + } + + for (let cellId = 0; cellId < heights.length; cellId++) { + const height = heights[cellId]; + const moisture = height < this.MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId); + const temperature = temp[gridReference[cellId]]; + pack.cells.biome[cellId] = this.getId(moisture, temperature, height, Boolean(riverIds[cellId])); } TIME && console.timeEnd("defineBiomes"); } - function getId(moisture, temperature, height, hasRiver) { + getId(moisture: number, temperature: number, height: number, hasRiver: boolean) { if (height < 20) return 0; // all water cells: marine biome if (temperature < -5) return 11; // too cold: permafrost biome if (temperature >= 25 && !hasRiver && moisture < 8) return 1; // too hot and dry: hot desert biome - if (isWetland(moisture, temperature, height)) return 12; // too wet: wetland biome + if (this.isWetland(moisture, temperature, height)) return 12; // too wet: wetland biome // in other cases use biome matrix const moistureBand = Math.min((moisture / 5) | 0, 4); // [0-4] const temperatureBand = Math.min(Math.max(20 - temperature, 0), 25); // [0-25] - return biomesData.biomesMartix[moistureBand][temperatureBand]; + return biomesData.biomesMatrix[moistureBand][temperatureBand]; } - function isWetland(moisture, temperature, height) { + private isWetland(moisture: number, temperature: number, height: number) { if (temperature <= -2) return false; // too cold if (moisture > 40 && height < 25) return true; // near coast if (moisture > 24 && height > 24 && height < 60) return true; // off coast return false; } +} - return {getDefault, define, getId}; -})(); +window.Biomes = new BiomesModule(); diff --git a/src/modules/features.ts b/src/modules/features.ts new file mode 100644 index 00000000..bedb48ff --- /dev/null +++ b/src/modules/features.ts @@ -0,0 +1,328 @@ +import { clipPoly, connectVertices, createTypedArray, distanceSquared, isLand, isWater, rn, TYPED_ARRAY_MAX_VALUES, unique } from "../utils"; +import Alea from "alea"; +import { polygonArea } from "d3"; + +declare global { + var Features: FeatureModule; +} + +type FeatureType = "ocean" | "lake" | "island"; + +export interface PackedGraphFeature { + i: number; + type: FeatureType; + land: boolean; + border: boolean; + cells: number; + firstCell: number; + vertices: number[]; + area: number; + shoreline: number[]; + height: number; + group: string; + temp: number; + flux: number; + evaporation: number; + name: string; + + // River related + inlets?: number[]; + outlet?: number; + river?: number; + enteringFlux?: number; + closed?: boolean; + outCell?: number; +} + +export interface GridFeature { + i: number; + land: boolean; + border: boolean; + type: FeatureType; +} + +class FeatureModule { + private DEEPER_LAND = 3; + private LANDLOCKED = 2; + private LAND_COAST = 1; + private UNMARKED = 0; + private WATER_COAST = -1; + private DEEP_WATER = -2; + + /** + * calculate distance to coast for every cell + */ + private markup({ distanceField, neighbors, start, increment, limit = TYPED_ARRAY_MAX_VALUES.INT8_MAX }: { + distanceField: Int8Array; + neighbors: number[][]; + start: number; + increment: number; + limit?: number; + }) { + for (let distance = start, marked = Infinity; marked > 0 && distance !== limit; distance += increment) { + marked = 0; + const prevDistance = distance - increment; + for (let cellId = 0; cellId < neighbors.length; cellId++) { + if (distanceField[cellId] !== prevDistance) continue; + + for (const neighborId of neighbors[cellId]) { + if (distanceField[neighborId] !== this.UNMARKED) continue; + distanceField[neighborId] = distance; + marked++; + } + } + } + } + + /** + * mark Grid features (ocean, lakes, islands) and calculate distance field + */ + markupGrid() { + TIME && console.time("markupGrid"); + Math.random = Alea(seed); // get the same result on heightmap edit in Erase mode + + const { h: heights, c: neighbors, b: borderCells, i } = grid.cells; + const cellsNumber = i.length; + const distanceField = new Int8Array(cellsNumber); // gird.cells.t + const featureIds = new Uint16Array(cellsNumber); // gird.cells.f + const features: GridFeature[] = []; + + const queue = [0]; + for (let featureId = 1; queue[0] !== -1; featureId++) { + const firstCell = queue[0]; + featureIds[firstCell] = featureId; + + const land = heights[firstCell] >= 20; + let border = false; // set true if feature touches map edge + + while (queue.length) { + const cellId = queue.pop() as number; + if (!border && borderCells[cellId]) border = true; + + for (const neighborId of neighbors[cellId]) { + const isNeibLand = heights[neighborId] >= 20; + + if (land === isNeibLand && featureIds[neighborId] === this.UNMARKED) { + featureIds[neighborId] = featureId; + queue.push(neighborId); + } else if (land && !isNeibLand) { + distanceField[cellId] = this.LAND_COAST; + distanceField[neighborId] = this.WATER_COAST; + } + } + } + + const type = land ? "island" : border ? "ocean" : "lake"; + features.push({ i: featureId, land, border, type }); + + queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell + } + + // markup deep ocean cells + this.markup({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 }); + grid.cells.t = distanceField; + grid.cells.f = featureIds; + grid.features = [0, ...features]; + + TIME && console.timeEnd("markupGrid"); + } + + /** + * mark PackedGraph features (oceans, lakes, islands) and calculate distance field + */ + markupPack() { + const defineHaven = (cellId: number) => { + const waterCells = neighbors[cellId].filter((index: number) => isWater(index, pack)); + const distances = waterCells.map((neibCellId: number) => distanceSquared(cells.p[cellId], cells.p[neibCellId])); + const closest = distances.indexOf(Math.min.apply(Math, distances)); + + haven[cellId] = waterCells[closest]; + harbor[cellId] = waterCells.length; + } + + const getCellsData = (featureType: string, firstCell: number): [number, number[]] => { + if (featureType === "ocean") return [firstCell, []]; + + const getType = (cellId: number) => featureIds[cellId]; + const type = getType(firstCell); + const ofSameType = (cellId: number) => getType(cellId) === type; + const ofDifferentType = (cellId: number) => getType(cellId) !== type; + + const startCell = findOnBorderCell(firstCell); + const featureVertices = getFeatureVertices(startCell); + return [startCell, featureVertices]; + + function findOnBorderCell(firstCell: number) { + const isOnBorder = (cellId: number) => borderCells[cellId] || neighbors[cellId].some(ofDifferentType); + if (isOnBorder(firstCell)) return firstCell; + + const startCell = cells.i.filter(ofSameType).find(isOnBorder); + if (startCell === undefined) + throw new Error(`Markup: firstCell ${firstCell} is not on the feature or map border`); + + return startCell; + } + + function getFeatureVertices(startCell: number) { + const startingVertex = cells.v[startCell].find((v: number) => vertices.c[v].some(ofDifferentType)); + if (startingVertex === undefined) + throw new Error(`Markup: startingVertex for cell ${startCell} is not found`); + + return connectVertices({ vertices, startingVertex, ofSameType, closeRing: false }); + } + } + + const addFeature = ({ firstCell, land, border, featureId, totalCells }: { firstCell: number; land: boolean; border: boolean; featureId: number; totalCells: number }): PackedGraphFeature => { + const type = land ? "island" : border ? "ocean" : "lake"; + const [startCell, featureVertices] = getCellsData(type, firstCell); + const points = clipPoly(featureVertices.map((vertex: number) => vertices.p[vertex])); + const area = polygonArea(points); // feature perimiter area + const absArea = Math.abs(rn(area)); + + const feature: Partial = { + i: featureId, + type, + land, + border, + cells: totalCells, + firstCell: startCell, + vertices: featureVertices, + area: absArea, + shoreline: [], + height: 0, + }; + + if (type === "lake") { + if (area > 0) feature.vertices = (feature.vertices as number[]).reverse(); + feature.shoreline = unique( + (feature.vertices as number[]) + .flatMap( + vertexIndex => vertices.c[vertexIndex].filter((index) => isLand(index, pack)) + ) + ); + feature.height = Lakes.getHeight(feature as PackedGraphFeature); + } + + return { + ...feature + } as PackedGraphFeature; + } + + TIME && console.time("markupPack"); + + const { cells, vertices } = pack; + const { c: neighbors, b: borderCells, i } = cells; + const packCellsNumber = i.length; + if (!packCellsNumber) return; // no cells -> there is nothing to do + + const distanceField = new Int8Array(packCellsNumber); // pack.cells.t + const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f + const haven = createTypedArray({ maxValue: packCellsNumber, length: packCellsNumber }); // haven: opposite water cell + const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells + const features: PackedGraphFeature[] = []; + + const queue = [0]; + for (let featureId = 1; queue[0] !== -1; featureId++) { + const firstCell = queue[0]; + featureIds[firstCell] = featureId; + + const land = isLand(firstCell, pack); + let border = Boolean(borderCells[firstCell]); // true if feature touches map border + let totalCells = 1; // count cells in a feature + + while (queue.length) { + const cellId = queue.pop() as number; + if (borderCells[cellId]) border = true; + + for (const neighborId of neighbors[cellId]) { + const isNeibLand = isLand(neighborId, pack); + + if (land && !isNeibLand) { + distanceField[cellId] = this.LAND_COAST; + distanceField[neighborId] = this.WATER_COAST; + if (!haven[cellId]) defineHaven(cellId); + } else if (land && isNeibLand) { + if (distanceField[neighborId] === this.UNMARKED && distanceField[cellId] === this.LAND_COAST) + distanceField[neighborId] = this.LANDLOCKED; + else if (distanceField[cellId] === this.UNMARKED && distanceField[neighborId] === this.LAND_COAST) + distanceField[cellId] = this.LANDLOCKED; + } + + if (!featureIds[neighborId] && land === isNeibLand) { + queue.push(neighborId); + featureIds[neighborId] = featureId; + totalCells++; + } + } + } + + features.push(addFeature({ firstCell, land, border, featureId, totalCells })); + queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell + } + + this.markup({ distanceField, neighbors, start: this.DEEPER_LAND, increment: 1 }); // markup pack land + this.markup({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 }); // markup pack water + + pack.cells.t = distanceField; + pack.cells.f = featureIds; + pack.cells.haven = haven; + pack.cells.harbor = harbor; + pack.features = [0 as unknown as PackedGraphFeature, ...features]; + TIME && console.timeEnd("markupPack"); + } + + /** + * define feature groups (ocean, sea, gulf, continent, island, isle, freshwater lake, salt lake, etc.) + */ + defineGroups() { + const gridCellsNumber = grid.cells.i.length; + const OCEAN_MIN_SIZE = gridCellsNumber / 25; + const SEA_MIN_SIZE = gridCellsNumber / 1000; + const CONTINENT_MIN_SIZE = gridCellsNumber / 10; + const ISLAND_MIN_SIZE = gridCellsNumber / 1000; + + const defineIslandGroup = (feature: PackedGraphFeature) => { + const prevFeature = pack.features[pack.cells.f[feature.firstCell - 1]]; + if (prevFeature && prevFeature.type === "lake") return "lake_island"; + if (feature.cells > CONTINENT_MIN_SIZE) return "continent"; + if (feature.cells > ISLAND_MIN_SIZE) return "island"; + return "isle"; + } + + const defineOceanGroup = (feature: PackedGraphFeature) => { + if (feature.cells > OCEAN_MIN_SIZE) return "ocean"; + if (feature.cells > SEA_MIN_SIZE) return "sea"; + return "gulf"; + } + + const defineLakeGroup = (feature: PackedGraphFeature) => { + if (feature.temp < -3) return "frozen"; + if (feature.height > 60 && feature.cells < 10 && feature.firstCell % 10 === 0) return "lava"; + + if (!feature.inlets && !feature.outlet) { + if (feature.evaporation > feature.flux * 4) return "dry"; + if (feature.cells < 3 && feature.firstCell % 10 === 0) return "sinkhole"; + } + + if (!feature.outlet && feature.evaporation > feature.flux) return "salt"; + + return "freshwater"; + } + + const defineGroup = (feature: PackedGraphFeature) => { + if (feature.type === "island") return defineIslandGroup(feature); + if (feature.type === "ocean") return defineOceanGroup(feature); + if (feature.type === "lake") return defineLakeGroup(feature); + throw new Error(`Markup: unknown feature type ${feature.type}`); + } + + for (const feature of pack.features) { + if (!feature || feature.type === "ocean") continue; + + if (feature.type === "lake") feature.height = Lakes.getHeight(feature); + feature.group = defineGroup(feature); + } + } +} + +window.Features = new FeatureModule(); diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index 27fb063b..a060ecdc 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -3,12 +3,7 @@ import { range as d3Range, leastIndex, mean } from "d3"; import { createTypedArray, byId, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils"; declare global { - interface Window { - HeightmapGenerator: HeightmapGenerator; - } - var heightmapTemplates: any; - var TIME: boolean; - var ERROR: boolean; + var HeightmapGenerator: HeightmapGenerator; } type Tool = "Hill" | "Pit" | "Range" | "Trough" | "Strait" | "Mask" | "Invert" | "Add" | "Multiply" | "Smooth"; @@ -19,21 +14,6 @@ class HeightmapGenerator { blobPower: number = 0; linePower: number = 0; - // TODO: remove after migration to TS and use param in constructor - get seed() { - return (window as any).seed; - } - get graphWidth() { - return (window as any).graphWidth; - } - get graphHeight() { - return (window as any).graphHeight; - } - - constructor() { - - } - private clearData() { this.heights = null; this.grid = null; @@ -107,8 +87,8 @@ class HeightmapGenerator { let h = lim(getNumberInRange(height)); do { - const x = this.getPointInRange(rangeX, this.graphWidth); - const y = this.getPointInRange(rangeY, this.graphHeight); + const x = this.getPointInRange(rangeX, graphWidth); + const y = this.getPointInRange(rangeY, graphHeight); if (x === undefined || y === undefined) return; start = findGridCell(x, y, this.grid); limit++; @@ -143,8 +123,8 @@ class HeightmapGenerator { let h = lim(getNumberInRange(height)); do { - const x = this.getPointInRange(rangeX, this.graphWidth); - const y = this.getPointInRange(rangeY, this.graphHeight); + const x = this.getPointInRange(rangeX, graphWidth); + const y = this.getPointInRange(rangeY, graphHeight); if (x === undefined || y === undefined) return; start = findGridCell(x, y, this.grid); limit++; @@ -207,8 +187,8 @@ class HeightmapGenerator { if (rangeX && rangeY) { // find start and end points - const startX = this.getPointInRange(rangeX, this.graphWidth) as number; - const startY = this.getPointInRange(rangeY, this.graphHeight) as number; + const startX = this.getPointInRange(rangeX, graphWidth) as number; + const startY = this.getPointInRange(rangeY, graphHeight) as number; let dist = 0; let limit = 0; @@ -216,11 +196,11 @@ class HeightmapGenerator { let endX; do { - endX = Math.random() * this.graphWidth * 0.8 + this.graphWidth * 0.1; - endY = Math.random() * this.graphHeight * 0.7 + this.graphHeight * 0.15; + endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; + endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; dist = Math.abs(endY - startY) + Math.abs(endX - startX); limit++; - } while ((dist < this.graphWidth / 8 || dist > this.graphWidth / 3) && limit < 50); + } while ((dist < graphWidth / 8 || dist > graphWidth / 3) && limit < 50); startCellId = findGridCell(startX, startY, this.grid); endCellId = findGridCell(endX, endY, this.grid); @@ -311,19 +291,19 @@ class HeightmapGenerator { let endX: number; let endY: number; do { - startX = this.getPointInRange(rangeX, this.graphWidth) as number; - startY = this.getPointInRange(rangeY, this.graphHeight) as number; + startX = this.getPointInRange(rangeX, graphWidth) as number; + startY = this.getPointInRange(rangeY, graphHeight) as number; startCellId = findGridCell(startX, startY, this.grid); limit++; } while (this.heights[startCellId] < 20 && limit < 50); limit = 0; do { - endX = Math.random() * this.graphWidth * 0.8 + this.graphWidth * 0.1; - endY = Math.random() * this.graphHeight * 0.7 + this.graphHeight * 0.15; + endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; + endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; dist = Math.abs(endY - startY) + Math.abs(endX - startX); limit++; - } while ((dist < this.graphWidth / 8 || dist > this.graphWidth / 2) && limit < 50); + } while ((dist < graphWidth / 8 || dist > graphWidth / 2) && limit < 50); endCellId = findGridCell(endX, endY, this.grid); } @@ -378,14 +358,14 @@ class HeightmapGenerator { if (desiredWidth < 1 && P(desiredWidth)) return; const used = new Uint8Array(this.heights.length); const vert = direction === "vertical"; - const startX = vert ? Math.floor(Math.random() * this.graphWidth * 0.4 + this.graphWidth * 0.3) : 5; - const startY = vert ? 5 : Math.floor(Math.random() * this.graphHeight * 0.4 + this.graphHeight * 0.3); + const startX = vert ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) : 5; + const startY = vert ? 5 : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3); const endX = vert - ? Math.floor(this.graphWidth - startX - this.graphWidth * 0.1 + Math.random() * this.graphWidth * 0.2) - : this.graphWidth - 5; + ? Math.floor(graphWidth - startX - graphWidth * 0.1 + Math.random() * graphWidth * 0.2) + : graphWidth - 5; const endY = vert - ? this.graphHeight - 5 - : Math.floor(this.graphHeight - startY - this.graphHeight * 0.1 + Math.random() * this.graphHeight * 0.2); + ? graphHeight - 5 + : Math.floor(graphHeight - startY - graphHeight * 0.1 + Math.random() * graphHeight * 0.2); const start = findGridCell(startX, startY, this.grid); const end = findGridCell(endX, endY, this.grid); @@ -462,8 +442,8 @@ class HeightmapGenerator { this.heights = this.heights.map((h, i) => { const [x, y] = this.grid.points[i]; - const nx = (2 * x) / this.graphWidth - 1; // [-1, 1], 0 is center - const ny = (2 * y) / this.graphHeight - 1; // [-1, 1], 0 is center + const nx = (2 * x) / graphWidth - 1; // [-1, 1], 0 is center + const ny = (2 * y) / graphHeight - 1; // [-1, 1], 0 is center let distance = (1 - nx ** 2) * (1 - ny ** 2); // 1 is center, 0 is edge if (power < 0) distance = 1 - distance; // inverted, 0 is center, 1 is edge const masked = h * distance; @@ -509,7 +489,7 @@ class HeightmapGenerator { TIME && console.time("defineHeightmap"); const id = (byId("templateInput")! as HTMLInputElement).value; - Math.random = Alea(this.seed); + Math.random = Alea(seed); const isTemplate = id in heightmapTemplates; const heights = isTemplate ? this.fromTemplate(graph, id) : await this.fromPrecreated(graph, id); diff --git a/src/modules/index.ts b/src/modules/index.ts index fe1135c0..41beaabd 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,2 +1,7 @@ import "./voronoi"; -import "./heightmap-generator"; \ No newline at end of file +import "./heightmap-generator"; +import "./features"; +import "./lakes"; +import "./ocean-layers"; +import "./river-generator"; +import "./biomes" diff --git a/public/modules/lakes.js b/src/modules/lakes.ts similarity index 67% rename from public/modules/lakes.js rename to src/modules/lakes.ts index 8ce18793..6fa381ac 100644 --- a/public/modules/lakes.js +++ b/src/modules/lakes.ts @@ -1,12 +1,93 @@ -"use strict"; +import { PackedGraphFeature } from "./features"; +import { min, mean } from "d3"; +import { byId, +rn } from "../utils"; -window.Lakes = (function () { - const LAKE_ELEVATION_DELTA = 0.1; +declare global { + var Lakes: LakesModule; +} + +export class LakesModule { + private LAKE_ELEVATION_DELTA = 0.1; + + getHeight(feature: PackedGraphFeature) { + const heights = pack.cells.h; + const minShoreHeight = min(feature.shoreline.map(cellId => heights[cellId])) || 20; + return rn(minShoreHeight - this.LAKE_ELEVATION_DELTA, 2); + }; + + defineNames() { + pack.features.forEach((feature: PackedGraphFeature) => { + if (feature.type !== "lake") return; + feature.name = this.getName(feature); + }); + }; + + getName(feature: PackedGraphFeature): string { + const landCell = feature.shoreline[0]; + const culture = pack.cells.culture[landCell]; + return Names.getCulture(culture); + }; + + cleanupLakeData = function () { + for (const feature of pack.features) { + if (feature.type !== "lake") continue; + delete feature.river; + delete feature.enteringFlux; + delete feature.outCell; + delete feature.closed; + feature.height = rn(feature.height, 3); + + const inlets = feature.inlets?.filter(r => pack.rivers.find(river => river.i === r)); + if (!inlets || !inlets.length) delete feature.inlets; + else feature.inlets = inlets; + + const outlet = feature.outlet && pack.rivers.find(river => river.i === feature.outlet); + if (!outlet) delete feature.outlet; + } + }; + + defineClimateData(heights: number[] | Uint8Array) { + const {cells, features} = pack; + const lakeOutCells = new Uint16Array(cells.i.length); + + const getFlux = (lake: PackedGraphFeature) => { + return lake.shoreline.reduce((acc, c) => acc + grid.cells.prec[cells.g[c]], 0); + } + + const getLakeTemp = (lake: PackedGraphFeature) => { + if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]]; + return rn(mean(lake.shoreline.map(c => grid.cells.temp[cells.g[c]])) as number, 1); + } + + const getLakeEvaporation = (lake: PackedGraphFeature) => { + const height = (lake.height - 18) ** Number(heightExponentInput.value); // height in meters + const evaporation = ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11] + return rn(evaporation * lake.cells); + } + + const getLowestShoreCell = (lake: PackedGraphFeature) => { + return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0]; + } + + features.forEach(feature => { + if (feature.type !== "lake") return; + feature.flux = getFlux(feature); + feature.temp = getLakeTemp(feature); + feature.evaporation = getLakeEvaporation(feature); + if (feature.closed) return; // no outlet for lakes in depressed areas + + feature.outCell = getLowestShoreCell(feature); + lakeOutCells[feature.outCell as number] = feature.i; + }); + + return lakeOutCells; + }; // check if lake can be potentially open (not in deep depression) - const detectCloseLakes = h => { + detectCloseLakes(h: number[] | Uint8Array) { const {cells} = pack; - const ELEVATION_LIMIT = +byId("lakeElevationLimitOutput").value; + const ELEVATION_LIMIT = +(byId("lakeElevationLimitOutput") as HTMLInputElement)?.value; pack.features.forEach(feature => { if (feature.type !== "lake") return; @@ -25,7 +106,7 @@ window.Lakes = (function () { checked[lowestShorelineCell] = true; while (queue.length && isDeep) { - const cellId = queue.pop(); + const cellId: number = queue.pop() as number; for (const neibCellId of cells.c[cellId]) { if (checked[neibCellId]) continue; @@ -44,80 +125,6 @@ window.Lakes = (function () { feature.closed = isDeep; }); }; +} - const defineClimateData = function (heights) { - const {cells, features} = pack; - const lakeOutCells = new Uint16Array(cells.i.length); - - features.forEach(feature => { - if (feature.type !== "lake") return; - feature.flux = getFlux(feature); - feature.temp = getLakeTemp(feature); - feature.evaporation = getLakeEvaporation(feature); - if (feature.closed) return; // no outlet for lakes in depressed areas - - feature.outCell = getLowestShoreCell(feature); - lakeOutCells[feature.outCell] = feature.i; - }); - - return lakeOutCells; - - function getFlux(lake) { - return lake.shoreline.reduce((acc, c) => acc + grid.cells.prec[cells.g[c]], 0); - } - - function getLakeTemp(lake) { - if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]]; - return rn(d3.mean(lake.shoreline.map(c => grid.cells.temp[cells.g[c]])), 1); - } - - function getLakeEvaporation(lake) { - const height = (lake.height - 18) ** heightExponentInput.value; // height in meters - const evaporation = ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11] - return rn(evaporation * lake.cells); - } - - function getLowestShoreCell(lake) { - return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0]; - } - }; - - const cleanupLakeData = function () { - for (const feature of pack.features) { - if (feature.type !== "lake") continue; - delete feature.river; - delete feature.enteringFlux; - delete feature.outCell; - delete feature.closed; - feature.height = rn(feature.height, 3); - - const inlets = feature.inlets?.filter(r => pack.rivers.find(river => river.i === r)); - if (!inlets || !inlets.length) delete feature.inlets; - else feature.inlets = inlets; - - const outlet = feature.outlet && pack.rivers.find(river => river.i === feature.outlet); - if (!outlet) delete feature.outlet; - } - }; - - const getHeight = function (feature) { - const heights = pack.cells.h; - const minShoreHeight = d3.min(feature.shoreline.map(cellId => heights[cellId])) || 20; - return rn(minShoreHeight - LAKE_ELEVATION_DELTA, 2); - }; - - const defineNames = function () { - pack.features.forEach(feature => { - if (feature.type !== "lake") return; - feature.name = getName(feature); - }); - }; - - const getName = function (feature) { - const landCell = feature.shoreline[0]; - const culture = pack.cells.culture[landCell]; - return Names.getCulture(culture); - }; - - return {defineClimateData, cleanupLakeData, detectCloseLakes, getHeight, defineNames, getName}; -})(); +window.Lakes = new LakesModule(); \ No newline at end of file diff --git a/src/modules/ocean-layers.ts b/src/modules/ocean-layers.ts new file mode 100644 index 00000000..11467ea5 --- /dev/null +++ b/src/modules/ocean-layers.ts @@ -0,0 +1,110 @@ +import { line, curveBasisClosed } from 'd3'; +import type { Selection } from 'd3'; +import { clipPoly,P,rn,round } from '../utils'; + +declare global { + var OceanLayers: typeof OceanModule.prototype.draw; +} +class OceanModule { + private cells: any; + private vertices: any; + private pointsN: any; + private used: any; + private lineGen = line().curve(curveBasisClosed); + private oceanLayers: Selection; + + + constructor(oceanLayers: Selection) { + this.oceanLayers = oceanLayers; + } + + randomizeOutline() { + const limits = []; + let odd = 0.2; + for (let l = -9; l < 0; l++) { + if (P(odd)) { + odd = 0.2; + limits.push(l); + } else { + odd *= 2; + } + } + return limits; + } + + // connect vertices to chain + connectVertices(start: number, t: number) { + const chain = []; // vertices chain to form a path + for (let i = 0, current = start; i === 0 || (current !== start && i < 10000); i++) { + const prev = chain[chain.length - 1]; // previous vertex in chain + chain.push(current); // add current vertex to sequence + const c = this.vertices.c[current]; // cells adjacent to vertex + c.filter((c: number) => this.cells.t[c] === t).forEach((c: number) => (this.used[c] = 1)); + const v = this.vertices.v[current]; // neighboring vertices + const c0 = !this.cells.t[c[0]] || this.cells.t[c[0]] === t - 1; + const c1 = !this.cells.t[c[1]] || this.cells.t[c[1]] === t - 1; + const c2 = !this.cells.t[c[2]] || this.cells.t[c[2]] === t - 1; + if (v[0] !== undefined && v[0] !== prev && c0 !== c1) current = v[0]; + else if (v[1] !== undefined && v[1] !== prev && c1 !== c2) current = v[1]; + else if (v[2] !== undefined && v[2] !== prev && c0 !== c2) current = v[2]; + if (current === chain[chain.length - 1]) { + ERROR && console.error("Next vertex is not found"); + break; + } + } + chain.push(chain[0]); // push first vertex as the last one + return chain; + } + + // find eligible cell vertex to start path detection + findStart(i: number, t: number) { + if (this.cells.b[i]) return this.cells.v[i].find((v: number) => this.vertices.c[v].some((c: number) => c >= this.pointsN)); // map border cell + return this.cells.v[i][this.cells.c[i].findIndex((c: number)=> this.cells.t[c] < t || !this.cells.t[c])]; + } + + draw() { + const outline = this.oceanLayers.attr("layers"); + if (outline === "none") return; + TIME && console.time("drawOceanLayers"); + this.cells = grid.cells; + this.pointsN = grid.cells.i.length; + this.vertices = grid.vertices; + const limits = outline === "random" ? this.randomizeOutline() : outline.split(",").map((s: string) => +s); + + const chains: [number, any[]][] = []; + const opacity = rn(0.4 / limits.length, 2); + this.used = new Uint8Array(this.pointsN); // to detect already passed cells + + for (const i of this.cells.i) { + const t = this.cells.t[i]; + if (t > 0) continue; + if (this.used[i] || !limits.includes(t)) continue; + const start = this.findStart(i, t); + if (!start) continue; + this.used[i] = 1; + const chain = this.connectVertices(start, t); // vertices chain to form a path + if (chain.length < 4) continue; + const relax = 1 + t * -2; // select only n-th point + const relaxed = chain.filter((v, i) => !(i % relax) || this.vertices.c[v].some((c: number) => c >= this.pointsN)); + if (relaxed.length < 4) continue; + + const points = clipPoly( + relaxed.map(v => this.vertices.p[v]), + graphWidth, + graphHeight, + 1 + ); + chains.push([t, points]); + } + + for (const t of limits) { + const layer = chains.filter((c: [number, any[]]) => c[0] === t); + let path = layer.map((c: [number, any[]]) => round(this.lineGen(c[1]) || "")).join(""); + if (path) this.oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").attr("fill-opacity", opacity); + } + + TIME && console.timeEnd("drawOceanLayers"); + } +} + +window.OceanLayers = () => new OceanModule(oceanLayers).draw(); diff --git a/public/modules/river-generator.js b/src/modules/river-generator.ts similarity index 63% rename from public/modules/river-generator.js rename to src/modules/river-generator.ts index 254e1af8..55cedaa1 100644 --- a/public/modules/river-generator.js +++ b/src/modules/river-generator.ts @@ -1,66 +1,89 @@ -"use strict"; +import Alea from "alea"; +import { each, rn, round, rw} from "../utils"; +import { curveBasis, line, mean, min, sum, curveCatmullRom } from "d3"; -window.Rivers = (function () { - const generate = function (allowErosion = true) { + + +declare global { + var Rivers: RiverModule; +} + +export interface River { + i: number; // river id + source: number; // source cell index + mouth: number; // mouth cell index + parent: number; // parent river id + basin: number; // basin river id + length: number; // river length + discharge: number; // river discharge in m3/s + width: number; // mouth width in km + widthFactor: number; // width scaling factor + sourceWidth: number; // source width in km + name: string; // river name + type: string; // river type + cells: number[]; // cells forming the river path +} + +class RiverModule { + private FLUX_FACTOR = 500; + private MAX_FLUX_WIDTH = 1; + private LENGTH_FACTOR = 200; + private LENGTH_STEP_WIDTH = 1 / this.LENGTH_FACTOR; + private LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / this.LENGTH_FACTOR); + private lineGen = line().curve(curveBasis) + + riverTypes = { + main: { + big: {River: 1}, + small: {Creek: 9, River: 3, Brook: 3, Stream: 1} + }, + fork: { + big: {Fork: 1}, + small: {Branch: 1} + } + }; + + smallLength: number | null = null; + + generate(allowErosion = true) { TIME && console.time("generateRivers"); - Math.random = aleaPRNG(seed); + Math.random = Alea(seed); const {cells, features} = pack; - const riversData = {}; // rivers data - const riverParents = {}; + const riversData: {[riverId: number]: number[]} = {}; + const riverParents: {[key: number]: number} = {}; - const addCellToRiver = function (cell, river) { - if (!riversData[river]) riversData[river] = [cell]; - else riversData[river].push(cell); + const addCellToRiver = (cellId: number, riverId: number) => { + if (!riversData[riverId]) riversData[riverId] = [cellId]; + else riversData[riverId].push(cellId); }; - cells.fl = new Uint16Array(cells.i.length); // water flux array - cells.r = new Uint16Array(cells.i.length); // rivers array - cells.conf = new Uint8Array(cells.i.length); // confluences array - let riverNext = 1; // first river id is 1 - - const h = alterHeights(); - Lakes.detectCloseLakes(h); - resolveDepressions(h); - drainWater(); - defineRivers(); - - calculateConfluenceFlux(); - Lakes.cleanupLakeData(); - - if (allowErosion) { - cells.h = Uint8Array.from(h); // apply gradient - downcutRivers(); // downcut river beds - } - - TIME && console.timeEnd("generateRivers"); - - function drainWater() { + const drainWater = () => { const MIN_FLUX_TO_FORM_RIVER = 30; - const cellsNumberModifier = (pointsInput.dataset.cells / 10000) ** 0.25; + const cellsNumberModifier = ((pointsInput.dataset.cells as any) / 10000) ** 0.25; const prec = grid.cells.prec; - const land = cells.i.filter(i => h[i] >= 20).sort((a, b) => h[b] - h[a]); + const land = cells.i.filter((i: number) => h[i] >= 20).sort((a: number, b: number) => h[b] - h[a]); const lakeOutCells = Lakes.defineClimateData(h); - land.forEach(function (i) { + land.forEach(function (i: number) { cells.fl[i] += prec[cells.g[i]] / cellsNumberModifier; // add flux from precipitation // create lake outlet if lake is not in deep depression and flux > evaporation const lakes = lakeOutCells[i] - ? features.filter(feature => i === feature.outCell && feature.flux > feature.evaporation) + ? features.filter((feature: any) => i === feature.outCell && feature.flux > feature.evaporation) : []; for (const lake of lakes) { - const lakeCell = cells.c[i].find(c => h[c] < 20 && cells.f[c] === lake.i); + const lakeCell = cells.c[i].find((c: number) => h[c] < 20 && cells.f[c] === lake.i)!; cells.fl[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet // allow chain lakes to retain identity if (cells.r[lakeCell] !== lake.river) { - const sameRiver = cells.c[lakeCell].some(c => cells.r[c] === lake.river); + const sameRiver = cells.c[lakeCell].some((c: number) => cells.r[c] === lake.river); if (sameRiver) { - cells.r[lakeCell] = lake.river; - addCellToRiver(lakeCell, lake.river); + cells.r[lakeCell] = lake.river as number; + addCellToRiver(lakeCell, lake.river as number); } else { cells.r[lakeCell] = riverNext; addCellToRiver(lakeCell, riverNext); @@ -77,7 +100,7 @@ window.Rivers = (function () { for (const lake of lakes) { if (!Array.isArray(lake.inlets)) continue; for (const inlet of lake.inlets) { - riverParents[inlet] = outlet; + riverParents[inlet] = outlet as number; } } @@ -87,12 +110,12 @@ window.Rivers = (function () { // downhill cell (make sure it's not in the source lake) let min = null; if (lakeOutCells[i]) { - const filtered = cells.c[i].filter(c => !lakes.map(lake => lake.i).includes(cells.f[c])); - min = filtered.sort((a, b) => h[a] - h[b])[0]; + const filtered = cells.c[i].filter((c: number) => !lakes.map((lake: any) => lake.i).includes(cells.f[c])); + min = filtered.sort((a: number, b: number) => h[a] - h[b])[0]; } else if (cells.haven[i]) { min = cells.haven[i]; } else { - min = cells.c[i].sort((a, b) => h[a] - h[b])[0]; + min = cells.c[i].sort((a: number, b: number) => h[a] - h[b])[0]; } // cells is depressed @@ -124,7 +147,7 @@ window.Rivers = (function () { }); } - function flowDown(toCell, fromFlux, river) { + const flowDown = (toCell: number, fromFlux: number, river: number) => { const toFlux = cells.fl[toCell] - cells.conf[toCell]; const toRiver = cells.r[toCell]; @@ -144,7 +167,7 @@ window.Rivers = (function () { // pour water to the water body const waterBody = features[cells.f[toCell]]; if (waterBody.type === "lake") { - if (!waterBody.river || fromFlux > waterBody.enteringFlux) { + if (!waterBody.river || fromFlux > (waterBody.enteringFlux as number)) { waterBody.river = river; waterBody.enteringFlux = fromFlux; } @@ -160,13 +183,13 @@ window.Rivers = (function () { addCellToRiver(toCell, river); } - function defineRivers() { + const defineRivers = () => { // re-initialize rivers and confluence arrays cells.r = new Uint16Array(cells.i.length); cells.conf = new Uint16Array(cells.i.length); pack.rivers = []; - const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); + const defaultWidthFactor = rn(1 / ((pointsInput.dataset.cells as any) / 10000) ** 0.25, 2); const mainStemWidthFactor = defaultWidthFactor * 1.2; for (const key in riversData) { @@ -187,12 +210,12 @@ window.Rivers = (function () { const parent = riverParents[key] || 0; const widthFactor = !parent || parent === riverId ? mainStemWidthFactor : defaultWidthFactor; - const meanderedPoints = addMeandering(riverCells); + const meanderedPoints = this.addMeandering(riverCells); const discharge = cells.fl[mouth]; // m3 in second - const length = getApproximateLength(meanderedPoints); - const sourceWidth = getSourceWidth(cells.fl[source]); - const width = getWidth( - getOffset({ + const length = this.getApproximateLength(meanderedPoints); + const sourceWidth = this.getSourceWidth(cells.fl[source]); + const width = this.getWidth( + this.getOffset({ flux: discharge, pointIndex: meanderedPoints.length, widthFactor, @@ -211,19 +234,19 @@ window.Rivers = (function () { sourceWidth, parent, cells: riverCells - }); + } as River); } } - function downcutRivers() { + const downcutRivers = () => { const MAX_DOWNCUT = 5; for (const i of pack.cells.i) { if (cells.h[i] < 35) continue; // don't donwcut lowlands if (!cells.fl[i]) continue; - const higherCells = cells.c[i].filter(c => cells.h[c] > cells.h[i]); - const higherFlux = higherCells.reduce((acc, c) => acc + cells.fl[c], 0) / higherCells.length; + const higherCells = cells.c[i].filter((c: number) => cells.h[c] > cells.h[i]); + const higherFlux = higherCells.reduce((acc: number, c: number) => acc + cells.fl[c], 0) / higherCells.length; if (!higherFlux) continue; const downcut = Math.floor(cells.fl[i] / higherFlux); @@ -231,48 +254,68 @@ window.Rivers = (function () { } } - function calculateConfluenceFlux() { + const calculateConfluenceFlux = () => { for (const i of cells.i) { if (!cells.conf[i]) continue; const sortedInflux = cells.c[i] - .filter(c => cells.r[c] && h[c] > h[i]) - .map(c => cells.fl[c]) - .sort((a, b) => b - a); - cells.conf[i] = sortedInflux.reduce((acc, flux, index) => (index ? acc + flux : acc), 0); + .filter((c: number) => cells.r[c] && h[c] > h[i]) + .map((c: number) => cells.fl[c]) + .sort((a: number, b: number) => b - a); + cells.conf[i] = sortedInflux.reduce((acc: number, flux: number, index: number) => (index ? acc + flux : acc), 0); } } + + cells.fl = new Uint16Array(cells.i.length); // water flux array + cells.r = new Uint16Array(cells.i.length); // rivers array + cells.conf = new Uint8Array(cells.i.length); // confluences array + let riverNext = 1; // first river id is 1 + + const h = this.alterHeights(); + Lakes.detectCloseLakes(h); + this.resolveDepressions(h); + drainWater(); + defineRivers(); + + calculateConfluenceFlux(); + Lakes.cleanupLakeData(); + + if (allowErosion) { + cells.h = Uint8Array.from(h); // apply gradient + downcutRivers(); // downcut river beds + } + + TIME && console.timeEnd("generateRivers"); }; - // add distance to water value to land cells to make map less depressed - const alterHeights = () => { - const {h, c, t} = pack.cells; + alterHeights(): number[] { + const {h, c, t} = pack.cells as {h: Uint8Array, c: number[][], t: Uint8Array}; return Array.from(h).map((h, i) => { if (h < 20 || t[i] < 1) return h; - return h + t[i] / 100 + d3.mean(c[i].map(c => t[c])) / 10000; + return h + t[i] / 100 + (mean(c[i].map(c => t[c])) as number) / 10000; }); }; // depression filling algorithm (for a correct water flux modeling) - const resolveDepressions = function (h) { + resolveDepressions(h: number[]) { const {cells, features} = pack; - const maxIterations = +document.getElementById("resolveDepressionsStepsOutput").value; + const maxIterations = +(document.getElementById("resolveDepressionsStepsOutput") as HTMLInputElement)?.value; const checkLakeMaxIteration = maxIterations * 0.85; const elevateLakeMaxIteration = maxIterations * 0.75; - const height = i => features[cells.f[i]].height || h[i]; // height of lake or specific cell + const height = (i: number) => features[cells.f[i]].height || h[i]; // height of lake or specific cell - const lakes = features.filter(f => f.type === "lake"); - const land = cells.i.filter(i => h[i] >= 20 && !cells.b[i]); // exclude near-border cells - land.sort((a, b) => h[a] - h[b]); // lowest cells go first + const lakes = features.filter((feature) => feature.type === "lake"); + const land = cells.i.filter((i: number) => h[i] >= 20 && !cells.b[i]); // exclude near-border cells + land.sort((a: number, b: number) => h[a] - h[b]); // lowest cells go first const progress = []; let depressions = Infinity; let prevDepressions = null; for (let iteration = 0; depressions && iteration < maxIterations; iteration++) { - if (progress.length > 5 && d3.sum(progress) > 0) { + if (progress.length > 5 && sum(progress) > 0) { // bad progress, abort and set heights back - h = alterHeights(); + h = this.alterHeights(); depressions = progress[0]; break; } @@ -282,23 +325,23 @@ window.Rivers = (function () { if (iteration < checkLakeMaxIteration) { for (const l of lakes) { if (l.closed) continue; - const minHeight = d3.min(l.shoreline.map(s => h[s])); + const minHeight = min(l.shoreline.map((s: number) => h[s])) as number; if (minHeight >= 100 || l.height > minHeight) continue; if (iteration > elevateLakeMaxIteration) { - l.shoreline.forEach(i => (h[i] = cells.h[i])); - l.height = d3.min(l.shoreline.map(s => h[s])) - 1; + l.shoreline.forEach((i: number) => (h[i] = cells.h[i])); + l.height = (min(l.shoreline.map((s: number) => h[s])) as number) - 1; l.closed = true; continue; } depressions++; - l.height = minHeight + 0.2; + l.height = (minHeight as number) + 0.2; } } for (const i of land) { - const minHeight = d3.min(cells.c[i].map(c => height(c))); + const minHeight = min(cells.c[i].map((c: number) => height(c))) as number; if (minHeight >= 100 || h[i] > minHeight) continue; depressions++; @@ -312,12 +355,11 @@ window.Rivers = (function () { depressions && WARN && console.warn(`Unresolved depressions: ${depressions}. Edit heightmap to fix`); }; - // add points at 1/3 and 2/3 of a line between adjacents river cells - const addMeandering = function (riverCells, riverPoints = null, meandering = 0.5) { + addMeandering(riverCells: number[], riverPoints = null, meandering = 0.5): [number, number, number][] { const {fl, h} = pack.cells; const meandered = []; const lastStep = riverCells.length - 1; - const points = getRiverPoints(riverCells, riverPoints); + const points = this.getRiverPoints(riverCells, riverPoints); let step = h[riverCells[0]] < 20 ? 1 : 10; for (let i = 0; i <= lastStep; i++, step++) { @@ -360,20 +402,20 @@ window.Rivers = (function () { } } - return meandered; + return meandered as [number, number, number][]; }; - const getRiverPoints = (riverCells, riverPoints) => { + getRiverPoints(riverCells: number[], riverPoints: [number, number][] | null) { if (riverPoints) return riverPoints; const {p} = pack.cells; return riverCells.map((cell, i) => { - if (cell === -1) return getBorderPoint(riverCells[i - 1]); + if (cell === -1) return this.getBorderPoint(riverCells[i - 1]); return p[cell]; }); }; - const getBorderPoint = i => { + getBorderPoint(i: number) { const [x, y] = pack.cells.p[i]; const min = Math.min(y, graphHeight - y, x, graphWidth - x); if (min === y) return [x, 0]; @@ -382,27 +424,23 @@ window.Rivers = (function () { return [graphWidth, y]; }; - const FLUX_FACTOR = 500; - const MAX_FLUX_WIDTH = 1; - const LENGTH_FACTOR = 200; - const LENGTH_STEP_WIDTH = 1 / LENGTH_FACTOR; - const LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / LENGTH_FACTOR); - - const getOffset = ({flux, pointIndex, widthFactor, startingWidth}) => { + getOffset({flux, pointIndex, widthFactor, startingWidth}: {flux: number, pointIndex: number, widthFactor: number, startingWidth: number}) { if (pointIndex === 0) return startingWidth; - const fluxWidth = Math.min(flux ** 0.7 / FLUX_FACTOR, MAX_FLUX_WIDTH); - const lengthWidth = pointIndex * LENGTH_STEP_WIDTH + (LENGTH_PROGRESSION[pointIndex] || LENGTH_PROGRESSION.at(-1)); + const fluxWidth = Math.min(flux ** 0.7 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH); + const lengthWidth = pointIndex * this.LENGTH_STEP_WIDTH + (this.LENGTH_PROGRESSION[pointIndex] || this.LENGTH_PROGRESSION.at(-1) as number); return widthFactor * (lengthWidth + fluxWidth) + startingWidth; }; - const getSourceWidth = flux => rn(Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH), 2); + getSourceWidth(flux: number) { + return rn(Math.min(flux ** 0.9 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH), 2); + } // build polygon from a list of points and calculated offset (width) - const getRiverPath = (points, widthFactor, startingWidth) => { - lineGen.curve(d3.curveCatmullRom.alpha(0.1)); - const riverPointsLeft = []; - const riverPointsRight = []; + getRiverPath(points: [number, number, number][], widthFactor: number, startingWidth: number) { + this.lineGen.curve(curveCatmullRom.alpha(0.1)); + const riverPointsLeft: [number, number][] = []; + const riverPointsRight: [number, number][] = []; let flux = 0; for (let pointIndex = 0; pointIndex < points.length; pointIndex++) { @@ -411,7 +449,7 @@ window.Rivers = (function () { const [x2, y2] = points[pointIndex + 1] || points[pointIndex]; if (pointFlux > flux) flux = pointFlux; - const offset = getOffset({flux, pointIndex, widthFactor, startingWidth}); + const offset = this.getOffset({flux, pointIndex, widthFactor, startingWidth}); const angle = Math.atan2(y0 - y2, x0 - x2); const sinOffset = Math.sin(angle) * offset; const cosOffset = Math.cos(angle) * offset; @@ -420,63 +458,52 @@ window.Rivers = (function () { riverPointsRight.push([x1 + sinOffset, y1 - cosOffset]); } - const right = lineGen(riverPointsRight.reverse()); - let left = lineGen(riverPointsLeft); + const right = this.lineGen(riverPointsRight.reverse()); + let left = this.lineGen(riverPointsLeft) || ""; left = left.substring(left.indexOf("C")); return round(right + left, 1); }; - const specify = function () { + specify() { const rivers = pack.rivers; if (!rivers.length) return; for (const river of rivers) { - river.basin = getBasin(river.i); - river.name = getName(river.mouth); - river.type = getType(river); + river.basin = this.getBasin(river.i); + river.name = this.getName(river.mouth); + river.type = this.getType(river); } }; - const getName = function (cell) { + getName(cell: number) { return Names.getCulture(pack.cells.culture[cell]); }; - // weighted arrays of river type names - const riverTypes = { - main: { - big: {River: 1}, - small: {Creek: 9, River: 3, Brook: 3, Stream: 1} - }, - fork: { - big: {Fork: 1}, - small: {Branch: 1} - } - }; - - let smallLength = null; - const getType = function ({i, length, parent}) { - if (smallLength === null) { + getType({i, length, parent}: River) { + if (this.smallLength === null) { const threshold = Math.ceil(pack.rivers.length * 0.15); - smallLength = pack.rivers.map(r => r.length || 0).sort((a, b) => a - b)[threshold]; + this.smallLength = pack.rivers.map(r => r.length || 0).sort((a: number, b: number) => a - b)[threshold]; } - const isSmall = length < smallLength; + const isSmall: boolean = length < (this.smallLength as number); const isFork = each(3)(i) && parent && parent !== i; - return rw(riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]); + return rw(this.riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]); }; - const getApproximateLength = points => { + getApproximateLength(points: [number, number, number][]) { const length = points.reduce((s, v, i, p) => s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0), 0); return rn(length, 2); }; // Real mouth width examples: Amazon 6000m, Volga 6000m, Dniepr 3000m, Mississippi 1300m, Themes 900m, // Danube 800m, Daugava 600m, Neva 500m, Nile 450m, Don 400m, Wisla 300m, Pripyat 150m, Bug 140m, Muchavets 40m - const getWidth = offset => rn((offset / 1.5) ** 1.8, 2); // mouth width in km + getWidth(offset: number) { + return rn((offset / 1.5) ** 1.8, 2); // mouth width in km + }; // remove river and all its tributaries - const remove = function (id) { + remove(id: number) { const cells = pack.cells; const riversToRemove = pack.rivers.filter(r => r.i === id || r.parent === id || r.basin === id).map(r => r.i); riversToRemove.forEach(r => rivers.select("#river" + r).remove()); @@ -489,32 +516,15 @@ window.Rivers = (function () { pack.rivers = pack.rivers.filter(r => !riversToRemove.includes(r.i)); }; - const getBasin = function (r) { + getBasin(r: number): number { const parent = pack.rivers.find(river => river.i === r)?.parent; if (!parent || r === parent) return r; - return getBasin(parent); + return this.getBasin(parent); }; - const getNextId = function (rivers) { + getNextId(rivers: {i: number}[]) { return rivers.length ? Math.max(...rivers.map(r => r.i)) + 1 : 1; }; +} - return { - generate, - alterHeights, - resolveDepressions, - addMeandering, - getRiverPath, - specify, - getName, - getType, - getBasin, - getWidth, - getOffset, - getSourceWidth, - getApproximateLength, - getRiverPoints, - remove, - getNextId - }; -})(); +window.Rivers = new RiverModule() \ No newline at end of file diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts new file mode 100644 index 00000000..23f464df --- /dev/null +++ b/src/types/PackedGraph.ts @@ -0,0 +1,36 @@ +import type { PackedGraphFeature } from "../modules/features"; +import type { River } from "../modules/river-generator"; + + +type TypedArray = Uint8Array | Uint16Array | Uint32Array | Int8Array | Int16Array | Float32Array | Float64Array; + +export interface PackedGraph { + cells: { + i: number[]; // cell indices + c: number[][]; // neighboring cells + v: number[][]; // neighboring vertices + p: [number, number][]; // cell polygon points + b: boolean[]; // cell is on border + h: TypedArray; // cell heights + t: TypedArray; // cell terrain types + r: Uint16Array; // river id passing through cell + f: Uint16Array; // feature id occupying cell + fl: TypedArray; // flux presence in cell + conf: TypedArray; // cell water confidence + haven: TypedArray; // cell is a haven + g: number[]; // cell ground type + culture: number[]; // cell culture id + biome: TypedArray; // cell biome id + harbor: TypedArray; // cell harbour presence + }; + vertices: { + i: number[]; // vertex indices + c: [number, number, number][]; // neighboring cells + v: number[][]; // neighboring vertices + x: number[]; // x coordinates + y: number[]; // y coordinates + p: [number, number][]; // vertex points + }; + rivers: River[]; + features: PackedGraphFeature[]; +} \ No newline at end of file diff --git a/src/types/global.ts b/src/types/global.ts new file mode 100644 index 00000000..1f37d64e --- /dev/null +++ b/src/types/global.ts @@ -0,0 +1,33 @@ +import type { Selection } from 'd3'; +import { PackedGraph } from "./PackedGraph"; + +declare global { + var seed: string; + var pack: PackedGraph; + var grid: any; + var graphHeight: number; + var graphWidth: number; + + var TIME: boolean; + var WARN: boolean; + var ERROR: boolean; + + var heightmapTemplates: any; + var Names: any; + + var pointsInput: HTMLInputElement; + var heightExponentInput: HTMLInputElement; + + var rivers: Selection; + var oceanLayers: Selection; + var biomesData: { + i: number[]; + name: string[]; + color: string[]; + biomesMatrix: Uint8Array[]; + habitability: number[]; + iconsDensity: number[]; + icons: string[][]; + cost: number[]; + }; +} \ No newline at end of file diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts index d5f2fc9a..24f7501c 100644 --- a/src/utils/commonUtils.ts +++ b/src/utils/commonUtils.ts @@ -11,7 +11,7 @@ import { last } from "./arrayUtils"; * @param secure - Secure clipping to avoid edge artifacts * @returns Clipped polygon points */ -export const clipPoly = (points: [number, number][], graphWidth: number, graphHeight: number, secure: number = 0) => { +export const clipPoly = (points: [number, number][], graphWidth?: number, graphHeight?: number, secure: number = 0) => { if (points.length < 2) return points; if (points.some(point => point === undefined)) { window.ERROR && console.error("Undefined point in clipPoly", points); diff --git a/tests/e2e/layers.spec.ts-snapshots/rivers.html b/tests/e2e/layers.spec.ts-snapshots/rivers.html index 087b4d8d..81b2fcf9 100644 --- a/tests/e2e/layers.spec.ts-snapshots/rivers.html +++ b/tests/e2e/layers.spec.ts-snapshots/rivers.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From e37fce1eed2e1add0b08ca00440d1d4ff46e97b1 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Mon, 26 Jan 2026 18:34:35 +0100 Subject: [PATCH 10/24] fix: GeoJSON export (#1283) * fix: use global vars instead of window. * feat: add GitHub Actions workflow for unit tests * fix: change mapCoordinates declaration from let to var for compatibility --- .github/workflows/unit-tests.yml | 17 ++++++ public/main.js | 2 +- src/utils/commonUtils.test.ts | 97 ++++++++++++++++++++++++++++++++ src/utils/commonUtils.ts | 5 ++ src/utils/index.ts | 8 +-- 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/unit-tests.yml create mode 100644 src/utils/commonUtils.test.ts diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000..7d02cf0f --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,17 @@ +name: Unit Tests +on: + pull_request: + branches: [ master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Install dependencies + run: npm ci + - name: Run Unit tests + run: npm run test \ No newline at end of file diff --git a/public/main.js b/public/main.js index e922c44e..c0ac9d11 100644 --- a/public/main.js +++ b/public/main.js @@ -187,7 +187,7 @@ const onZoom = debounce(function () { }, 50); const zoom = d3.zoom().scaleExtent([1, 20]).on("zoom", onZoom); -let mapCoordinates = {}; // map coordinates on globe +var mapCoordinates = {}; // map coordinates on globe let populationRate = +byId("populationRateInput").value; let distanceScale = +byId("distanceScaleInput").value; let urbanization = +byId("urbanizationInput").value; diff --git a/src/utils/commonUtils.test.ts b/src/utils/commonUtils.test.ts new file mode 100644 index 00000000..6ca595d3 --- /dev/null +++ b/src/utils/commonUtils.test.ts @@ -0,0 +1,97 @@ +import { expect, describe, it } from 'vitest' +import { getLongitude, getLatitude, getCoordinates } from './commonUtils' + +describe('getLongitude', () => { + const mapCoordinates = { lonW: -10, lonT: 20 }; + const graphWidth = 1000; + + it('should calculate longitude at the left edge (x=0)', () => { + expect(getLongitude(0, mapCoordinates, graphWidth, 2)).toBe(-10); + }); + + it('should calculate longitude at the right edge (x=graphWidth)', () => { + expect(getLongitude(1000, mapCoordinates, graphWidth, 2)).toBe(10); + }); + + it('should calculate longitude at the center (x=graphWidth/2)', () => { + expect(getLongitude(500, mapCoordinates, graphWidth, 2)).toBe(0); + }); + + it('should respect decimal precision', () => { + // 333/1000 * 20 = 6.66, -10 + 6.66 = -3.34 + expect(getLongitude(333, mapCoordinates, graphWidth, 4)).toBe(-3.34); + }); + + it('should handle different map coordinate ranges', () => { + const wideMap = { lonW: -180, lonT: 360 }; + expect(getLongitude(500, wideMap, graphWidth, 2)).toBe(0); + expect(getLongitude(0, wideMap, graphWidth, 2)).toBe(-180); + expect(getLongitude(1000, wideMap, graphWidth, 2)).toBe(180); + }); +}); + +describe('getLatitude', () => { + const mapCoordinates = { latN: 60, latT: 40 }; + const graphHeight = 800; + + it('should calculate latitude at the top edge (y=0)', () => { + expect(getLatitude(0, mapCoordinates, graphHeight, 2)).toBe(60); + }); + + it('should calculate latitude at the bottom edge (y=graphHeight)', () => { + expect(getLatitude(800, mapCoordinates, graphHeight, 2)).toBe(20); + }); + + it('should calculate latitude at the center (y=graphHeight/2)', () => { + expect(getLatitude(400, mapCoordinates, graphHeight, 2)).toBe(40); + }); + + it('should respect decimal precision', () => { + // 60 - (333/800 * 40) = 60 - 16.65 = 43.35 + expect(getLatitude(333, mapCoordinates, graphHeight, 4)).toBe(43.35); + }); + + it('should handle equator-centered maps', () => { + const equatorMap = { latN: 45, latT: 90 }; + expect(getLatitude(400, equatorMap, graphHeight, 2)).toBe(0); + }); +}); + +describe('getCoordinates', () => { + const mapCoordinates = { lonW: -10, lonT: 20, latN: 60, latT: 40 }; + const graphWidth = 1000; + const graphHeight = 800; + + it('should return [longitude, latitude] tuple', () => { + const result = getCoordinates(500, 400, mapCoordinates, graphWidth, graphHeight, 2); + expect(result).toEqual([0, 40]); + }); + + it('should calculate coordinates at top-left corner', () => { + const result = getCoordinates(0, 0, mapCoordinates, graphWidth, graphHeight, 2); + expect(result).toEqual([-10, 60]); + }); + + it('should calculate coordinates at bottom-right corner', () => { + const result = getCoordinates(1000, 800, mapCoordinates, graphWidth, graphHeight, 2); + expect(result).toEqual([10, 20]); + }); + + it('should respect decimal precision for both coordinates', () => { + const result = getCoordinates(333, 333, mapCoordinates, graphWidth, graphHeight, 4); + expect(result[0]).toBe(-3.34); // longitude + expect(result[1]).toBe(43.35); // latitude + }); + + it('should use default precision of 2 decimals', () => { + const result = getCoordinates(333, 333, mapCoordinates, graphWidth, graphHeight); + expect(result[0]).toBe(-3.34); + expect(result[1]).toBe(43.35); + }); + + it('should handle global map coordinates', () => { + const globalMap = { lonW: -180, lonT: 360, latN: 90, latT: 180 }; + const result = getCoordinates(500, 400, globalMap, graphWidth, graphHeight, 2); + expect(result).toEqual([0, 0]); // center of the world + }); +}); diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts index 24f7501c..f326c067 100644 --- a/src/utils/commonUtils.ts +++ b/src/utils/commonUtils.ts @@ -317,4 +317,9 @@ declare global { getLatitude: typeof getLatitude; getCoordinates: typeof getCoordinates; } + + // Global variables defined in main.js + var mapCoordinates: { latT?: number; latN?: number; latS?: number; lonT?: number; lonW?: number; lonE?: number }; + var graphWidth: number; + var graphHeight: number; } \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 73581a38..e75d88cb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -116,7 +116,7 @@ window.isLand = (i: number) => isLand(i, (window as any).pack); window.isWater = (i: number) => isWater(i, (window as any).pack); import { clipPoly, getSegmentId, debounce, throttle, parseError, getBase64, openURL, wiki, link, isCtrlClick, generateDate, getLongitude, getLatitude, getCoordinates, initializePrompt } from "./commonUtils"; -window.clipPoly = (points: [number, number][], secure?: number) => clipPoly(points, (window as any).graphWidth, (window as any).graphHeight, secure); +window.clipPoly = (points: [number, number][], secure?: number) => clipPoly(points, graphWidth, graphHeight, secure); window.getSegmentId = getSegmentId; window.debounce = debounce; window.throttle = throttle; @@ -127,9 +127,9 @@ window.wiki = wiki; window.link = link; window.isCtrlClick = isCtrlClick; window.generateDate = generateDate; -window.getLongitude = (x: number, decimals?: number) => getLongitude(x, (window as any).mapCoordinates, (window as any).graphWidth, decimals); -window.getLatitude = (y: number, decimals?: number) => getLatitude(y, (window as any).mapCoordinates, (window as any).graphHeight, decimals); -window.getCoordinates = (x: number, y: number, decimals?: number) => getCoordinates(x, y, (window as any).mapCoordinates, (window as any).graphWidth, (window as any).graphHeight, decimals); +window.getLongitude = (x: number, decimals?: number) => getLongitude(x, mapCoordinates, graphWidth, decimals); +window.getLatitude = (y: number, decimals?: number) => getLatitude(y, mapCoordinates, graphHeight, decimals); +window.getCoordinates = (x: number, y: number, decimals?: number) => getCoordinates(x, y, mapCoordinates, graphWidth, graphHeight, decimals); // Initialize prompt when DOM is ready if (document.readyState === 'loading') { From 9db40a523032c297b943930092a3381cd3ab7c09 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Mon, 26 Jan 2026 22:30:28 +0100 Subject: [PATCH 11/24] chore: add biome for linting/formatting + CI action for linting in SRC folder (#1284) * chore: add npm + vite for progressive enhancement * fix: update Dockerfile to copy only the dist folder contents * fix: update Dockerfile to use multi-stage build for optimized production image * fix: correct nginx config file copy command in Dockerfile * chore: add netlify configuration for build and redirects * fix: add NODE_VERSION to environment in Netlify configuration * remove wrong dist folder * Update package.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: split public and src * migrating all util files from js to ts * feat: Implement HeightmapGenerator and Voronoi module - Added HeightmapGenerator class for generating heightmaps with various tools (Hill, Pit, Range, Trough, Strait, etc.). - Introduced Voronoi class for creating Voronoi diagrams using Delaunator. - Updated index.html to include new modules. - Created index.ts to manage module imports. - Enhanced arrayUtils and graphUtils with type definitions and improved functionality. - Added utility functions for generating grids and calculating Voronoi cells. * chore: add GitHub Actions workflow for deploying to GitHub Pages * fix: update branch name in GitHub Actions workflow from 'main' to 'master' * chore: update package.json to specify Node.js engine version and remove unused launch.json * Initial plan * Update copilot guidelines to reflect NPM/Vite/TypeScript migration Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com> * Update src/modules/heightmap-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/utils/graphUtils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/heightmap-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: Add TIME and ERROR variables to global scope in HeightmapGenerator * fix: Update base path in vite.config.ts for Netlify deployment * refactor: Migrate features to a new module and remove legacy script reference * refactor: Update feature interfaces and improve type safety in FeatureModule * refactor: Add documentation for markupPack and defineGroups methods in FeatureModule * refactor: Remove legacy ocean-layers.js and migrate functionality to ocean-layers.ts * refactor: Remove river-generator.js script reference and migrate river generation logic to river-generator.ts * refactor: Remove river-generator.js reference and add biomes module * refactor: Migrate lakes functionality to lakes.ts and update related interfaces * refactor: clean up global variable declarations and improve type definitions * refactor: update shoreline calculation and improve type imports in PackedGraph * fix: e2e tests * chore: add biome for linting/formatting * chore: add linting workflow using Biome * refactor: improve code readability by standardizing string quotes and simplifying function calls --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Azgaar Co-authored-by: Azgaar Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com> --- .github/workflows/lint.yml | 22 ++ biome.json | 58 +++++ package-lock.json | 165 +++++++++++++- package.json | 5 +- src/modules/biomes.ts | 117 +++++++--- src/modules/features.ts | 161 ++++++++++--- src/modules/heightmap-generator.ts | 347 +++++++++++++++++++---------- src/modules/index.ts | 2 +- src/modules/lakes.ts | 74 +++--- src/modules/ocean-layers.ts | 60 +++-- src/modules/river-generator.ts | 266 +++++++++++++++------- src/modules/voronoi.ts | 68 ++++-- src/types/PackedGraph.ts | 12 +- src/types/global.ts | 8 +- src/utils/arrayUtils.ts | 51 +++-- src/utils/colorUtils.ts | 56 +++-- src/utils/commonUtils.test.ts | 95 +++++--- src/utils/commonUtils.ts | 183 ++++++++++----- src/utils/debugUtils.ts | 50 +++-- src/utils/functionUtils.ts | 28 ++- src/utils/graphUtils.ts | 286 +++++++++++++++++------- src/utils/index.ts | 201 ++++++++++++++--- src/utils/languageUtils.ts | 114 +++++----- src/utils/nodeUtils.ts | 12 +- src/utils/numberUtils.ts | 14 +- src/utils/pathUtils.ts | 166 ++++++++++---- src/utils/polyfills.ts | 27 ++- src/utils/probabilityUtils.ts | 53 +++-- src/utils/stringUtils.test.ts | 10 +- src/utils/stringUtils.ts | 22 +- src/utils/unitUtils.ts | 50 +++-- 31 files changed, 2001 insertions(+), 782 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 biome.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..dc7ad769 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Code quality + +on: + push: + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Setup Biome + uses: biomejs/setup-biome@v2 + with: + version: latest + - name: Run Biome + run: biome ci . \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..84644252 --- /dev/null +++ b/biome.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["src/**/*.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useTemplate": { + "level": "warn", + "fix": "safe" + }, + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noGlobalIsNan": { + "level": "error", + "fix": "safe" + } + }, + "correctness": { + "noUnusedVariables": { + "level": "error", + "fix": "safe" + }, + "useParseIntRadix": { + "fix": "safe", + "level": "error" + } + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 55b1e80f..428c81c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "polylabel": "^2.0.1" }, "devDependencies": { + "@biomejs/biome": "2.3.12", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -31,6 +32,169 @@ "node": ">=24.0.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.12.tgz", + "integrity": "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.12", + "@biomejs/cli-darwin-x64": "2.3.12", + "@biomejs/cli-linux-arm64": "2.3.12", + "@biomejs/cli-linux-arm64-musl": "2.3.12", + "@biomejs/cli-linux-x64": "2.3.12", + "@biomejs/cli-linux-x64-musl": "2.3.12", + "@biomejs/cli-win32-arm64": "2.3.12", + "@biomejs/cli-win32-x64": "2.3.12" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.12.tgz", + "integrity": "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.12.tgz", + "integrity": "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.12.tgz", + "integrity": "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.12.tgz", + "integrity": "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.12.tgz", + "integrity": "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.12.tgz", + "integrity": "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.12.tgz", + "integrity": "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.12.tgz", + "integrity": "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -2025,7 +2189,6 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.57.0" }, diff --git a/package.json b/package.json index 9d3fbe11..4656ff9a 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,12 @@ "preview": "vite preview", "test": "vitest", "test:browser": "vitest --config=vitest.browser.config.ts", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "lint": "biome check --write", + "format": "biome format --write" }, "devDependencies": { + "@biomejs/biome": "2.3.12", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", diff --git a/src/modules/biomes.ts b/src/modules/biomes.ts index 321ea77a..b708589f 100644 --- a/src/modules/biomes.ts +++ b/src/modules/biomes.ts @@ -1,4 +1,4 @@ -import { range, mean } from "d3"; +import { mean, range } from "d3"; import { rn } from "../utils"; declare global { @@ -22,7 +22,7 @@ class BiomesModule { "Taiga", "Tundra", "Glacier", - "Wetland" + "Wetland", ]; const color: string[] = [ @@ -38,33 +38,54 @@ class BiomesModule { "#4b6b32", "#96784b", "#d5e7eb", - "#0b9131" + "#0b9131", ]; - const habitability: number[] = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12]; - const iconsDensity: number[] = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250]; - const icons: Array<{[key: string]: number}> = [ - {}, - {dune: 3, cactus: 6, deadTree: 1}, - {dune: 9, deadTree: 1}, - {acacia: 1, grass: 9}, - {grass: 1}, - {acacia: 8, palm: 1}, - {deciduous: 1}, - {acacia: 5, palm: 3, deciduous: 1, swamp: 1}, - {deciduous: 6, swamp: 1}, - {conifer: 1}, - {grass: 1}, - {}, - {swamp: 1} + const habitability: number[] = [ + 0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12, ]; - const cost: number[] = [10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150]; // biome movement cost + const iconsDensity: number[] = [ + 0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250, + ]; + const icons: Array<{ [key: string]: number }> = [ + {}, + { dune: 3, cactus: 6, deadTree: 1 }, + { dune: 9, deadTree: 1 }, + { acacia: 1, grass: 9 }, + { grass: 1 }, + { acacia: 8, palm: 1 }, + { deciduous: 1 }, + { acacia: 5, palm: 3, deciduous: 1, swamp: 1 }, + { deciduous: 6, swamp: 1 }, + { conifer: 1 }, + { grass: 1 }, + {}, + { swamp: 1 }, + ]; + const cost: number[] = [ + 10, 200, 150, 60, 50, 70, 70, 80, 90, 200, 1000, 5000, 150, + ]; // biome movement cost const biomesMatrix: Uint8Array[] = [ // hot ↔ cold [>19°C; <-4°C]; dry ↕ wet - new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 10]), - new Uint8Array([3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10]), - new Uint8Array([7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 10, 10]) + new Uint8Array([ + 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 10, + ]), + new Uint8Array([ + 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 5, 6, 6, 6, 6, 6, 6, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, + 10, 10, + ]), + new Uint8Array([ + 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, + 10, 10, + ]), ]; // parse icons weighted array into a simple array @@ -79,14 +100,29 @@ class BiomesModule { parsedIcons[i] = parsed; } - return {i: range(0, name.length), name, color, biomesMatrix, habitability, iconsDensity, icons: parsedIcons, cost}; - }; + return { + i: range(0, name.length), + name, + color, + biomesMatrix, + habitability, + iconsDensity, + icons: parsedIcons, + cost, + }; + } define() { TIME && console.time("defineBiomes"); - const {fl: flux, r: riverIds, h: heights, c: neighbors, g: gridReference} = pack.cells; - const {temp, prec} = grid.cells; + const { + fl: flux, + r: riverIds, + h: heights, + c: neighbors, + g: gridReference, + } = pack.cells; + const { temp, prec } = grid.cells; pack.cells.biome = new Uint8Array(pack.cells.i.length); // biomes array const calculateMoisture = (cellId: number) => { @@ -94,23 +130,36 @@ class BiomesModule { if (riverIds[cellId]) moisture += Math.max(flux[cellId] / 10, 2); const moistAround = neighbors[cellId] - .filter((neibCellId: number) => heights[neibCellId] >= this.MIN_LAND_HEIGHT) + .filter( + (neibCellId: number) => heights[neibCellId] >= this.MIN_LAND_HEIGHT, + ) .map((c: number) => prec[gridReference[c]]) .concat([moisture]); return rn(4 + (mean(moistAround) as number)); - } + }; for (let cellId = 0; cellId < heights.length; cellId++) { const height = heights[cellId]; - const moisture = height < this.MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId); + const moisture = + height < this.MIN_LAND_HEIGHT ? 0 : calculateMoisture(cellId); const temperature = temp[gridReference[cellId]]; - pack.cells.biome[cellId] = this.getId(moisture, temperature, height, Boolean(riverIds[cellId])); + pack.cells.biome[cellId] = this.getId( + moisture, + temperature, + height, + Boolean(riverIds[cellId]), + ); } TIME && console.timeEnd("defineBiomes"); } - getId(moisture: number, temperature: number, height: number, hasRiver: boolean) { + getId( + moisture: number, + temperature: number, + height: number, + hasRiver: boolean, + ) { if (height < 20) return 0; // all water cells: marine biome if (temperature < -5) return 11; // too cold: permafrost biome if (temperature >= 25 && !hasRiver && moisture < 8) return 1; // too hot and dry: hot desert biome diff --git a/src/modules/features.ts b/src/modules/features.ts index bedb48ff..06984af6 100644 --- a/src/modules/features.ts +++ b/src/modules/features.ts @@ -1,6 +1,16 @@ -import { clipPoly, connectVertices, createTypedArray, distanceSquared, isLand, isWater, rn, TYPED_ARRAY_MAX_VALUES, unique } from "../utils"; import Alea from "alea"; import { polygonArea } from "d3"; +import { + clipPoly, + connectVertices, + createTypedArray, + distanceSquared, + isLand, + isWater, + rn, + TYPED_ARRAY_MAX_VALUES, + unique, +} from "../utils"; declare global { var Features: FeatureModule; @@ -52,14 +62,24 @@ class FeatureModule { /** * calculate distance to coast for every cell */ - private markup({ distanceField, neighbors, start, increment, limit = TYPED_ARRAY_MAX_VALUES.INT8_MAX }: { + private markup({ + distanceField, + neighbors, + start, + increment, + limit = TYPED_ARRAY_MAX_VALUES.INT8_MAX, + }: { distanceField: Int8Array; neighbors: number[][]; start: number; increment: number; limit?: number; }) { - for (let distance = start, marked = Infinity; marked > 0 && distance !== limit; distance += increment) { + for ( + let distance = start, marked = Infinity; + marked > 0 && distance !== limit; + distance += increment + ) { marked = 0; const prevDistance = distance - increment; for (let cellId = 0; cellId < neighbors.length; cellId++) { @@ -115,11 +135,17 @@ class FeatureModule { const type = land ? "island" : border ? "ocean" : "lake"; features.push({ i: featureId, land, border, type }); - queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell + queue[0] = featureIds.indexOf(this.UNMARKED); // find unmarked cell } // markup deep ocean cells - this.markup({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 }); + this.markup({ + distanceField, + neighbors, + start: this.DEEP_WATER, + increment: -1, + limit: -10, + }); grid.cells.t = distanceField; grid.cells.f = featureIds; grid.features = [0, ...features]; @@ -132,15 +158,22 @@ class FeatureModule { */ markupPack() { const defineHaven = (cellId: number) => { - const waterCells = neighbors[cellId].filter((index: number) => isWater(index, pack)); - const distances = waterCells.map((neibCellId: number) => distanceSquared(cells.p[cellId], cells.p[neibCellId])); + const waterCells = neighbors[cellId].filter((index: number) => + isWater(index, pack), + ); + const distances = waterCells.map((neibCellId: number) => + distanceSquared(cells.p[cellId], cells.p[neibCellId]), + ); const closest = distances.indexOf(Math.min.apply(Math, distances)); haven[cellId] = waterCells[closest]; harbor[cellId] = waterCells.length; - } + }; - const getCellsData = (featureType: string, firstCell: number): [number, number[]] => { + const getCellsData = ( + featureType: string, + firstCell: number, + ): [number, number[]] => { if (featureType === "ocean") return [firstCell, []]; const getType = (cellId: number) => featureIds[cellId]; @@ -153,29 +186,55 @@ class FeatureModule { return [startCell, featureVertices]; function findOnBorderCell(firstCell: number) { - const isOnBorder = (cellId: number) => borderCells[cellId] || neighbors[cellId].some(ofDifferentType); + const isOnBorder = (cellId: number) => + borderCells[cellId] || neighbors[cellId].some(ofDifferentType); if (isOnBorder(firstCell)) return firstCell; const startCell = cells.i.filter(ofSameType).find(isOnBorder); if (startCell === undefined) - throw new Error(`Markup: firstCell ${firstCell} is not on the feature or map border`); + throw new Error( + `Markup: firstCell ${firstCell} is not on the feature or map border`, + ); return startCell; } function getFeatureVertices(startCell: number) { - const startingVertex = cells.v[startCell].find((v: number) => vertices.c[v].some(ofDifferentType)); + const startingVertex = cells.v[startCell].find((v: number) => + vertices.c[v].some(ofDifferentType), + ); if (startingVertex === undefined) - throw new Error(`Markup: startingVertex for cell ${startCell} is not found`); + throw new Error( + `Markup: startingVertex for cell ${startCell} is not found`, + ); - return connectVertices({ vertices, startingVertex, ofSameType, closeRing: false }); + return connectVertices({ + vertices, + startingVertex, + ofSameType, + closeRing: false, + }); } - } + }; - const addFeature = ({ firstCell, land, border, featureId, totalCells }: { firstCell: number; land: boolean; border: boolean; featureId: number; totalCells: number }): PackedGraphFeature => { + const addFeature = ({ + firstCell, + land, + border, + featureId, + totalCells, + }: { + firstCell: number; + land: boolean; + border: boolean; + featureId: number; + totalCells: number; + }): PackedGraphFeature => { const type = land ? "island" : border ? "ocean" : "lake"; const [startCell, featureVertices] = getCellsData(type, firstCell); - const points = clipPoly(featureVertices.map((vertex: number) => vertices.p[vertex])); + const points = clipPoly( + featureVertices.map((vertex: number) => vertices.p[vertex]), + ); const area = polygonArea(points); // feature perimiter area const absArea = Math.abs(rn(area)); @@ -193,20 +252,20 @@ class FeatureModule { }; if (type === "lake") { - if (area > 0) feature.vertices = (feature.vertices as number[]).reverse(); + if (area > 0) + feature.vertices = (feature.vertices as number[]).reverse(); feature.shoreline = unique( - (feature.vertices as number[]) - .flatMap( - vertexIndex => vertices.c[vertexIndex].filter((index) => isLand(index, pack)) - ) + (feature.vertices as number[]).flatMap((vertexIndex) => + vertices.c[vertexIndex].filter((index) => isLand(index, pack)), + ), ); feature.height = Lakes.getHeight(feature as PackedGraphFeature); } return { - ...feature + ...feature, } as PackedGraphFeature; - } + }; TIME && console.time("markupPack"); @@ -217,7 +276,10 @@ class FeatureModule { const distanceField = new Int8Array(packCellsNumber); // pack.cells.t const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f - const haven = createTypedArray({ maxValue: packCellsNumber, length: packCellsNumber }); // haven: opposite water cell + const haven = createTypedArray({ + maxValue: packCellsNumber, + length: packCellsNumber, + }); // haven: opposite water cell const harbor = new Uint8Array(packCellsNumber); // harbor: number of adjacent water cells const features: PackedGraphFeature[] = []; @@ -242,9 +304,15 @@ class FeatureModule { distanceField[neighborId] = this.WATER_COAST; if (!haven[cellId]) defineHaven(cellId); } else if (land && isNeibLand) { - if (distanceField[neighborId] === this.UNMARKED && distanceField[cellId] === this.LAND_COAST) + if ( + distanceField[neighborId] === this.UNMARKED && + distanceField[cellId] === this.LAND_COAST + ) distanceField[neighborId] = this.LANDLOCKED; - else if (distanceField[cellId] === this.UNMARKED && distanceField[neighborId] === this.LAND_COAST) + else if ( + distanceField[cellId] === this.UNMARKED && + distanceField[neighborId] === this.LAND_COAST + ) distanceField[cellId] = this.LANDLOCKED; } @@ -256,12 +324,25 @@ class FeatureModule { } } - features.push(addFeature({ firstCell, land, border, featureId, totalCells })); - queue[0] = featureIds.findIndex(f => f === this.UNMARKED); // find unmarked cell + features.push( + addFeature({ firstCell, land, border, featureId, totalCells }), + ); + queue[0] = featureIds.indexOf(this.UNMARKED); // find unmarked cell } - this.markup({ distanceField, neighbors, start: this.DEEPER_LAND, increment: 1 }); // markup pack land - this.markup({ distanceField, neighbors, start: this.DEEP_WATER, increment: -1, limit: -10 }); // markup pack water + this.markup({ + distanceField, + neighbors, + start: this.DEEPER_LAND, + increment: 1, + }); // markup pack land + this.markup({ + distanceField, + neighbors, + start: this.DEEP_WATER, + increment: -1, + limit: -10, + }); // markup pack water pack.cells.t = distanceField; pack.cells.f = featureIds; @@ -287,34 +368,40 @@ class FeatureModule { if (feature.cells > CONTINENT_MIN_SIZE) return "continent"; if (feature.cells > ISLAND_MIN_SIZE) return "island"; return "isle"; - } + }; const defineOceanGroup = (feature: PackedGraphFeature) => { if (feature.cells > OCEAN_MIN_SIZE) return "ocean"; if (feature.cells > SEA_MIN_SIZE) return "sea"; return "gulf"; - } + }; const defineLakeGroup = (feature: PackedGraphFeature) => { if (feature.temp < -3) return "frozen"; - if (feature.height > 60 && feature.cells < 10 && feature.firstCell % 10 === 0) return "lava"; + if ( + feature.height > 60 && + feature.cells < 10 && + feature.firstCell % 10 === 0 + ) + return "lava"; if (!feature.inlets && !feature.outlet) { if (feature.evaporation > feature.flux * 4) return "dry"; - if (feature.cells < 3 && feature.firstCell % 10 === 0) return "sinkhole"; + if (feature.cells < 3 && feature.firstCell % 10 === 0) + return "sinkhole"; } if (!feature.outlet && feature.evaporation > feature.flux) return "salt"; return "freshwater"; - } + }; const defineGroup = (feature: PackedGraphFeature) => { if (feature.type === "island") return defineIslandGroup(feature); if (feature.type === "ocean") return defineOceanGroup(feature); if (feature.type === "lake") return defineLakeGroup(feature); throw new Error(`Markup: unknown feature type ${feature.type}`); - } + }; for (const feature of pack.features) { if (!feature || feature.type === "ocean") continue; diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index a060ecdc..9a6d462a 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -1,14 +1,33 @@ import Alea from "alea"; import { range as d3Range, leastIndex, mean } from "d3"; -import { createTypedArray, byId, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils"; +import { + byId, + createTypedArray, + findGridCell, + getNumberInRange, + lim, + minmax, + P, + rand, +} from "../utils"; declare global { - var HeightmapGenerator: HeightmapGenerator; + var HeightmapGenerator: HeightmapModule; } -type Tool = "Hill" | "Pit" | "Range" | "Trough" | "Strait" | "Mask" | "Invert" | "Add" | "Multiply" | "Smooth"; +type Tool = + | "Hill" + | "Pit" + | "Range" + | "Trough" + | "Strait" + | "Mask" + | "Invert" + | "Add" + | "Multiply" + | "Smooth"; -class HeightmapGenerator { +class HeightmapModule { grid: any = null; heights: Uint8Array | null = null; blobPower: number = 0; @@ -17,9 +36,8 @@ class HeightmapGenerator { private clearData() { this.heights = null; this.grid = null; - }; + } - private getBlobPower(cells: number): number { const blobPowerMap: Record = { 1000: 0.93, @@ -34,11 +52,11 @@ class HeightmapGenerator { 70000: 0.9955, 80000: 0.996, 90000: 0.9964, - 100000: 0.9973 + 100000: 0.9973, }; return blobPowerMap[cells] || 0.98; } - + private getLinePower(cells: number): number { const linePowerMap: Record = { 1000: 0.75, @@ -53,38 +71,43 @@ class HeightmapGenerator { 70000: 0.88, 80000: 0.91, 90000: 0.92, - 100000: 0.93 + 100000: 0.93, }; - + return linePowerMap[cells] || 0.81; } - + private getPointInRange(range: string, length: number): number | undefined { if (typeof range !== "string") { window.ERROR && console.error("Range should be a string"); return; } - - const min = parseInt(range.split("-")[0]) / 100 || 0; - const max = parseInt(range.split("-")[1]) / 100 || min; + + const min = parseInt(range.split("-")[0], 10) / 100 || 0; + const max = parseInt(range.split("-")[1], 10) / 100 || min; return rand(min * length, max * length); } setGraph(graph: any) { - const {cellsDesired, cells, points} = graph; - this.heights = cells.h ? Uint8Array.from(cells.h) : createTypedArray({maxValue: 100, length: points.length}) as Uint8Array; + const { cellsDesired, cells, points } = graph; + this.heights = cells.h + ? Uint8Array.from(cells.h) + : (createTypedArray({ + maxValue: 100, + length: points.length, + }) as Uint8Array); this.blobPower = this.getBlobPower(cellsDesired); this.linePower = this.getLinePower(cellsDesired); this.grid = graph; - }; - + } + addHill(count: string, height: string, rangeX: string, rangeY: string): void { const addOneHill = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const change = new Uint8Array(this.heights.length); let limit = 0; let start: number; - let h = lim(getNumberInRange(height)); + const h = lim(getNumberInRange(height)); do { const x = this.getPointInRange(rangeX, graphWidth); @@ -106,17 +129,17 @@ class HeightmapGenerator { } this.heights = this.heights.map((h, i) => lim(h + change[i])); - } + }; const desiredHillCount = getNumberInRange(count); for (let i = 0; i < desiredHillCount; i++) { addOneHill(); } - }; + } addPit(count: string, height: string, rangeX: string, rangeY: string): void { const addOnePit = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const used = new Uint8Array(this.heights.length); let limit = 0; let start: number; @@ -138,24 +161,33 @@ class HeightmapGenerator { this.grid.cells.c[q].forEach((c: number) => { if (used[c] || this.heights === null) return; - this.heights[c] = lim(this.heights[c] - h * (Math.random() * 0.2 + 0.9)); + this.heights[c] = lim( + this.heights[c] - h * (Math.random() * 0.2 + 0.9), + ); used[c] = 1; queue.push(c); }); } - } + }; const desiredPitCount = getNumberInRange(count); for (let i = 0; i < desiredPitCount; i++) { addOnePit(); } - }; + } - addRange(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void { - if(!this.heights || !this.grid) return; + addRange( + count: string, + height: string, + rangeX: string, + rangeY: string, + startCellId?: number, + endCellId?: number, + ): void { + if (!this.heights || !this.grid) return; const addOneRange = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; // get main ridge const getRange = (cur: number, end: number) => { @@ -180,7 +212,7 @@ class HeightmapGenerator { } return range; - } + }; const used = new Uint8Array(this.heights.length); let h = lim(getNumberInRange(height)); @@ -192,32 +224,37 @@ class HeightmapGenerator { let dist = 0; let limit = 0; - let endY; - let endX; + let endY: number; + let endX: number; do { endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; dist = Math.abs(endY - startY) + Math.abs(endX - startX); limit++; - } while ((dist < graphWidth / 8 || dist > graphWidth / 3) && limit < 50); + } while ( + (dist < graphWidth / 8 || dist > graphWidth / 3) && + limit < 50 + ); startCellId = findGridCell(startX, startY, this.grid); endCellId = findGridCell(endX, endY, this.grid); } - let range = getRange(startCellId as number, endCellId as number); - + const range = getRange(startCellId as number, endCellId as number); // add height to ridge and cells around let queue = range.slice(); let i = 0; while (queue.length) { const frontier = queue.slice(); - (queue = []), i++; + queue = []; + i++; frontier.forEach((i: number) => { - if(!this.heights) return; - this.heights[i] = lim(this.heights[i] + h * (Math.random() * 0.3 + 0.85)); + if (!this.heights) return; + this.heights[i] = lim( + this.heights[i] + h * (Math.random() * 0.3 + 0.85), + ); }); h = h ** this.linePower - 1; if (h < 2) break; @@ -235,31 +272,42 @@ class HeightmapGenerator { range.forEach((cur: number, d: number) => { if (d % 6 !== 0) return; for (const _l of d3Range(i)) { - const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]); - if(index === undefined) continue; + const index = leastIndex( + this.grid.cells.c[cur], + (a: number, b: number) => this.heights![a] - this.heights![b], + ); + if (index === undefined) continue; const min = this.grid.cells.c[cur][index]; // downhill cell - this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3; + this.heights![min] = + (this.heights![cur] * 2 + this.heights![min]) / 3; cur = min; } }); - } + }; const desiredRangeCount = getNumberInRange(count); for (let i = 0; i < desiredRangeCount; i++) { addOneRange(); } - }; + } - addTrough(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void { + addTrough( + count: string, + height: string, + rangeX: string, + rangeY: string, + startCellId?: number, + endCellId?: number, + ): void { const addOneTrough = () => { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; - // get main ridge + // get main ridge const getRange = (cur: number, end: number) => { const range = [cur]; const p = this.grid.points; used[cur] = 1; - + while (cur !== end) { let min = Infinity; this.grid.cells.c[cur].forEach((e: number) => { @@ -275,13 +323,13 @@ class HeightmapGenerator { range.push(cur); used[cur] = 1; } - + return range; - } + }; const used = new Uint8Array(this.heights.length); let h = lim(getNumberInRange(height)); - + if (rangeX && rangeY) { // find start and end points let limit = 0; @@ -296,29 +344,34 @@ class HeightmapGenerator { startCellId = findGridCell(startX, startY, this.grid); limit++; } while (this.heights[startCellId] < 20 && limit < 50); - + limit = 0; do { endX = Math.random() * graphWidth * 0.8 + graphWidth * 0.1; endY = Math.random() * graphHeight * 0.7 + graphHeight * 0.15; dist = Math.abs(endY - startY) + Math.abs(endX - startX); limit++; - } while ((dist < graphWidth / 8 || dist > graphWidth / 2) && limit < 50); - + } while ( + (dist < graphWidth / 8 || dist > graphWidth / 2) && + limit < 50 + ); + endCellId = findGridCell(endX, endY, this.grid); } - - let range = getRange(startCellId as number, endCellId as number); - - + + const range = getRange(startCellId as number, endCellId as number); + // add height to ridge and cells around let queue = range.slice(), - i = 0; + i = 0; while (queue.length) { const frontier = queue.slice(); - (queue = []), i++; + queue = []; + i++; frontier.forEach((i: number) => { - this.heights![i] = lim(this.heights![i] - h * (Math.random() * 0.3 + 0.85)); + this.heights![i] = lim( + this.heights![i] - h * (Math.random() * 0.3 + 0.85), + ); }); h = h ** this.linePower - 1; if (h < 2) break; @@ -331,41 +384,62 @@ class HeightmapGenerator { }); }); } - + // generate prominences range.forEach((cur: number, d: number) => { if (d % 6 !== 0) return; for (const _l of d3Range(i)) { - const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]); - if(index === undefined) continue; + const index = leastIndex( + this.grid.cells.c[cur], + (a: number, b: number) => this.heights![a] - this.heights![b], + ); + if (index === undefined) continue; const min = this.grid.cells.c[cur][index]; // downhill cell //debug.append("circle").attr("cx", p[min][0]).attr("cy", p[min][1]).attr("r", 1); - this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3; + this.heights![min] = + (this.heights![cur] * 2 + this.heights![min]) / 3; cur = min; } }); - } + }; const desiredTroughCount = getNumberInRange(count); - for(let i = 0; i < desiredTroughCount; i++) { + for (let i = 0; i < desiredTroughCount; i++) { addOneTrough(); } - }; - + } + addStrait(width: string, direction = "vertical"): void { - if(!this.heights || !this.grid) return; - const desiredWidth = Math.min(getNumberInRange(width), this.grid.cellsX / 3); + if (!this.heights || !this.grid) return; + const desiredWidth = Math.min( + getNumberInRange(width), + this.grid.cellsX / 3, + ); if (desiredWidth < 1 && P(desiredWidth)) return; const used = new Uint8Array(this.heights.length); const vert = direction === "vertical"; - const startX = vert ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) : 5; - const startY = vert ? 5 : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3); + const startX = vert + ? Math.floor(Math.random() * graphWidth * 0.4 + graphWidth * 0.3) + : 5; + const startY = vert + ? 5 + : Math.floor(Math.random() * graphHeight * 0.4 + graphHeight * 0.3); const endX = vert - ? Math.floor(graphWidth - startX - graphWidth * 0.1 + Math.random() * graphWidth * 0.2) + ? Math.floor( + graphWidth - + startX - + graphWidth * 0.1 + + Math.random() * graphWidth * 0.2, + ) : graphWidth - 5; const endY = vert ? graphHeight - 5 - : Math.floor(graphHeight - startY - graphHeight * 0.1 + Math.random() * graphHeight * 0.2); + : Math.floor( + graphHeight - + startY - + graphHeight * 0.1 + + Math.random() * graphHeight * 0.2, + ); const start = findGridCell(startX, startY, this.grid); const end = findGridCell(endX, endY, this.grid); @@ -388,14 +462,13 @@ class HeightmapGenerator { } return range; - } + }; let range = getRange(start, end); const query: number[] = []; - const step = 0.1 / desiredWidth; - for(let i = 0; i < desiredWidth; i++) { + for (let i = 0; i < desiredWidth; i++) { const exp = 0.9 - step * desiredWidth; range.forEach((r: number) => { this.grid.cells.c[r].forEach((e: number) => { @@ -408,15 +481,17 @@ class HeightmapGenerator { }); range = query.slice(); } - }; + } modify(range: string, add: number, mult: number, power?: number): void { - if(!this.heights) return; - const min = range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0]; - const max = range === "land" || range === "all" ? 100 : +range.split("-")[1]; + if (!this.heights) return; + const min = + range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0]; + const max = + range === "land" || range === "all" ? 100 : +range.split("-")[1]; const isLand = min === 20; - this.heights = this.heights.map(h => { + this.heights = this.heights.map((h) => { if (h < min || h > max) return h; if (add) h = isLand ? Math.max(h + add, 20) : h + add; @@ -424,20 +499,22 @@ class HeightmapGenerator { if (power) h = isLand ? (h - 20) ** power + 20 : h ** power; return lim(h); }); - }; + } smooth(fr = 2, add = 0): void { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; this.heights = this.heights.map((h, i) => { const a = [h]; - this.grid.cells.c[i].forEach((c: number) => a.push(this.heights![c])); + this.grid.cells.c[i].forEach((c: number) => { + a.push(this.heights![c]); + }); if (fr === 1) return (mean(a) as number) + add; return lim((h * (fr - 1) + (mean(a) as number) + add) / fr); }); - }; + } mask(power = 1): void { - if(!this.heights || !this.grid) return; + if (!this.heights || !this.grid) return; const fr = power ? Math.abs(power) : 1; this.heights = this.heights.map((h, i) => { @@ -449,17 +526,17 @@ class HeightmapGenerator { const masked = h * distance; return lim((h * (fr - 1) + masked) / fr); }); - }; + } invert(count: number, axes: string): void { if (!P(count) || !this.heights || !this.grid) return; const invertX = axes !== "y"; const invertY = axes !== "x"; - const {cellsX, cellsY} = this.grid; + const { cellsX, cellsY } = this.grid; const inverted = this.heights.map((_h: number, i: number) => { - if(!this.heights) return 0; + if (!this.heights) return 0; const x = i % cellsX; const y = Math.floor(i / cellsX); @@ -470,66 +547,104 @@ class HeightmapGenerator { }); this.heights = inverted; - }; + } addStep(tool: Tool, a2: string, a3: string, a4: string, a5: string): void { - if (tool === "Hill") return this.addHill(a2, a3, a4, a5); - if (tool === "Pit") return this.addPit(a2, a3, a4, a5); - if (tool === "Range") return this.addRange(a2, a3, a4, a5); - if (tool === "Trough") return this.addTrough(a2, a3, a4, a5); - if (tool === "Strait") return this.addStrait(a2, a3); - if (tool === "Mask") return this.mask(+a2); - if (tool === "Invert") return this.invert(+a2, a3); - if (tool === "Add") return this.modify(a3, +a2, 1); - if (tool === "Multiply") return this.modify(a3, 0, +a2); - if (tool === "Smooth") return this.smooth(+a2); + if (tool === "Hill") { + this.addHill(a2, a3, a4, a5); + return; + } + if (tool === "Pit") { + this.addPit(a2, a3, a4, a5); + return; + } + if (tool === "Range") { + this.addRange(a2, a3, a4, a5); + return; + } + if (tool === "Trough") { + this.addTrough(a2, a3, a4, a5); + return; + } + if (tool === "Strait") { + this.addStrait(a2, a3); + return; + } + if (tool === "Mask") { + this.mask(+a2); + return; + } + if (tool === "Invert") { + this.invert(+a2, a3); + return; + } + if (tool === "Add") { + this.modify(a3, +a2, 1); + return; + } + if (tool === "Multiply") { + this.modify(a3, 0, +a2); + return; + } + if (tool === "Smooth") { + this.smooth(+a2); + return; + } } async generate(graph: any): Promise { TIME && console.time("defineHeightmap"); const id = (byId("templateInput")! as HTMLInputElement).value; - Math.random = Alea(seed); const isTemplate = id in heightmapTemplates; - - const heights = isTemplate ? this.fromTemplate(graph, id) : await this.fromPrecreated(graph, id); + + const heights = isTemplate + ? this.fromTemplate(graph, id) + : await this.fromPrecreated(graph, id); TIME && console.timeEnd("defineHeightmap"); this.clearData(); return heights as Uint8Array; } - fromTemplate(graph: any, id: string): Uint8Array | null { + fromTemplate(graph: any, id: string): Uint8Array | null { const templateString = heightmapTemplates[id]?.template || ""; const steps = templateString.split("\n"); - if (!steps.length) throw new Error(`Heightmap template: no steps. Template: ${id}. Steps: ${steps}`); + if (!steps.length) + throw new Error( + `Heightmap template: no steps. Template: ${id}. Steps: ${steps}`, + ); this.setGraph(graph); for (const step of steps) { const elements = step.trim().split(" "); - if (elements.length < 2) throw new Error(`Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`); - this.addStep(...elements as [Tool, string, string, string, string]); + if (elements.length < 2) + throw new Error( + `Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`, + ); + this.addStep(...(elements as [Tool, string, string, string, string])); } return this.heights; - }; + } private getHeightsFromImageData(imageData: Uint8ClampedArray): void { - if(!this.heights) return; + if (!this.heights) return; for (let i = 0; i < this.heights.length; i++) { const lightness = imageData[i * 4] / 255; - const powered = lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8; + const powered = + lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8; this.heights[i] = minmax(Math.floor(powered * 100), 0, 100); } } fromPrecreated(graph: any, id: string): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { // create canvas where 1px corresponds to a cell const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; - const {cellsX, cellsY} = graph; + const { cellsX, cellsY } = graph; canvas.width = cellsX; canvas.height = cellsY; @@ -537,7 +652,7 @@ class HeightmapGenerator { const img = new Image(); img.src = `./heightmaps/${id}.png`; img.onload = () => { - if(!ctx) { + if (!ctx) { throw new Error("Could not get canvas context"); } this.heights = this.heights || new Uint8Array(cellsX * cellsY); @@ -550,11 +665,11 @@ class HeightmapGenerator { resolve(this.heights); }; }); - }; + } getHeights() { return this.heights; } } -window.HeightmapGenerator = new HeightmapGenerator(); \ No newline at end of file +window.HeightmapGenerator = new HeightmapModule(); diff --git a/src/modules/index.ts b/src/modules/index.ts index 41beaabd..6867c05f 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -4,4 +4,4 @@ import "./features"; import "./lakes"; import "./ocean-layers"; import "./river-generator"; -import "./biomes" +import "./biomes"; diff --git a/src/modules/lakes.ts b/src/modules/lakes.ts index 6fa381ac..6a95d0af 100644 --- a/src/modules/lakes.ts +++ b/src/modules/lakes.ts @@ -1,7 +1,6 @@ -import { PackedGraphFeature } from "./features"; -import { min, mean } from "d3"; -import { byId, -rn } from "../utils"; +import { mean, min } from "d3"; +import { byId, rn } from "../utils"; +import type { PackedGraphFeature } from "./features"; declare global { var Lakes: LakesModule; @@ -12,24 +11,25 @@ export class LakesModule { getHeight(feature: PackedGraphFeature) { const heights = pack.cells.h; - const minShoreHeight = min(feature.shoreline.map(cellId => heights[cellId])) || 20; + const minShoreHeight = + min(feature.shoreline.map((cellId) => heights[cellId])) || 20; return rn(minShoreHeight - this.LAKE_ELEVATION_DELTA, 2); - }; + } defineNames() { pack.features.forEach((feature: PackedGraphFeature) => { if (feature.type !== "lake") return; feature.name = this.getName(feature); }); - }; + } getName(feature: PackedGraphFeature): string { const landCell = feature.shoreline[0]; const culture = pack.cells.culture[landCell]; return Names.getCulture(culture); - }; + } - cleanupLakeData = function () { + cleanupLakeData = () => { for (const feature of pack.features) { if (feature.type !== "lake") continue; delete feature.river; @@ -38,39 +38,50 @@ export class LakesModule { delete feature.closed; feature.height = rn(feature.height, 3); - const inlets = feature.inlets?.filter(r => pack.rivers.find(river => river.i === r)); + const inlets = feature.inlets?.filter((r) => + pack.rivers.find((river) => river.i === r), + ); if (!inlets || !inlets.length) delete feature.inlets; else feature.inlets = inlets; - const outlet = feature.outlet && pack.rivers.find(river => river.i === feature.outlet); + const outlet = + feature.outlet && + pack.rivers.find((river) => river.i === feature.outlet); if (!outlet) delete feature.outlet; } }; defineClimateData(heights: number[] | Uint8Array) { - const {cells, features} = pack; + const { cells, features } = pack; const lakeOutCells = new Uint16Array(cells.i.length); - + const getFlux = (lake: PackedGraphFeature) => { - return lake.shoreline.reduce((acc, c) => acc + grid.cells.prec[cells.g[c]], 0); - } + return lake.shoreline.reduce( + (acc, c) => acc + grid.cells.prec[cells.g[c]], + 0, + ); + }; const getLakeTemp = (lake: PackedGraphFeature) => { if (lake.cells < 6) return grid.cells.temp[cells.g[lake.firstCell]]; - return rn(mean(lake.shoreline.map(c => grid.cells.temp[cells.g[c]])) as number, 1); - } + return rn( + mean(lake.shoreline.map((c) => grid.cells.temp[cells.g[c]])) as number, + 1, + ); + }; const getLakeEvaporation = (lake: PackedGraphFeature) => { const height = (lake.height - 18) ** Number(heightExponentInput.value); // height in meters - const evaporation = ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11] + const evaporation = + ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11] return rn(evaporation * lake.cells); - } + }; const getLowestShoreCell = (lake: PackedGraphFeature) => { return lake.shoreline.sort((a, b) => heights[a] - heights[b])[0]; - } + }; - features.forEach(feature => { + features.forEach((feature) => { if (feature.type !== "lake") return; feature.flux = getFlux(feature); feature.temp = getLakeTemp(feature); @@ -82,14 +93,16 @@ export class LakesModule { }); return lakeOutCells; - }; + } // check if lake can be potentially open (not in deep depression) detectCloseLakes(h: number[] | Uint8Array) { - const {cells} = pack; - const ELEVATION_LIMIT = +(byId("lakeElevationLimitOutput") as HTMLInputElement)?.value; + const { cells } = pack; + const ELEVATION_LIMIT = +( + byId("lakeElevationLimitOutput") as HTMLInputElement + )?.value; - pack.features.forEach(feature => { + pack.features.forEach((feature) => { if (feature.type !== "lake") return; delete feature.closed; @@ -100,7 +113,9 @@ export class LakesModule { } let isDeep = true; - const lowestShorelineCell = feature.shoreline.sort((a, b) => h[a] - h[b])[0]; + const lowestShorelineCell = feature.shoreline.sort( + (a, b) => h[a] - h[b], + )[0]; const queue = [lowestShorelineCell]; const checked = []; checked[lowestShorelineCell] = true; @@ -114,7 +129,8 @@ export class LakesModule { if (h[neibCellId] < 20) { const nFeature = pack.features[cells.f[neibCellId]]; - if (nFeature.type === "ocean" || feature.height > nFeature.height) isDeep = false; + if (nFeature.type === "ocean" || feature.height > nFeature.height) + isDeep = false; } checked[neibCellId] = true; @@ -124,7 +140,7 @@ export class LakesModule { feature.closed = isDeep; }); - }; + } } -window.Lakes = new LakesModule(); \ No newline at end of file +window.Lakes = new LakesModule(); diff --git a/src/modules/ocean-layers.ts b/src/modules/ocean-layers.ts index 11467ea5..a18b844a 100644 --- a/src/modules/ocean-layers.ts +++ b/src/modules/ocean-layers.ts @@ -1,6 +1,6 @@ -import { line, curveBasisClosed } from 'd3'; -import type { Selection } from 'd3'; -import { clipPoly,P,rn,round } from '../utils'; +import type { Selection } from "d3"; +import { curveBasisClosed, line } from "d3"; +import { clipPoly, P, rn, round } from "../utils"; declare global { var OceanLayers: typeof OceanModule.prototype.draw; @@ -13,7 +13,6 @@ class OceanModule { private lineGen = line().curve(curveBasisClosed); private oceanLayers: Selection; - constructor(oceanLayers: Selection) { this.oceanLayers = oceanLayers; } @@ -35,11 +34,17 @@ class OceanModule { // connect vertices to chain connectVertices(start: number, t: number) { const chain = []; // vertices chain to form a path - for (let i = 0, current = start; i === 0 || (current !== start && i < 10000); i++) { + for ( + let i = 0, current = start; + i === 0 || (current !== start && i < 10000); + i++ + ) { const prev = chain[chain.length - 1]; // previous vertex in chain chain.push(current); // add current vertex to sequence const c = this.vertices.c[current]; // cells adjacent to vertex - c.filter((c: number) => this.cells.t[c] === t).forEach((c: number) => (this.used[c] = 1)); + c.filter((c: number) => this.cells.t[c] === t).forEach((c: number) => { + this.used[c] = 1; + }); const v = this.vertices.v[current]; // neighboring vertices const c0 = !this.cells.t[c[0]] || this.cells.t[c[0]] === t - 1; const c1 = !this.cells.t[c[1]] || this.cells.t[c[1]] === t - 1; @@ -58,9 +63,16 @@ class OceanModule { // find eligible cell vertex to start path detection findStart(i: number, t: number) { - if (this.cells.b[i]) return this.cells.v[i].find((v: number) => this.vertices.c[v].some((c: number) => c >= this.pointsN)); // map border cell - return this.cells.v[i][this.cells.c[i].findIndex((c: number)=> this.cells.t[c] < t || !this.cells.t[c])]; - } + if (this.cells.b[i]) + return this.cells.v[i].find((v: number) => + this.vertices.c[v].some((c: number) => c >= this.pointsN), + ); // map border cell + return this.cells.v[i][ + this.cells.c[i].findIndex( + (c: number) => this.cells.t[c] < t || !this.cells.t[c], + ) + ]; + } draw() { const outline = this.oceanLayers.attr("layers"); @@ -69,8 +81,11 @@ class OceanModule { this.cells = grid.cells; this.pointsN = grid.cells.i.length; this.vertices = grid.vertices; - const limits = outline === "random" ? this.randomizeOutline() : outline.split(",").map((s: string) => +s); - + const limits = + outline === "random" + ? this.randomizeOutline() + : outline.split(",").map((s: string) => +s); + const chains: [number, any[]][] = []; const opacity = rn(0.4 / limits.length, 2); this.used = new Uint8Array(this.pointsN); // to detect already passed cells @@ -85,22 +100,33 @@ class OceanModule { const chain = this.connectVertices(start, t); // vertices chain to form a path if (chain.length < 4) continue; const relax = 1 + t * -2; // select only n-th point - const relaxed = chain.filter((v, i) => !(i % relax) || this.vertices.c[v].some((c: number) => c >= this.pointsN)); + const relaxed = chain.filter( + (v, i) => + !(i % relax) || + this.vertices.c[v].some((c: number) => c >= this.pointsN), + ); if (relaxed.length < 4) continue; - + const points = clipPoly( - relaxed.map(v => this.vertices.p[v]), + relaxed.map((v) => this.vertices.p[v]), graphWidth, graphHeight, - 1 + 1, ); chains.push([t, points]); } for (const t of limits) { const layer = chains.filter((c: [number, any[]]) => c[0] === t); - let path = layer.map((c: [number, any[]]) => round(this.lineGen(c[1]) || "")).join(""); - if (path) this.oceanLayers.append("path").attr("d", path).attr("fill", "#ecf2f9").attr("fill-opacity", opacity); + const path = layer + .map((c: [number, any[]]) => round(this.lineGen(c[1]) || "")) + .join(""); + if (path) + this.oceanLayers + .append("path") + .attr("d", path) + .attr("fill", "#ecf2f9") + .attr("fill-opacity", opacity); } TIME && console.timeEnd("drawOceanLayers"); diff --git a/src/modules/river-generator.ts b/src/modules/river-generator.ts index 55cedaa1..a953aa51 100644 --- a/src/modules/river-generator.ts +++ b/src/modules/river-generator.ts @@ -1,8 +1,6 @@ import Alea from "alea"; -import { each, rn, round, rw} from "../utils"; -import { curveBasis, line, mean, min, sum, curveCatmullRom } from "d3"; - - +import { curveBasis, curveCatmullRom, line, mean, min, sum } from "d3"; +import { each, rn, round, rw } from "../utils"; declare global { var Rivers: RiverModule; @@ -29,18 +27,20 @@ class RiverModule { private MAX_FLUX_WIDTH = 1; private LENGTH_FACTOR = 200; private LENGTH_STEP_WIDTH = 1 / this.LENGTH_FACTOR; - private LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / this.LENGTH_FACTOR); - private lineGen = line().curve(curveBasis) + private LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map( + (n) => n / this.LENGTH_FACTOR, + ); + private lineGen = line().curve(curveBasis); riverTypes = { main: { - big: {River: 1}, - small: {Creek: 9, River: 3, Brook: 3, Stream: 1} + big: { River: 1 }, + small: { Creek: 9, River: 3, Brook: 3, Stream: 1 }, }, fork: { - big: {Fork: 1}, - small: {Branch: 1} - } + big: { Fork: 1 }, + small: { Branch: 1 }, + }, }; smallLength: number | null = null; @@ -48,10 +48,10 @@ class RiverModule { generate(allowErosion = true) { TIME && console.time("generateRivers"); Math.random = Alea(seed); - const {cells, features} = pack; + const { cells, features } = pack; - const riversData: {[riverId: number]: number[]} = {}; - const riverParents: {[key: number]: number} = {}; + const riversData: { [riverId: number]: number[] } = {}; + const riverParents: { [key: number]: number } = {}; const addCellToRiver = (cellId: number, riverId: number) => { if (!riversData[riverId]) riversData[riverId] = [cellId]; @@ -60,26 +60,36 @@ class RiverModule { const drainWater = () => { const MIN_FLUX_TO_FORM_RIVER = 30; - const cellsNumberModifier = ((pointsInput.dataset.cells as any) / 10000) ** 0.25; + const cellsNumberModifier = + ((pointsInput.dataset.cells as any) / 10000) ** 0.25; const prec = grid.cells.prec; - const land = cells.i.filter((i: number) => h[i] >= 20).sort((a: number, b: number) => h[b] - h[a]); + const land = cells.i + .filter((i: number) => h[i] >= 20) + .sort((a: number, b: number) => h[b] - h[a]); const lakeOutCells = Lakes.defineClimateData(h); - land.forEach(function (i: number) { + for (const i of land) { cells.fl[i] += prec[cells.g[i]] / cellsNumberModifier; // add flux from precipitation // create lake outlet if lake is not in deep depression and flux > evaporation const lakes = lakeOutCells[i] - ? features.filter((feature: any) => i === feature.outCell && feature.flux > feature.evaporation) + ? features.filter( + (feature: any) => + i === feature.outCell && feature.flux > feature.evaporation, + ) : []; for (const lake of lakes) { - const lakeCell = cells.c[i].find((c: number) => h[c] < 20 && cells.f[c] === lake.i)!; + const lakeCell = cells.c[i].find( + (c: number) => h[c] < 20 && cells.f[c] === lake.i, + )!; cells.fl[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet // allow chain lakes to retain identity if (cells.r[lakeCell] !== lake.river) { - const sameRiver = cells.c[lakeCell].some((c: number) => cells.r[c] === lake.river); + const sameRiver = cells.c[lakeCell].some( + (c: number) => cells.r[c] === lake.river, + ); if (sameRiver) { cells.r[lakeCell] = lake.river as number; @@ -105,12 +115,18 @@ class RiverModule { } // near-border cell: pour water out of the screen - if (cells.b[i] && cells.r[i]) return addCellToRiver(-1, cells.r[i]); + if (cells.b[i] && cells.r[i]) { + addCellToRiver(-1, cells.r[i]); + continue; + } // downhill cell (make sure it's not in the source lake) let min = null; if (lakeOutCells[i]) { - const filtered = cells.c[i].filter((c: number) => !lakes.map((lake: any) => lake.i).includes(cells.f[c])); + const filtered = cells.c[i].filter( + (c: number) => + !lakes.map((lake: any) => lake.i).includes(cells.f[c]), + ); min = filtered.sort((a: number, b: number) => h[a] - h[b])[0]; } else if (cells.haven[i]) { min = cells.haven[i]; @@ -119,7 +135,7 @@ class RiverModule { } // cells is depressed - if (h[i] <= h[min]) return; + if (h[i] <= h[min]) continue; // debug // .append("line") @@ -133,7 +149,7 @@ class RiverModule { if (cells.fl[i] < MIN_FLUX_TO_FORM_RIVER) { // flux is too small to operate as a river if (h[min] >= 20) cells.fl[min] += cells.fl[i]; - return; + continue; } // proclaim a new river @@ -144,8 +160,8 @@ class RiverModule { } flowDown(min, cells.fl[i], cells.r[i]); - }); - } + } + }; const flowDown = (toCell: number, fromFlux: number, river: number) => { const toFlux = cells.fl[toCell] - cells.conf[toCell]; @@ -167,7 +183,10 @@ class RiverModule { // pour water to the water body const waterBody = features[cells.f[toCell]]; if (waterBody.type === "lake") { - if (!waterBody.river || fromFlux > (waterBody.enteringFlux as number)) { + if ( + !waterBody.river || + fromFlux > (waterBody.enteringFlux as number) + ) { waterBody.river = river; waterBody.enteringFlux = fromFlux; } @@ -181,7 +200,7 @@ class RiverModule { } addCellToRiver(toCell, river); - } + }; const defineRivers = () => { // re-initialize rivers and confluence arrays @@ -189,7 +208,10 @@ class RiverModule { cells.conf = new Uint16Array(cells.i.length); pack.rivers = []; - const defaultWidthFactor = rn(1 / ((pointsInput.dataset.cells as any) / 10000) ** 0.25, 2); + const defaultWidthFactor = rn( + 1 / ((pointsInput.dataset.cells as any) / 10000) ** 0.25, + 2, + ); const mainStemWidthFactor = defaultWidthFactor * 1.2; for (const key in riversData) { @@ -209,7 +231,10 @@ class RiverModule { const mouth = riverCells[riverCells.length - 2]; const parent = riverParents[key] || 0; - const widthFactor = !parent || parent === riverId ? mainStemWidthFactor : defaultWidthFactor; + const widthFactor = + !parent || parent === riverId + ? mainStemWidthFactor + : defaultWidthFactor; const meanderedPoints = this.addMeandering(riverCells); const discharge = cells.fl[mouth]; // m3 in second const length = this.getApproximateLength(meanderedPoints); @@ -219,8 +244,8 @@ class RiverModule { flux: discharge, pointIndex: meanderedPoints.length, widthFactor, - startingWidth: sourceWidth - }) + startingWidth: sourceWidth, + }), ); pack.rivers.push({ @@ -233,10 +258,10 @@ class RiverModule { widthFactor, sourceWidth, parent, - cells: riverCells + cells: riverCells, } as River); } - } + }; const downcutRivers = () => { const MAX_DOWNCUT = 5; @@ -245,14 +270,18 @@ class RiverModule { if (cells.h[i] < 35) continue; // don't donwcut lowlands if (!cells.fl[i]) continue; - const higherCells = cells.c[i].filter((c: number) => cells.h[c] > cells.h[i]); - const higherFlux = higherCells.reduce((acc: number, c: number) => acc + cells.fl[c], 0) / higherCells.length; + const higherCells = cells.c[i].filter( + (c: number) => cells.h[c] > cells.h[i], + ); + const higherFlux = + higherCells.reduce((acc: number, c: number) => acc + cells.fl[c], 0) / + higherCells.length; if (!higherFlux) continue; const downcut = Math.floor(cells.fl[i] / higherFlux); if (downcut) cells.h[i] -= Math.min(downcut, MAX_DOWNCUT); } - } + }; const calculateConfluenceFlux = () => { for (const i of cells.i) { @@ -262,9 +291,13 @@ class RiverModule { .filter((c: number) => cells.r[c] && h[c] > h[i]) .map((c: number) => cells.fl[c]) .sort((a: number, b: number) => b - a); - cells.conf[i] = sortedInflux.reduce((acc: number, flux: number, index: number) => (index ? acc + flux : acc), 0); + cells.conf[i] = sortedInflux.reduce( + (acc: number, flux: number, index: number) => + index ? acc + flux : acc, + 0, + ); } - } + }; cells.fl = new Uint16Array(cells.i.length); // water flux array cells.r = new Uint16Array(cells.i.length); // rivers array @@ -286,20 +319,28 @@ class RiverModule { } TIME && console.timeEnd("generateRivers"); - }; + } alterHeights(): number[] { - const {h, c, t} = pack.cells as {h: Uint8Array, c: number[][], t: Uint8Array}; + const { h, c, t } = pack.cells as { + h: Uint8Array; + c: number[][]; + t: Uint8Array; + }; return Array.from(h).map((h, i) => { if (h < 20 || t[i] < 1) return h; - return h + t[i] / 100 + (mean(c[i].map(c => t[c])) as number) / 10000; + return h + t[i] / 100 + (mean(c[i].map((c) => t[c])) as number) / 10000; }); - }; + } // depression filling algorithm (for a correct water flux modeling) resolveDepressions(h: number[]) { - const {cells, features} = pack; - const maxIterations = +(document.getElementById("resolveDepressionsStepsOutput") as HTMLInputElement)?.value; + const { cells, features } = pack; + const maxIterations = +( + document.getElementById( + "resolveDepressionsStepsOutput", + ) as HTMLInputElement + )?.value; const checkLakeMaxIteration = maxIterations * 0.85; const elevateLakeMaxIteration = maxIterations * 0.75; @@ -312,7 +353,11 @@ class RiverModule { const progress = []; let depressions = Infinity; let prevDepressions = null; - for (let iteration = 0; depressions && iteration < maxIterations; iteration++) { + for ( + let iteration = 0; + depressions && iteration < maxIterations; + iteration++ + ) { if (progress.length > 5 && sum(progress) > 0) { // bad progress, abort and set heights back h = this.alterHeights(); @@ -329,8 +374,11 @@ class RiverModule { if (minHeight >= 100 || l.height > minHeight) continue; if (iteration > elevateLakeMaxIteration) { - l.shoreline.forEach((i: number) => (h[i] = cells.h[i])); - l.height = (min(l.shoreline.map((s: number) => h[s])) as number) - 1; + l.shoreline.forEach((i: number) => { + h[i] = cells.h[i]; + }); + l.height = + (min(l.shoreline.map((s: number) => h[s])) as number) - 1; l.closed = true; continue; } @@ -341,7 +389,9 @@ class RiverModule { } for (const i of land) { - const minHeight = min(cells.c[i].map((c: number) => height(c))) as number; + const minHeight = min( + cells.c[i].map((c: number) => height(c)), + ) as number; if (minHeight >= 100 || h[i] > minHeight) continue; depressions++; @@ -352,11 +402,19 @@ class RiverModule { prevDepressions = depressions; } - depressions && WARN && console.warn(`Unresolved depressions: ${depressions}. Edit heightmap to fix`); - }; + depressions && + WARN && + console.warn( + `Unresolved depressions: ${depressions}. Edit heightmap to fix`, + ); + } - addMeandering(riverCells: number[], riverPoints = null, meandering = 0.5): [number, number, number][] { - const {fl, h} = pack.cells; + addMeandering( + riverCells: number[], + riverPoints = null, + meandering = 0.5, + ): [number, number, number][] { + const { fl, h } = pack.cells; const meandered = []; const lastStep = riverCells.length - 1; const points = this.getRiverPoints(riverCells, riverPoints); @@ -382,7 +440,8 @@ class RiverModule { const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells if (dist2 <= 25 && riverCells.length >= 6) continue; - const meander = meandering + 1 / step + Math.max(meandering - step / 100, 0); + const meander = + meandering + 1 / step + Math.max(meandering - step / 100, 0); const angle = Math.atan2(y2 - y1, x2 - x1); const sinMeander = Math.sin(angle) * meander; const cosMeander = Math.cos(angle) * meander; @@ -403,17 +462,17 @@ class RiverModule { } return meandered as [number, number, number][]; - }; + } getRiverPoints(riverCells: number[], riverPoints: [number, number][] | null) { if (riverPoints) return riverPoints; - const {p} = pack.cells; + const { p } = pack.cells; return riverCells.map((cell, i) => { if (cell === -1) return this.getBorderPoint(riverCells[i - 1]); return p[cell]; }); - }; + } getBorderPoint(i: number) { const [x, y] = pack.cells.p[i]; @@ -422,22 +481,42 @@ class RiverModule { else if (min === graphHeight - y) return [x, graphHeight]; else if (min === x) return [0, y]; return [graphWidth, y]; - }; + } - getOffset({flux, pointIndex, widthFactor, startingWidth}: {flux: number, pointIndex: number, widthFactor: number, startingWidth: number}) { + getOffset({ + flux, + pointIndex, + widthFactor, + startingWidth, + }: { + flux: number; + pointIndex: number; + widthFactor: number; + startingWidth: number; + }) { if (pointIndex === 0) return startingWidth; - const fluxWidth = Math.min(flux ** 0.7 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH); - const lengthWidth = pointIndex * this.LENGTH_STEP_WIDTH + (this.LENGTH_PROGRESSION[pointIndex] || this.LENGTH_PROGRESSION.at(-1) as number); + const fluxWidth = Math.min( + flux ** 0.7 / this.FLUX_FACTOR, + this.MAX_FLUX_WIDTH, + ); + const lengthWidth = + pointIndex * this.LENGTH_STEP_WIDTH + + (this.LENGTH_PROGRESSION[pointIndex] || + (this.LENGTH_PROGRESSION.at(-1) as number)); return widthFactor * (lengthWidth + fluxWidth) + startingWidth; - }; + } getSourceWidth(flux: number) { return rn(Math.min(flux ** 0.9 / this.FLUX_FACTOR, this.MAX_FLUX_WIDTH), 2); } // build polygon from a list of points and calculated offset (width) - getRiverPath(points: [number, number, number][], widthFactor: number, startingWidth: number) { + getRiverPath( + points: [number, number, number][], + widthFactor: number, + startingWidth: number, + ) { this.lineGen.curve(curveCatmullRom.alpha(0.1)); const riverPointsLeft: [number, number][] = []; const riverPointsRight: [number, number][] = []; @@ -449,7 +528,12 @@ class RiverModule { const [x2, y2] = points[pointIndex + 1] || points[pointIndex]; if (pointFlux > flux) flux = pointFlux; - const offset = this.getOffset({flux, pointIndex, widthFactor, startingWidth}); + const offset = this.getOffset({ + flux, + pointIndex, + widthFactor, + startingWidth, + }); const angle = Math.atan2(y0 - y2, x0 - x2); const sinOffset = Math.sin(angle) * offset; const cosOffset = Math.cos(angle) * offset; @@ -463,7 +547,7 @@ class RiverModule { left = left.substring(left.indexOf("C")); return round(right + left, 1); - }; + } specify() { const rivers = pack.rivers; @@ -474,57 +558,69 @@ class RiverModule { river.name = this.getName(river.mouth); river.type = this.getType(river); } - }; + } getName(cell: number) { return Names.getCulture(pack.cells.culture[cell]); - }; + } - getType({i, length, parent}: River) { + getType({ i, length, parent }: River) { if (this.smallLength === null) { const threshold = Math.ceil(pack.rivers.length * 0.15); - this.smallLength = pack.rivers.map(r => r.length || 0).sort((a: number, b: number) => a - b)[threshold]; + this.smallLength = pack.rivers + .map((r) => r.length || 0) + .sort((a: number, b: number) => a - b)[threshold]; } const isSmall: boolean = length < (this.smallLength as number); const isFork = each(3)(i) && parent && parent !== i; - return rw(this.riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]); - }; + return rw( + this.riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"], + ); + } getApproximateLength(points: [number, number, number][]) { - const length = points.reduce((s, v, i, p) => s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0), 0); + const length = points.reduce( + (s, v, i, p) => + s + (i ? Math.hypot(v[0] - p[i - 1][0], v[1] - p[i - 1][1]) : 0), + 0, + ); return rn(length, 2); - }; + } // Real mouth width examples: Amazon 6000m, Volga 6000m, Dniepr 3000m, Mississippi 1300m, Themes 900m, // Danube 800m, Daugava 600m, Neva 500m, Nile 450m, Don 400m, Wisla 300m, Pripyat 150m, Bug 140m, Muchavets 40m getWidth(offset: number) { - return rn((offset / 1.5) ** 1.8, 2); // mouth width in km - }; + return rn((offset / 1.5) ** 1.8, 2); // mouth width in km + } // remove river and all its tributaries remove(id: number) { const cells = pack.cells; - const riversToRemove = pack.rivers.filter(r => r.i === id || r.parent === id || r.basin === id).map(r => r.i); - riversToRemove.forEach(r => rivers.select("#river" + r).remove()); + const riversToRemove = pack.rivers + .filter((r) => r.i === id || r.parent === id || r.basin === id) + .map((r) => r.i); + riversToRemove.forEach((r) => { + rivers.select(`#river${r}`).remove(); + }); cells.r.forEach((r, i) => { if (!r || !riversToRemove.includes(r)) return; cells.r[i] = 0; cells.fl[i] = grid.cells.prec[cells.g[i]]; cells.conf[i] = 0; }); - pack.rivers = pack.rivers.filter(r => !riversToRemove.includes(r.i)); - }; + pack.rivers = pack.rivers.filter((r) => !riversToRemove.includes(r.i)); + } getBasin(r: number): number { - const parent = pack.rivers.find(river => river.i === r)?.parent; + const parent = pack.rivers.find((river) => river.i === r)?.parent; if (!parent || r === parent) return r; return this.getBasin(parent); - }; + } - getNextId(rivers: {i: number}[]) { - return rivers.length ? Math.max(...rivers.map(r => r.i)) + 1 : 1; - }; + getNextId(rivers: { i: number }[]) { + return rivers.length ? Math.max(...rivers.map((r) => r.i)) + 1 : 1; + } } -window.Rivers = new RiverModule() \ No newline at end of file +window.Rivers = new RiverModule(); diff --git a/src/modules/voronoi.ts b/src/modules/voronoi.ts index 55ac77ab..adceaaa6 100644 --- a/src/modules/voronoi.ts +++ b/src/modules/voronoi.ts @@ -1,6 +1,11 @@ -import Delaunator from "delaunator"; -export type Vertices = { p: Point[], v: number[][], c: number[][] }; -export type Cells = { v: number[][], c: number[][], b: number[], i: Uint32Array } ; +import type Delaunator from "delaunator"; +export type Vertices = { p: Point[]; v: number[][]; c: number[][] }; +export type Cells = { + v: number[][]; + c: number[][]; + b: number[]; + i: Uint32Array; +}; export type Point = [number, number]; /** @@ -11,36 +16,41 @@ export type Point = [number, number]; * @param {number} pointsN The number of points. */ export class Voronoi { - delaunay: Delaunator> + delaunay: Delaunator>; points: Point[]; pointsN: number; cells: Cells = { v: [], c: [], b: [], i: new Uint32Array() }; // voronoi cells: v = cell vertices, c = adjacent cells, b = near-border cell, i = cell indexes; vertices: Vertices = { p: [], v: [], c: [] }; // cells vertices: p = vertex coordinates, v = neighboring vertices, c = adjacent cells - - constructor(delaunay: Delaunator>, points: Point[], pointsN: number) { + + constructor( + delaunay: Delaunator>, + points: Point[], + pointsN: number, + ) { this.delaunay = delaunay; this.points = points; this.pointsN = pointsN; - this.vertices + this.vertices; // Half-edges are the indices into the delaunator outputs: // delaunay.triangles[e] gives the point ID where the half-edge starts // delaunay.halfedges[e] returns either the opposite half-edge in the adjacent triangle, or -1 if there's not an adjacent triangle. for (let e = 0; e < this.delaunay.triangles.length; e++) { - const p = this.delaunay.triangles[this.nextHalfedge(e)]; if (p < this.pointsN && !this.cells.c[p]) { const edges = this.edgesAroundPoint(e); - this.cells.v[p] = edges.map(e => this.triangleOfEdge(e)); // cell: adjacent vertex - this.cells.c[p] = edges.map(e => this.delaunay.triangles[e]).filter(c => c < this.pointsN); // cell: adjacent valid cells - this.cells.b[p] = edges.length > this.cells.c[p].length ? 1 : 0; // cell: is border + this.cells.v[p] = edges.map((e) => this.triangleOfEdge(e)); // cell: adjacent vertex + this.cells.c[p] = edges + .map((e) => this.delaunay.triangles[e]) + .filter((c) => c < this.pointsN); // cell: adjacent valid cells + this.cells.b[p] = edges.length > this.cells.c[p].length ? 1 : 0; // cell: is border } const t = this.triangleOfEdge(e); if (!this.vertices.p[t]) { - this.vertices.p[t] = this.triangleCenter(t); // vertex: coordinates + this.vertices.p[t] = this.triangleCenter(t); // vertex: coordinates this.vertices.v[t] = this.trianglesAdjacentToTriangle(t); // vertex: adjacent vertices - this.vertices.c[t] = this.pointsOfTriangle(t); // vertex: adjacent cells + this.vertices.c[t] = this.pointsOfTriangle(t); // vertex: adjacent cells } } } @@ -51,7 +61,9 @@ export class Voronoi { * @returns {[number, number, number]} The IDs of the points comprising the given triangle. */ private pointsOfTriangle(triangleIndex: number): [number, number, number] { - return this.edgesOfTriangle(triangleIndex).map(edge => this.delaunay.triangles[edge]) as [number, number, number]; + return this.edgesOfTriangle(triangleIndex).map( + (edge) => this.delaunay.triangles[edge], + ) as [number, number, number]; } /** @@ -60,9 +72,9 @@ export class Voronoi { * @returns {number[]} The indices of the triangles that share half-edges with this triangle. */ private trianglesAdjacentToTriangle(triangleIndex: number): number[] { - let triangles = []; - for (let edge of this.edgesOfTriangle(triangleIndex)) { - let opposite = this.delaunay.halfedges[edge]; + const triangles = []; + for (const edge of this.edgesOfTriangle(triangleIndex)) { + const opposite = this.delaunay.halfedges[edge]; triangles.push(this.triangleOfEdge(opposite)); } return triangles; @@ -90,7 +102,9 @@ export class Voronoi { * @returns {[number, number]} The coordinates of the triangle's circumcenter. */ private triangleCenter(triangleIndex: number): Point { - let vertices = this.pointsOfTriangle(triangleIndex).map(p => this.points[p]); + const vertices = this.pointsOfTriangle(triangleIndex).map( + (p) => this.points[p], + ); return this.circumcenter(vertices[0], vertices[1], vertices[2]); } @@ -99,21 +113,27 @@ export class Voronoi { * @param {number} triangleIndex The index of the triangle * @returns {[number, number, number]} The edges of the triangle. */ - private edgesOfTriangle(triangleIndex: number): [number, number, number] { return [3 * triangleIndex, 3 * triangleIndex + 1, 3 * triangleIndex + 2]; } + private edgesOfTriangle(triangleIndex: number): [number, number, number] { + return [3 * triangleIndex, 3 * triangleIndex + 1, 3 * triangleIndex + 2]; + } /** * Enables lookup of a triangle, given one of the half-edges of that triangle. Taken from {@link https://mapbox.github.io/delaunator/#edge-and-triangle| the Delaunator docs.} * @param {number} e The index of the edge * @returns {number} The index of the triangle */ - private triangleOfEdge(e: number): number { return Math.floor(e / 3); } + private triangleOfEdge(e: number): number { + return Math.floor(e / 3); + } /** * Moves to the next half-edge of a triangle, given the current half-edge's index. Taken from {@link https://mapbox.github.io/delaunator/#edge-to-edges| the Delaunator docs.} * @param {number} e The index of the current half edge * @returns {number} The index of the next half edge */ - private nextHalfedge(e: number): number { return (e % 3 === 2) ? e - 2 : e + 1; } + private nextHalfedge(e: number): number { + return e % 3 === 2 ? e - 2 : e + 1; + } /** * Moves to the previous half-edge of a triangle, given the current half-edge's index. Taken from {@link https://mapbox.github.io/delaunator/#edge-to-edges| the Delaunator docs.} @@ -138,8 +158,8 @@ export class Voronoi { const cd = cx * cx + cy * cy; const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); return [ - Math.floor(1 / D * (ad * (by - cy) + bd * (cy - ay) + cd * (ay - by))), - Math.floor(1 / D * (ad * (cx - bx) + bd * (ax - cx) + cd * (bx - ax))) + Math.floor((1 / D) * (ad * (by - cy) + bd * (cy - ay) + cd * (ay - by))), + Math.floor((1 / D) * (ad * (cx - bx) + bd * (ax - cx) + cd * (bx - ax))), ]; } -} \ No newline at end of file +} diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 23f464df..9aecef43 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -1,8 +1,14 @@ import type { PackedGraphFeature } from "../modules/features"; import type { River } from "../modules/river-generator"; - -type TypedArray = Uint8Array | Uint16Array | Uint32Array | Int8Array | Int16Array | Float32Array | Float64Array; +type TypedArray = + | Uint8Array + | Uint16Array + | Uint32Array + | Int8Array + | Int16Array + | Float32Array + | Float64Array; export interface PackedGraph { cells: { @@ -33,4 +39,4 @@ export interface PackedGraph { }; rivers: River[]; features: PackedGraphFeature[]; -} \ No newline at end of file +} diff --git a/src/types/global.ts b/src/types/global.ts index 1f37d64e..43e2c1b0 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -1,5 +1,5 @@ -import type { Selection } from 'd3'; -import { PackedGraph } from "./PackedGraph"; +import type { Selection } from "d3"; +import type { PackedGraph } from "./PackedGraph"; declare global { var seed: string; @@ -7,14 +7,12 @@ declare global { var grid: any; var graphHeight: number; var graphWidth: number; - var TIME: boolean; var WARN: boolean; var ERROR: boolean; var heightmapTemplates: any; var Names: any; - var pointsInput: HTMLInputElement; var heightExponentInput: HTMLInputElement; @@ -30,4 +28,4 @@ declare global { icons: string[][]; cost: number[]; }; -} \ No newline at end of file +} diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts index ad2f9486..add587d8 100644 --- a/src/utils/arrayUtils.ts +++ b/src/utils/arrayUtils.ts @@ -5,7 +5,7 @@ */ export const last = (array: T[]): T => { return array[array.length - 1]; -} +}; /** * Get unique elements from an array @@ -14,7 +14,7 @@ export const last = (array: T[]): T => { */ export const unique = (array: T[]): T[] => { return [...new Set(array)]; -} +}; /** * Deep copy an object or array @@ -24,12 +24,15 @@ export const unique = (array: T[]): T[] => { export const deepCopy = (obj: T): T => { const id = (x: T): T => x; const dcTArray = (a: T[]): T[] => a.map(id); - const dcObject = (x: object): object => Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)])); - const dcAny = (x: any): any => (x instanceof Object ? (cf.get(x.constructor) || id)(x) : x); + const dcObject = (x: object): object => + Object.fromEntries(Object.entries(x).map(([k, d]) => [k, dcAny(d)])); + const dcAny = (x: any): any => + x instanceof Object ? (cf.get(x.constructor) || id)(x) : x; // don't map keys, probably this is what we would expect - const dcMapCore = (m: Map): [any, any][] => [...m.entries()].map(([k, v]) => [k, dcAny(v)]); + const dcMapCore = (m: Map): [any, any][] => + [...m.entries()].map(([k, v]) => [k, dcAny(v)]); - const cf: Map any> = new Map any>([ + const cf: Map any> = new Map any>([ [Int8Array, dcTArray], [Uint8Array, dcTArray], [Uint8ClampedArray, dcTArray], @@ -41,17 +44,17 @@ export const deepCopy = (obj: T): T => { [Float64Array, dcTArray], [BigInt64Array, dcTArray], [BigUint64Array, dcTArray], - [Map, m => new Map(dcMapCore(m))], - [WeakMap, m => new WeakMap(dcMapCore(m))], - [Array, a => a.map(dcAny)], - [Set, s => [...s.values()].map(dcAny)], - [Date, d => new Date(d.getTime())], - [Object, dcObject] + [Map, (m) => new Map(dcMapCore(m))], + [WeakMap, (m) => new WeakMap(dcMapCore(m))], + [Array, (a) => a.map(dcAny)], + [Set, (s) => [...s.values()].map(dcAny)], + [Date, (d) => new Date(d.getTime())], + [Object, dcObject], // ... extend here to implement their custom deep copy ]); return dcAny(obj); -} +}; /** * Get the appropriate typed array constructor based on the maximum value @@ -60,15 +63,17 @@ export const deepCopy = (obj: T): T => { */ export const getTypedArray = (maxValue: number) => { console.assert( - Number.isInteger(maxValue) && maxValue >= 0 && maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX, - `Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${maxValue}` + Number.isInteger(maxValue) && + maxValue >= 0 && + maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX, + `Array maxValue must be an integer between 0 and ${TYPED_ARRAY_MAX_VALUES.UINT32_MAX}, got ${maxValue}`, ); if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT8_MAX) return Uint8Array; if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT16_MAX) return Uint16Array; if (maxValue <= TYPED_ARRAY_MAX_VALUES.UINT32_MAX) return Uint32Array; return Uint32Array; -} +}; /** * Create a typed array based on the maximum value and length or from an existing array @@ -78,18 +83,26 @@ export const getTypedArray = (maxValue: number) => { * @param {Array} [options.from] - An optional array to create the typed array from * @returns The created typed array */ -export const createTypedArray = ({maxValue, length, from}: {maxValue: number; length: number; from?: ArrayLike}): Uint8Array | Uint16Array | Uint32Array => { +export const createTypedArray = ({ + maxValue, + length, + from, +}: { + maxValue: number; + length: number; + from?: ArrayLike; +}): Uint8Array | Uint16Array | Uint32Array => { const typedArray = getTypedArray(maxValue); if (!from) return new typedArray(length); return typedArray.from(from); -} +}; // typed arrays max values export const TYPED_ARRAY_MAX_VALUES = { INT8_MAX: 127, UINT8_MAX: 255, UINT16_MAX: 65535, - UINT32_MAX: 4294967295 + UINT32_MAX: 4294967295, }; declare global { diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index e64636fc..9a2d26f5 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -1,4 +1,12 @@ -import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffler } from "d3"; +import { + color, + interpolate, + interpolateRainbow, + type RGBColor, + range, + scaleSequential, + shuffler, +} from "d3"; /** * Convert RGB or RGBA color to HEX @@ -8,14 +16,16 @@ import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequentia export const toHEX = (rgba: string): string => { if (rgba.charAt(0) === "#") return rgba; - const matches = rgba.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i); + const matches = rgba.match( + /^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i, + ); return matches && matches.length === 4 ? "#" + - ("0" + parseInt(matches[1], 10).toString(16)).slice(-2) + - ("0" + parseInt(matches[2], 10).toString(16)).slice(-2) + - ("0" + parseInt(matches[3], 10).toString(16)).slice(-2) + `0${parseInt(matches[1], 10).toString(16)}`.slice(-2) + + `0${parseInt(matches[2], 10).toString(16)}`.slice(-2) + + `0${parseInt(matches[3], 10).toString(16)}`.slice(-2) : ""; -} +}; /** Predefined set of 12 distinct colors */ export const C_12 = [ @@ -30,33 +40,39 @@ export const C_12 = [ "#ccebc5", "#ffed6f", "#8dd3c7", - "#eb8de7" + "#eb8de7", ]; -/** +/** * Get an array of distinct colors * Uses shuffler with current Math.random to ensure seeded randomness works * @param {number} count - The count of colors to generate * @returns {string[]} - The array of HEX color strings -*/ + */ export const getColors = (count: number): string[] => { const scaleRainbow = scaleSequential(interpolateRainbow); // Use shuffler() to create a shuffle function that uses the current Math.random const shuffle = shuffler(() => Math.random()); const colors = shuffle( - range(count).map(i => (i < 12 ? C_12[i] : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex())) + range(count).map((i) => + i < 12 + ? C_12[i] + : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex(), + ), ); return colors.filter((c): c is string => typeof c === "string"); -} +}; /** * Get a random color in HEX format * @returns {string} - The HEX color string */ export const getRandomColor = (): string => { - const colorFromRainbow: RGBColor = color(scaleSequential(interpolateRainbow)(Math.random())) as RGBColor; + const colorFromRainbow: RGBColor = color( + scaleSequential(interpolateRainbow)(Math.random()), + ) as RGBColor; return colorFromRainbow.formatHex(); -} +}; /** * Get a mixed color by blending a given color with a random color @@ -65,11 +81,17 @@ export const getRandomColor = (): string => { * @param {number} bright - The brightness adjustment * @returns {string} - The mixed HEX color string */ -export const getMixedColor = (colorToMix: string, mix = 0.2, bright = 0.3): string => { +export const getMixedColor = ( + colorToMix: string, + mix = 0.2, + bright = 0.3, +): string => { const c = colorToMix && colorToMix[0] === "#" ? colorToMix : getRandomColor(); // if provided color is not hex (e.g. harching), generate random one - const mixedColor: RGBColor = color(interpolate(c, getRandomColor())(mix)) as RGBColor; + const mixedColor: RGBColor = color( + interpolate(c, getRandomColor())(mix), + ) as RGBColor; return mixedColor.brighter(bright).formatHex(); -} +}; declare global { interface Window { @@ -78,5 +100,5 @@ declare global { getRandomColor: typeof getRandomColor; getMixedColor: typeof getMixedColor; C_12: typeof C_12; - } + } } diff --git a/src/utils/commonUtils.test.ts b/src/utils/commonUtils.test.ts index 6ca595d3..c5ed8f7e 100644 --- a/src/utils/commonUtils.test.ts +++ b/src/utils/commonUtils.test.ts @@ -1,28 +1,28 @@ -import { expect, describe, it } from 'vitest' -import { getLongitude, getLatitude, getCoordinates } from './commonUtils' +import { describe, expect, it } from "vitest"; +import { getCoordinates, getLatitude, getLongitude } from "./commonUtils"; -describe('getLongitude', () => { +describe("getLongitude", () => { const mapCoordinates = { lonW: -10, lonT: 20 }; const graphWidth = 1000; - it('should calculate longitude at the left edge (x=0)', () => { + it("should calculate longitude at the left edge (x=0)", () => { expect(getLongitude(0, mapCoordinates, graphWidth, 2)).toBe(-10); }); - it('should calculate longitude at the right edge (x=graphWidth)', () => { + it("should calculate longitude at the right edge (x=graphWidth)", () => { expect(getLongitude(1000, mapCoordinates, graphWidth, 2)).toBe(10); }); - it('should calculate longitude at the center (x=graphWidth/2)', () => { + it("should calculate longitude at the center (x=graphWidth/2)", () => { expect(getLongitude(500, mapCoordinates, graphWidth, 2)).toBe(0); }); - it('should respect decimal precision', () => { + it("should respect decimal precision", () => { // 333/1000 * 20 = 6.66, -10 + 6.66 = -3.34 expect(getLongitude(333, mapCoordinates, graphWidth, 4)).toBe(-3.34); }); - it('should handle different map coordinate ranges', () => { + it("should handle different map coordinate ranges", () => { const wideMap = { lonW: -180, lonT: 360 }; expect(getLongitude(500, wideMap, graphWidth, 2)).toBe(0); expect(getLongitude(0, wideMap, graphWidth, 2)).toBe(-180); @@ -30,68 +30,109 @@ describe('getLongitude', () => { }); }); -describe('getLatitude', () => { +describe("getLatitude", () => { const mapCoordinates = { latN: 60, latT: 40 }; const graphHeight = 800; - it('should calculate latitude at the top edge (y=0)', () => { + it("should calculate latitude at the top edge (y=0)", () => { expect(getLatitude(0, mapCoordinates, graphHeight, 2)).toBe(60); }); - it('should calculate latitude at the bottom edge (y=graphHeight)', () => { + it("should calculate latitude at the bottom edge (y=graphHeight)", () => { expect(getLatitude(800, mapCoordinates, graphHeight, 2)).toBe(20); }); - it('should calculate latitude at the center (y=graphHeight/2)', () => { + it("should calculate latitude at the center (y=graphHeight/2)", () => { expect(getLatitude(400, mapCoordinates, graphHeight, 2)).toBe(40); }); - it('should respect decimal precision', () => { + it("should respect decimal precision", () => { // 60 - (333/800 * 40) = 60 - 16.65 = 43.35 expect(getLatitude(333, mapCoordinates, graphHeight, 4)).toBe(43.35); }); - it('should handle equator-centered maps', () => { + it("should handle equator-centered maps", () => { const equatorMap = { latN: 45, latT: 90 }; expect(getLatitude(400, equatorMap, graphHeight, 2)).toBe(0); }); }); -describe('getCoordinates', () => { +describe("getCoordinates", () => { const mapCoordinates = { lonW: -10, lonT: 20, latN: 60, latT: 40 }; const graphWidth = 1000; const graphHeight = 800; - it('should return [longitude, latitude] tuple', () => { - const result = getCoordinates(500, 400, mapCoordinates, graphWidth, graphHeight, 2); + it("should return [longitude, latitude] tuple", () => { + const result = getCoordinates( + 500, + 400, + mapCoordinates, + graphWidth, + graphHeight, + 2, + ); expect(result).toEqual([0, 40]); }); - it('should calculate coordinates at top-left corner', () => { - const result = getCoordinates(0, 0, mapCoordinates, graphWidth, graphHeight, 2); + it("should calculate coordinates at top-left corner", () => { + const result = getCoordinates( + 0, + 0, + mapCoordinates, + graphWidth, + graphHeight, + 2, + ); expect(result).toEqual([-10, 60]); }); - it('should calculate coordinates at bottom-right corner', () => { - const result = getCoordinates(1000, 800, mapCoordinates, graphWidth, graphHeight, 2); + it("should calculate coordinates at bottom-right corner", () => { + const result = getCoordinates( + 1000, + 800, + mapCoordinates, + graphWidth, + graphHeight, + 2, + ); expect(result).toEqual([10, 20]); }); - it('should respect decimal precision for both coordinates', () => { - const result = getCoordinates(333, 333, mapCoordinates, graphWidth, graphHeight, 4); + it("should respect decimal precision for both coordinates", () => { + const result = getCoordinates( + 333, + 333, + mapCoordinates, + graphWidth, + graphHeight, + 4, + ); expect(result[0]).toBe(-3.34); // longitude expect(result[1]).toBe(43.35); // latitude }); - it('should use default precision of 2 decimals', () => { - const result = getCoordinates(333, 333, mapCoordinates, graphWidth, graphHeight); + it("should use default precision of 2 decimals", () => { + const result = getCoordinates( + 333, + 333, + mapCoordinates, + graphWidth, + graphHeight, + ); expect(result[0]).toBe(-3.34); expect(result[1]).toBe(43.35); }); - it('should handle global map coordinates', () => { + it("should handle global map coordinates", () => { const globalMap = { lonW: -180, lonT: 360, latN: 90, latT: 180 }; - const result = getCoordinates(500, 400, globalMap, graphWidth, graphHeight, 2); + const result = getCoordinates( + 500, + 400, + globalMap, + graphWidth, + graphHeight, + 2, + ); expect(result).toEqual([0, 0]); // center of the world }); }); diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts index f326c067..6808eb11 100644 --- a/src/utils/commonUtils.ts +++ b/src/utils/commonUtils.ts @@ -1,7 +1,7 @@ -import { distanceSquared } from "./functionUtils"; -import { rand } from "./probabilityUtils"; -import { rn } from "./numberUtils"; import { last } from "./arrayUtils"; +import { distanceSquared } from "./functionUtils"; +import { rn } from "./numberUtils"; +import { rand } from "./probabilityUtils"; /** * Clip polygon points to graph boundaries @@ -11,15 +11,20 @@ import { last } from "./arrayUtils"; * @param secure - Secure clipping to avoid edge artifacts * @returns Clipped polygon points */ -export const clipPoly = (points: [number, number][], graphWidth?: number, graphHeight?: number, secure: number = 0) => { +export const clipPoly = ( + points: [number, number][], + graphWidth?: number, + graphHeight?: number, + secure: number = 0, +) => { if (points.length < 2) return points; - if (points.some(point => point === undefined)) { + if (points.some((point) => point === undefined)) { window.ERROR && console.error("Undefined point in clipPoly", points); return points; } return window.polygonclip(points, [0, 0, graphWidth, graphHeight], secure); -} +}; /** * Get segment of any point on polyline @@ -28,7 +33,11 @@ export const clipPoly = (points: [number, number][], graphWidth?: number, graphH * @param step - Step size for segment search (default is 10) * @returns The segment ID (1-indexed) */ -export const getSegmentId = (points: [number, number][], point: [number, number], step: number = 10): number => { +export const getSegmentId = ( + points: [number, number][], + point: [number, number], + step: number = 10, +): number => { if (points.length === 2) return 1; let minSegment = 1; @@ -55,7 +64,7 @@ export const getSegmentId = (points: [number, number][], point: [number, number] } return minSegment; -} +}; /** * Creates a debounced function that delays invoking func until after ms milliseconds have elapsed @@ -63,16 +72,21 @@ export const getSegmentId = (points: [number, number][], point: [number, number] * @param ms - The number of milliseconds to delay * @returns The debounced function */ -export const debounce = any>(func: T, ms: number) => { +export const debounce = any>( + func: T, + ms: number, +) => { let isCooldown = false; return function (this: any, ...args: Parameters) { if (isCooldown) return; func.apply(this, args); isCooldown = true; - setTimeout(() => (isCooldown = false), ms); + setTimeout(() => { + isCooldown = false; + }, ms); }; -} +}; /** * Creates a throttled function that only invokes func at most once every ms milliseconds @@ -80,7 +94,10 @@ export const debounce = any>(func: T, ms: number) * @param ms - The number of milliseconds to throttle invocations to * @returns The throttled function */ -export const throttle = any>(func: T, ms: number) => { +export const throttle = any>( + func: T, + ms: number, +) => { let isThrottled = false; let savedArgs: any[] | null = null; let savedThis: any = null; @@ -95,7 +112,7 @@ export const throttle = any>(func: T, ms: number) func.apply(this, args); isThrottled = true; - setTimeout(function () { + setTimeout(() => { isThrottled = false; if (savedArgs) { wrapper.apply(savedThis, savedArgs as Parameters); @@ -105,7 +122,7 @@ export const throttle = any>(func: T, ms: number) } return wrapper; -} +}; /** * Parse error to get the readable string in Chrome and Firefox @@ -114,23 +131,32 @@ export const throttle = any>(func: T, ms: number) */ export const parseError = (error: Error): string => { const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1; - const errorString = isFirefox ? error.toString() + " " + error.stack : error.stack || ""; - const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; - const errorNoURL = errorString.replace(regex, url => "" + last(url.split("/")) + ""); + const errorString = isFirefox + ? `${error.toString()} ${error.stack}` + : error.stack || ""; + const regex = + /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi; + const errorNoURL = errorString.replace( + regex, + (url) => `${last(url.split("/"))}`, + ); const errorParsed = errorNoURL.replace(/at /gi, "
  at "); return errorParsed; -} +}; /** * Convert a URL to base64 encoded data * @param url - The URL to convert * @param callback - Callback function that receives the base64 data */ -export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | null) => void): void => { +export const getBase64 = ( + url: string, + callback: (result: string | ArrayBuffer | null) => void, +): void => { const xhr = new XMLHttpRequest(); - xhr.onload = function () { + xhr.onload = () => { const reader = new FileReader(); - reader.onloadend = function () { + reader.onloadend = () => { callback(reader.result); }; reader.readAsDataURL(xhr.response); @@ -138,7 +164,7 @@ export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | xhr.open("GET", url); xhr.responseType = "blob"; xhr.send(); -} +}; /** * Open URL in a new tab or window @@ -146,15 +172,18 @@ export const getBase64 = (url: string, callback: (result: string | ArrayBuffer | */ export const openURL = (url: string): void => { window.open(url, "_blank"); -} +}; /** * Open project wiki-page * @param page - The wiki page name/path to open */ export const wiki = (page: string): void => { - window.open("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/" + page, "_blank"); -} + window.open( + `https://github.com/Azgaar/Fantasy-Map-Generator/wiki/${page}`, + "_blank", + ); +}; /** * Wrap URL into html a element @@ -164,7 +193,7 @@ export const wiki = (page: string): void => { */ export const link = (URL: string, description: string): string => { return `${description}`; -} +}; /** * Check if Ctrl key (or Cmd on Mac) was pressed during an event @@ -174,7 +203,7 @@ export const link = (URL: string, description: string): string => { export const isCtrlClick = (event: MouseEvent | KeyboardEvent): boolean => { // meta key is cmd key on MacOs return event.ctrlKey || event.metaKey; -} +}; /** * Generate a random date within a specified range @@ -186,9 +215,9 @@ export const generateDate = (from: number = 100, to: number = 1000): string => { return new Date(rand(from, to), rand(12), rand(31)).toLocaleDateString("en", { year: "numeric", month: "long", - day: "numeric" + day: "numeric", }); -} +}; /** * Convert x coordinate to longitude @@ -198,9 +227,17 @@ export const generateDate = (from: number = 100, to: number = 1000): string => { * @param decimals - Number of decimal places (default is 2) * @returns Longitude value */ -export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, decimals: number = 2): number => { - return rn(mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, decimals); -} +export const getLongitude = ( + x: number, + mapCoordinates: any, + graphWidth: number, + decimals: number = 2, +): number => { + return rn( + mapCoordinates.lonW + (x / graphWidth) * mapCoordinates.lonT, + decimals, + ); +}; /** * Convert y coordinate to latitude @@ -210,9 +247,17 @@ export const getLongitude = (x: number, mapCoordinates: any, graphWidth: number, * @param decimals - Number of decimal places (default is 2) * @returns Latitude value */ -export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, decimals: number = 2): number => { - return rn(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, decimals); -} +export const getLatitude = ( + y: number, + mapCoordinates: any, + graphHeight: number, + decimals: number = 2, +): number => { + return rn( + mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT, + decimals, + ); +}; /** * Convert x,y coordinates to longitude,latitude @@ -224,9 +269,19 @@ export const getLatitude = (y: number, mapCoordinates: any, graphHeight: number, * @param decimals - Number of decimal places (default is 2) * @returns Array with [longitude, latitude] */ -export const getCoordinates = (x: number, y: number, mapCoordinates: any, graphWidth: number, graphHeight: number, decimals: number = 2): [number, number] => { - return [getLongitude(x, mapCoordinates, graphWidth, decimals), getLatitude(y, mapCoordinates, graphHeight, decimals)]; -} +export const getCoordinates = ( + x: number, + y: number, + mapCoordinates: any, + graphWidth: number, + graphHeight: number, + decimals: number = 2, +): [number, number] => { + return [ + getLongitude(x, mapCoordinates, graphWidth, decimals), + getLatitude(y, mapCoordinates, graphHeight, decimals), + ]; +}; /** * Prompt options interface @@ -246,22 +301,39 @@ export interface PromptOptions { export const initializePrompt = (): void => { const prompt = document.getElementById("prompt"); if (!prompt) return; - + const form = prompt.querySelector("#promptForm"); if (!form) return; const defaultText = "Please provide an input"; - const defaultOptions: PromptOptions = {default: 1, step: 0.01, min: 0, max: 100, required: true}; + const defaultOptions: PromptOptions = { + default: 1, + step: 0.01, + min: 0, + max: 100, + required: true, + }; - (window as any).prompt = function (promptText: string = defaultText, options: PromptOptions = defaultOptions, callback?: (value: number | string) => void) { + (window as any).prompt = ( + promptText: string = defaultText, + options: PromptOptions = defaultOptions, + callback?: (value: number | string) => void, + ) => { if (options.default === undefined) - return window.ERROR && console.error("Prompt: options object does not have default value defined"); + return ( + window.ERROR && + console.error( + "Prompt: options object does not have default value defined", + ) + ); const input = prompt.querySelector("#promptInput") as HTMLInputElement; - const promptTextElement = prompt.querySelector("#promptText") as HTMLElement; - + const promptTextElement = prompt.querySelector( + "#promptText", + ) as HTMLElement; + if (!input || !promptTextElement) return; - + promptTextElement.innerHTML = promptText; const type = typeof options.default === "number" ? "number" : "text"; @@ -271,8 +343,8 @@ export const initializePrompt = (): void => { if (options.min !== undefined) input.min = options.min.toString(); if (options.max !== undefined) input.max = options.max.toString(); - input.required = options.required === false ? false : true; - input.placeholder = "type a " + type; + input.required = options.required !== false; + input.placeholder = `type a ${type}`; input.value = options.default.toString(); input.style.width = promptText.length > 10 ? "100%" : "auto"; prompt.style.display = "block"; @@ -285,7 +357,7 @@ export const initializePrompt = (): void => { const v = type === "number" ? +input.value : input.value; if (callback) callback(v); }, - {once: true} + { once: true }, ); }; @@ -295,13 +367,13 @@ export const initializePrompt = (): void => { prompt.style.display = "none"; }); } -} +}; declare global { interface Window { ERROR: boolean; polygonclip: any; - + clipPoly: typeof clipPoly; getSegmentId: typeof getSegmentId; debounce: typeof debounce; @@ -319,7 +391,14 @@ declare global { } // Global variables defined in main.js - var mapCoordinates: { latT?: number; latN?: number; latS?: number; lonT?: number; lonW?: number; lonE?: number }; + var mapCoordinates: { + latT?: number; + latN?: number; + latS?: number; + lonT?: number; + lonW?: number; + lonE?: number; + }; var graphWidth: number; var graphHeight: number; -} \ No newline at end of file +} diff --git a/src/utils/debugUtils.ts b/src/utils/debugUtils.ts index 6b236ebe..dec49390 100644 --- a/src/utils/debugUtils.ts +++ b/src/utils/debugUtils.ts @@ -1,7 +1,7 @@ -import {curveBundle, line, max, min} from "d3"; -import { normalize } from "./numberUtils"; -import { getGridPolygon } from "./graphUtils"; +import { curveBundle, line, max, min } from "d3"; import { C_12 } from "./colorUtils"; +import { getGridPolygon } from "./graphUtils"; +import { normalize } from "./numberUtils"; import { round } from "./stringUtils"; /** @@ -19,7 +19,7 @@ export const drawCellsValue = (data: any[], packedGraph: any): void => { .attr("x", (_d: any, i: number) => packedGraph.cells.p[i][0]) .attr("y", (_d: any, i: number) => packedGraph.cells.p[i][1]) .text((d: any) => d); -} +}; /** * Drawing polygons colored according to data values for debugging purposes * @param {number[]} data - Array of numerical values corresponding to each cell @@ -28,9 +28,11 @@ export const drawCellsValue = (data: any[], packedGraph: any): void => { export const drawPolygons = (data: number[], terrs: any, grid: any): void => { const maximum: number = max(data) as number; const minimum: number = min(data) as number; - const scheme = window.getColorScheme(terrs.select("#landHeights").attr("scheme")); + const scheme = window.getColorScheme( + terrs.select("#landHeights").attr("scheme"), + ); - data = data.map(d => 1 - normalize(d, minimum, maximum)); + data = data.map((d) => 1 - normalize(d, minimum, maximum)); window.debug.selectAll("polygon").remove(); window.debug .selectAll("polygon") @@ -40,7 +42,7 @@ export const drawPolygons = (data: number[], terrs: any, grid: any): void => { .attr("points", (_d: number, i: number) => getGridPolygon(i, grid)) .attr("fill", (d: number) => scheme(d)) .attr("stroke", (d: number) => scheme(d)); -} +}; /** * Drawing route connections for debugging purposes @@ -48,7 +50,10 @@ export const drawPolygons = (data: number[], terrs: any, grid: any): void => { */ export const drawRouteConnections = (packedGraph: any): void => { window.debug.select("#connections").remove(); - const routes = window.debug.append("g").attr("id", "connections").attr("stroke-width", 0.8); + const routes = window.debug + .append("g") + .attr("id", "connections") + .attr("stroke-width", 0.8); const points = packedGraph.cells.p; const links = packedGraph.cells.routes; @@ -70,7 +75,7 @@ export const drawRouteConnections = (packedGraph: any): void => { .attr("stroke", C_12[routeId % 12]); } } -} +}; /** * Drawing a point for debugging purposes @@ -79,9 +84,17 @@ export const drawRouteConnections = (packedGraph: any): void => { * @param {string} options.color - Color of the point * @param {number} options.radius - Radius of the point */ -export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5}): void => { - window.debug.append("circle").attr("cx", x).attr("cy", y).attr("r", radius).attr("fill", color); -} +export const drawPoint = ( + [x, y]: [number, number], + { color = "red", radius = 0.5 }, +): void => { + window.debug + .append("circle") + .attr("cx", x) + .attr("cy", y) + .attr("r", radius) + .attr("fill", color); +}; /** * Drawing a path for debugging purposes @@ -90,7 +103,10 @@ export const drawPoint = ([x, y]: [number, number], {color = "red", radius = 0.5 * @param {string} options.color - Color of the path * @param {number} options.width - Stroke width of the path */ -export const drawPath = (points: [number, number][], {color = "red", width = 0.5}): void => { +export const drawPath = ( + points: [number, number][], + { color = "red", width = 0.5 }, +): void => { const lineGen = line().curve(curveBundle); window.debug .append("path") @@ -98,17 +114,17 @@ export const drawPath = (points: [number, number][], {color = "red", width = 0.5 .attr("stroke", color) .attr("stroke-width", width) .attr("fill", "none"); -} +}; declare global { interface Window { debug: any; getColorScheme: (name: string) => (t: number) => string; - + drawCellsValue: typeof drawCellsValue; drawPolygons: typeof drawPolygons; drawRouteConnections: typeof drawRouteConnections; drawPoint: typeof drawPoint; drawPath: typeof drawPath; - } -} \ No newline at end of file + } +} diff --git a/src/utils/functionUtils.ts b/src/utils/functionUtils.ts index 5a3d7283..a753c019 100644 --- a/src/utils/functionUtils.ts +++ b/src/utils/functionUtils.ts @@ -4,7 +4,7 @@ * @param {Function} reduce - The reduce function to apply to each group * @param {...Function} keys - The key functions to group by * @returns {Map} - The regrouped and reduced Map - * + * * @example * const data = [ * {category: 'A', type: 'X', value: 10}, @@ -24,11 +24,20 @@ * // 'B' => Map { 'X' => 30, 'Y' => 40 } * // } */ -export const rollups = (values: any[], reduce: (values: any[]) => any, ...keys: ((value: any, index: number, array: any[]) => any)[]) => { +export const rollups = ( + values: any[], + reduce: (values: any[]) => any, + ...keys: ((value: any, index: number, array: any[]) => any)[] +) => { return nest(values, Array.from, reduce, keys); -} +}; -const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (values: any[]) => any, keys: ((value: any, index: number, array: any[]) => any)[]) => { +const nest = ( + values: any[], + map: (iterable: Iterable) => any, + reduce: (values: any[]) => any, + keys: ((value: any, index: number, array: any[]) => any)[], +) => { return (function regroup(values, i) { if (i >= keys.length) return reduce(values); const groups = new Map(); @@ -45,7 +54,7 @@ const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (val } return map(groups); })(values, 0); -} +}; /** * Calculate squared distance between two points @@ -53,12 +62,15 @@ const nest = (values: any[], map: (iterable: Iterable) => any, reduce: (val * @param {[number, number]} p2 - Second point [x2, y2] * @returns {number} - Squared distance between p1 and p2 */ -export const distanceSquared = ([x1, y1]: [number, number], [x2, y2]: [number, number]) => { +export const distanceSquared = ( + [x1, y1]: [number, number], + [x2, y2]: [number, number], +) => { return (x1 - x2) ** 2 + (y1 - y2) ** 2; -} +}; declare global { interface Window { rollups: typeof rollups; dist2: typeof distanceSquared; } -} \ No newline at end of file +} diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index 274d69f9..83ef0ae5 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -1,10 +1,15 @@ -import Delaunator from "delaunator"; import Alea from "alea"; import { color } from "d3"; -import { byId } from "./shorthands"; -import { rn } from "./numberUtils"; +import Delaunator from "delaunator"; +import { + type Cells, + type Point, + type Vertices, + Voronoi, +} from "../modules/voronoi"; import { createTypedArray } from "./arrayUtils"; -import { Cells, Vertices, Voronoi, Point } from "../modules/voronoi"; +import { rn } from "./numberUtils"; +import { byId } from "./shorthands"; /** * Get boundary points on a regular square grid @@ -13,7 +18,11 @@ import { Cells, Vertices, Voronoi, Point } from "../modules/voronoi"; * @param {number} spacing - The spacing between points * @returns {Array} - An array of boundary points */ -const getBoundaryPoints = (width: number, height: number, spacing: number): Point[] => { +const getBoundaryPoints = ( + width: number, + height: number, + spacing: number, +): Point[] => { const offset = rn(-1 * spacing); const bSpacing = spacing * 2; const w = width - offset * 2; @@ -23,17 +32,17 @@ const getBoundaryPoints = (width: number, height: number, spacing: number): Poin const points: Point[] = []; for (let i = 0.5; i < numberX; i++) { - let x = Math.ceil((w * i) / numberX + offset); + const x = Math.ceil((w * i) / numberX + offset); points.push([x, offset], [x, h + offset]); } for (let i = 0.5; i < numberY; i++) { - let y = Math.ceil((h * i) / numberY + offset); + const y = Math.ceil((h * i) / numberY + offset); points.push([offset, y], [w + offset, y]); } return points; -} +}; /** * Get points on a jittered square grid @@ -42,13 +51,17 @@ const getBoundaryPoints = (width: number, height: number, spacing: number): Poin * @param {number} spacing - The spacing between points * @returns {Array} - An array of jittered grid points */ -const getJitteredGrid = (width: number, height: number, spacing: number): Point[] => { +const getJitteredGrid = ( + width: number, + height: number, + spacing: number, +): Point[] => { const radius = spacing / 2; // square radius const jittering = radius * 0.9; // max deviation const doubleJittering = jittering * 2; const jitter = () => Math.random() * doubleJittering - jittering; - let points: Point[] = []; + const points: Point[] = []; for (let y = radius; y < height; y += spacing) { for (let x = radius; x < width; x += spacing) { const xj = Math.min(rn(x + jitter(), 2), width); @@ -57,7 +70,7 @@ const getJitteredGrid = (width: number, height: number, spacing: number): Point[ } } return points; -} +}; /** * Places points on a jittered grid and calculates spacing and cell counts @@ -65,7 +78,17 @@ const getJitteredGrid = (width: number, height: number, spacing: number): Point[ * @param {number} graphHeight - The height of the graph * @returns {Object} - An object containing spacing, cellsDesired, boundary points, grid points, cellsX, and cellsY */ -const placePoints = (graphWidth: number, graphHeight: number): {spacing: number, cellsDesired: number, boundary: Point[], points: Point[], cellsX: number, cellsY: number} => { +const placePoints = ( + graphWidth: number, + graphHeight: number, +): { + spacing: number; + cellsDesired: number; + boundary: Point[]; + points: Point[]; + cellsX: number; + cellsY: number; +} => { TIME && console.time("placePoints"); const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0); const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jittering @@ -73,12 +96,20 @@ const placePoints = (graphWidth: number, graphHeight: number): {spacing: number, const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing); const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid const cellCountX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing); // number of cells in x direction - const cellCountY = Math.floor((graphHeight + 0.5 * spacing - 1e-10) / spacing); // number of cells in y direction + const cellCountY = Math.floor( + (graphHeight + 0.5 * spacing - 1e-10) / spacing, + ); // number of cells in y direction TIME && console.timeEnd("placePoints"); - return {spacing, cellsDesired, boundary, points, cellsX: cellCountX, cellsY: cellCountY}; -} - + return { + spacing, + cellsDesired, + boundary, + points, + cellsX: cellCountX, + cellsY: cellCountY, + }; +}; /** * Checks if the grid needs to be regenerated based on desired parameters @@ -88,18 +119,34 @@ const placePoints = (graphWidth: number, graphHeight: number): {spacing: number, * @param {number} graphHeight - The height of the graph * @returns {boolean} - True if the grid should be regenerated, false otherwise */ -export const shouldRegenerateGrid = (grid: any, expectedSeed: number, graphWidth: number, graphHeight: number) => { +export const shouldRegenerateGrid = ( + grid: any, + expectedSeed: number, + graphWidth: number, + graphHeight: number, +) => { if (expectedSeed && expectedSeed !== grid.seed) return true; const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0); if (cellsDesired !== grid.cellsDesired) return true; - const newSpacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); - const newCellsX = Math.floor((graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing); - const newCellsY = Math.floor((graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing); + const newSpacing = rn( + Math.sqrt((graphWidth * graphHeight) / cellsDesired), + 2, + ); + const newCellsX = Math.floor( + (graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing, + ); + const newCellsY = Math.floor( + (graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing, + ); - return grid.spacing !== newSpacing || grid.cellsX !== newCellsX || grid.cellsY !== newCellsY; -} + return ( + grid.spacing !== newSpacing || + grid.cellsX !== newCellsX || + grid.cellsY !== newCellsY + ); +}; interface Grid { spacing: number; @@ -116,12 +163,27 @@ interface Grid { * Generates a Voronoi grid based on jittered grid points * @returns {Object} - The generated grid object containing spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, and seed */ -export const generateGrid = (seed: string, graphWidth: number, graphHeight: number): Grid => { +export const generateGrid = ( + seed: string, + graphWidth: number, + graphHeight: number, +): Grid => { Math.random = Alea(seed); // reset PRNG - const {spacing, cellsDesired, boundary, points, cellsX, cellsY} = placePoints(graphWidth, graphHeight); - const {cells, vertices} = calculateVoronoi(points, boundary); - return {spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, seed}; -} + const { spacing, cellsDesired, boundary, points, cellsX, cellsY } = + placePoints(graphWidth, graphHeight); + const { cells, vertices } = calculateVoronoi(points, boundary); + return { + spacing, + cellsDesired, + boundary, + points, + cellsX, + cellsY, + cells, + vertices, + seed, + }; +}; /** * Calculates the Voronoi diagram from given points and boundary @@ -129,7 +191,10 @@ export const generateGrid = (seed: string, graphWidth: number, graphHeight: numb * @param {Array} boundary - The boundary points to clip the Voronoi cells * @returns {Object} - An object containing Voronoi cells and vertices */ -export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Cells, vertices: Vertices} => { +export const calculateVoronoi = ( + points: Point[], + boundary: Point[], +): { cells: Cells; vertices: Vertices } => { TIME && console.time("calculateDelaunay"); const allPoints = points.concat(boundary); const delaunay = Delaunator.from(allPoints); @@ -139,12 +204,15 @@ export const calculateVoronoi = (points: Point[], boundary: Point[]): {cells: Ce const voronoi = new Voronoi(delaunay, allPoints, points.length); const cells = voronoi.cells; - cells.i = createTypedArray({maxValue: points.length, length: points.length}).map((_, i) => i) as Uint32Array; // array of indexes + cells.i = createTypedArray({ + maxValue: points.length, + length: points.length, + }).map((_, i) => i) as Uint32Array; // array of indexes const vertices = voronoi.vertices; TIME && console.timeEnd("calculateVoronoi"); - return {cells, vertices}; -} + return { cells, vertices }; +}; /** * Returns a cell index on a regular square grid based on x and y coordinates @@ -158,9 +226,9 @@ export const findGridCell = (x: number, y: number, grid: any): number => { Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX + Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1)) ); -} +}; -/** +/** * return array of cell indexes in radius on a regular square grid * @param {number} x - The x coordinate * @param {number} y - The y coordinate @@ -168,7 +236,12 @@ export const findGridCell = (x: number, y: number, grid: any): number => { * @param {Object} grid - The grid object containing spacing, cellsX, and cellsY * @returns {Array} - An array of cell indexes within the specified radius */ -export const findGridAll = (x: number, y: number, radius: number, grid: any): number[] => { +export const findGridAll = ( + x: number, + y: number, + radius: number, + grid: any, +): number[] => { const c = grid.cells.c; let r = Math.floor(radius / grid.spacing); let found = [findGridCell(x, y, grid)]; @@ -177,10 +250,10 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu if (r > 1) { let frontier = c[found[0]]; while (r > 1) { - let cycle = frontier.slice(); + const cycle = frontier.slice(); frontier = []; - cycle.forEach(function (s: number) { - c[s].forEach(function (e: number) { + cycle.forEach((s: number) => { + c[s].forEach((e: number) => { if (found.indexOf(e) !== -1) return; found.push(e); frontier.push(e); @@ -191,7 +264,7 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu } return found; -} +}; /** * Returns the index of the packed cell containing the given x and y coordinates @@ -200,11 +273,16 @@ export const findGridAll = (x: number, y: number, radius: number, grid: any): nu * @param {number} radius - The search radius (default is Infinity) * @returns {number|undefined} - The index of the found cell or undefined if not found */ -export const findClosestCell = (x: number, y: number, radius = Infinity, packedGraph: any): number | undefined => { +export const findClosestCell = ( + x: number, + y: number, + radius = Infinity, + packedGraph: any, +): number | undefined => { if (!packedGraph.cells?.q) return; const found = packedGraph.cells.q.find(x, y, radius); return found ? found[2] : undefined; -} +}; /** * Searches a quadtree for all points within a given radius @@ -215,21 +293,31 @@ export const findClosestCell = (x: number, y: number, radius = Infinity, packedG * @param {Object} quadtree - The D3 quadtree to search * @returns {Array} - An array of found data points within the radius */ -export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => { +export const findAllInQuadtree = ( + x: number, + y: number, + radius: number, + quadtree: any, +) => { + let dx: number, dy: number, d2: number; + const radiusSearchInit = (t: any, radius: number) => { t.result = []; - (t.x0 = t.x - radius), (t.y0 = t.y - radius); - (t.x3 = t.x + radius), (t.y3 = t.y + radius); + t.x0 = t.x - radius; + t.y0 = t.y - radius; + t.x3 = t.x + radius; + t.y3 = t.y + radius; t.radius = radius * radius; }; const radiusSearchVisit = (t: any, d2: number) => { t.node.data.scanned = true; if (d2 < t.radius) { - do { + while (t.node) { t.result.push(t.node.data); t.node.data.selected = true; - } while ((t.node = t.node.next)); + t.node = t.node.next; + } } }; @@ -248,39 +336,52 @@ export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree } } - const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root}; + const t: any = { + x, + y, + x0: quadtree._x0, + y0: quadtree._y0, + x3: quadtree._x1, + y3: quadtree._y1, + quads: [], + node: quadtree._root, + }; if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3)); radiusSearchInit(t, radius); - var i = 0; - while ((t.q = t.quads.pop())) { - i++; + var _i = 0; + t.q = t.quads.pop(); + while (t.q) { + _i++; + + t.node = t.q.node; + t.x1 = t.q.x0; + t.y1 = t.q.y0; + t.x2 = t.q.x1; + t.y2 = t.q.y1; // Stop searching if this quadrant can't contain a closer node. - if ( - !(t.node = t.q.node) || - (t.x1 = t.q.x0) > t.x3 || - (t.y1 = t.q.y0) > t.y3 || - (t.x2 = t.q.x1) < t.x0 || - (t.y2 = t.q.y1) < t.y0 - ) + if (!t.node || t.x1 > t.x3 || t.y1 > t.y3 || t.x2 < t.x0 || t.y2 < t.y0) { + t.q = t.quads.pop(); continue; + } // Bisect the current quadrant. if (t.node.length) { t.node.explored = true; - var xm: number = (t.x1 + t.x2) / 2, + const xm: number = (t.x1 + t.x2) / 2, ym: number = (t.y1 + t.y2) / 2; t.quads.push( new Quad(t.node[3], xm, ym, t.x2, t.y2), new Quad(t.node[2], t.x1, ym, xm, t.y2), new Quad(t.node[1], xm, t.y1, t.x2, ym), - new Quad(t.node[0], t.x1, t.y1, xm, ym) + new Quad(t.node[0], t.x1, t.y1, xm, ym), ); // Visit the closest quadrant first. - if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) { + t.i = (+(y >= ym) << 1) | +(x >= xm); + if (t.i) { t.q = t.quads[t.quads.length - 1]; t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; t.quads[t.quads.length - 1 - t.i] = t.q; @@ -289,14 +390,15 @@ export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree // Visit this point. (Visiting coincident points isn't necessary!) else { - var dx = x - +quadtree._x.call(null, t.node.data), - dy = y - +quadtree._y.call(null, t.node.data), - d2 = dx * dx + dy * dy; + dx = x - +quadtree._x.call(null, t.node.data); + dy = y - +quadtree._y.call(null, t.node.data); + d2 = dx * dx + dy * dy; radiusSearchVisit(t, d2); } + t.q = t.quads.pop(); } return t.result; -} +}; /** * Returns an array of packed cell indexes within a specified radius from given x and y coordinates @@ -306,11 +408,16 @@ export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree * @param {Object} packedGraph - The packed graph containing cells with quadtree * @returns {number[]} - An array of cell indexes within the radius */ -export const findAllCellsInRadius = (x: number, y: number, radius: number, packedGraph: any): number[] => { +export const findAllCellsInRadius = ( + x: number, + y: number, + radius: number, + packedGraph: any, +): number[] => { // Use findAllInQuadtree directly instead of relying on prototype extension const found = findAllInQuadtree(x, y, radius, packedGraph.cells.q); return found.map((r: any) => r[2]); -} +}; /** * Returns the polygon points for a packed cell given its index @@ -318,8 +425,10 @@ export const findAllCellsInRadius = (x: number, y: number, radius: number, packe * @returns {Array} - An array of polygon points for the specified cell */ export const getPackPolygon = (cellIndex: number, packedGraph: any) => { - return packedGraph.cells.v[cellIndex].map((v: number) => packedGraph.vertices.p[v]); -} + return packedGraph.cells.v[cellIndex].map( + (v: number) => packedGraph.vertices.p[v], + ); +}; /** * Returns the polygon points for a grid cell given its index @@ -328,7 +437,7 @@ export const getPackPolygon = (cellIndex: number, packedGraph: any) => { */ export const getGridPolygon = (i: number, grid: any) => { return grid.cells.v[i].map((v: number) => grid.vertices.p[v]); -} +}; /** * mbostock's poissonDiscSampler implementation @@ -341,7 +450,14 @@ export const getGridPolygon = (i: number, grid: any) => { * @param {number} k - The number of attempts before rejection (default is 3) * @yields {Array} - An array containing the x and y coordinates of a generated point */ -export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: number, r: number, k = 3) { +export function* poissonDiscSampler( + x0: number, + y0: number, + x1: number, + y1: number, + r: number, + k = 3, +) { if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error(); const width = x1 - x0; @@ -377,7 +493,8 @@ export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: numb function sample(x: number, y: number) { const point: [number, number] = [x, y]; - queue.push((grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point)); + grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point; + queue.push(point); return [x + x0, y + y0]; } @@ -410,7 +527,7 @@ export function* poissonDiscSampler(x0: number, y0: number, x1: number, y1: numb */ export const isLand = (i: number, packedGraph: any) => { return packedGraph.cells.h[i] >= 20; -} +}; /** * Checks if a packed cell is water based on its height @@ -419,8 +536,7 @@ export const isLand = (i: number, packedGraph: any) => { */ export const isWater = (i: number, packedGraph: any) => { return packedGraph.cells.h[i] < 20; -} - +}; // draw raster heightmap preview (not used in main generation) /** @@ -433,18 +549,31 @@ export const isWater = (i: number, packedGraph: any) => { * @param {boolean} options.renderOcean - Whether to render ocean heights * @returns {string} - A data URL representing the drawn heightmap image */ -export const drawHeights = ({heights, width, height, scheme, renderOcean}: {heights: number[], width: number, height: number, scheme: (value: number) => string, renderOcean: boolean}) => { +export const drawHeights = ({ + heights, + width, + height, + scheme, + renderOcean, +}: { + heights: number[]; + width: number; + height: number; + scheme: (value: number) => string; + renderOcean: boolean; +}) => { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d")!; const imageData = ctx.createImageData(width, height); - const getHeight = (height: number) => (height < 20 ? (renderOcean ? height : 0) : height); + const getHeight = (height: number) => + height < 20 ? (renderOcean ? height : 0) : height; for (let i = 0; i < heights.length; i++) { const colorScheme = scheme(1 - getHeight(heights[i]) / 100); - const {r, g, b} = color(colorScheme)!.rgb(); + const { r, g, b } = color(colorScheme)?.rgb() ?? { r: 0, g: 0, b: 0 }; const n = i * 4; imageData.data[n] = r; @@ -455,12 +584,11 @@ export const drawHeights = ({heights, width, height, scheme, renderOcean}: {heig ctx.putImageData(imageData, 0, 0); return canvas.toDataURL("image/png"); -} +}; declare global { var TIME: boolean; interface Window { - shouldRegenerateGrid: typeof shouldRegenerateGrid; generateGrid: typeof generateGrid; findCell: typeof findClosestCell; @@ -476,4 +604,4 @@ declare global { findAllInQuadtree: typeof findAllInQuadtree; drawHeights: typeof drawHeights; } -} \ No newline at end of file +} diff --git a/src/utils/index.ts b/src/utils/index.ts index e75d88cb..59b4b528 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,13 +1,22 @@ import "./polyfills"; -import { rn, lim, minmax, normalize, lerp } from "./numberUtils"; +import { lerp, lim, minmax, normalize, rn } from "./numberUtils"; + window.rn = rn; window.lim = lim; window.minmax = minmax; window.normalize = normalize; window.lerp = lerp as typeof window.lerp; -import { isVowel, trimVowels, getAdjective, nth, abbreviate, list } from "./languageUtils"; +import { + abbreviate, + getAdjective, + isVowel, + list, + nth, + trimVowels, +} from "./languageUtils"; + window.vowel = isVowel; window.trimVowels = trimVowels; window.getAdjective = getAdjective; @@ -15,7 +24,15 @@ window.nth = nth; window.abbreviate = abbreviate; window.list = list; -import { last, unique, deepCopy, getTypedArray, createTypedArray, TYPED_ARRAY_MAX_VALUES } from "./arrayUtils"; +import { + createTypedArray, + deepCopy, + getTypedArray, + last, + TYPED_ARRAY_MAX_VALUES, + unique, +} from "./arrayUtils"; + window.last = last; window.unique = unique; window.deepCopy = deepCopy; @@ -26,7 +43,19 @@ window.UINT8_MAX = TYPED_ARRAY_MAX_VALUES.UINT8_MAX; window.UINT16_MAX = TYPED_ARRAY_MAX_VALUES.UINT16_MAX; window.UINT32_MAX = TYPED_ARRAY_MAX_VALUES.UINT32_MAX; -import { rand, P, each, gauss, Pint, biased, generateSeed, getNumberInRange, ra, rw } from "./probabilityUtils"; +import { + biased, + each, + gauss, + generateSeed, + getNumberInRange, + P, + Pint, + ra, + rand, + rw, +} from "./probabilityUtils"; + window.rand = rand; window.P = P; window.each = each; @@ -38,12 +67,23 @@ window.biased = biased; window.getNumberInRange = getNumberInRange; window.generateSeed = generateSeed; -import { convertTemperature, si, getIntegerFromSI } from "./unitUtils"; -window.convertTemperature = (temp:number, scale: any = (window as any).temperatureScale.value || "°C") => convertTemperature(temp, scale); +import { convertTemperature, getIntegerFromSI, si } from "./unitUtils"; + +window.convertTemperature = ( + temp: number, + scale: any = (window as any).temperatureScale.value || "°C", +) => convertTemperature(temp, scale); window.si = si; window.getInteger = getIntegerFromSI; -import { toHEX, getColors, getRandomColor, getMixedColor, C_12 } from "./colorUtils"; +import { + C_12, + getColors, + getMixedColor, + getRandomColor, + toHEX, +} from "./colorUtils"; + window.toHEX = toHEX; window.getColors = getColors; window.getRandomColor = getRandomColor; @@ -51,21 +91,41 @@ window.getMixedColor = getMixedColor; window.C_12 = C_12; import { getComposedPath, getNextId } from "./nodeUtils"; + window.getComposedPath = getComposedPath; window.getNextId = getNextId; -import { rollups, distanceSquared } from "./functionUtils"; +import { distanceSquared, rollups } from "./functionUtils"; + window.rollups = rollups; window.dist2 = distanceSquared; -import { getIsolines, getPolesOfInaccessibility, connectVertices, findPath, getVertexPath } from "./pathUtils"; +import { + connectVertices, + findPath, + getIsolines, + getPolesOfInaccessibility, + getVertexPath, +} from "./pathUtils"; + window.getIsolines = getIsolines; window.getPolesOfInaccessibility = getPolesOfInaccessibility; window.connectVertices = connectVertices; -window.findPath = (start, end, getCost) => findPath(start, end, getCost, (window as any).pack); -window.getVertexPath = (cellsArray) => getVertexPath(cellsArray, (window as any).pack); +window.findPath = (start, end, getCost) => + findPath(start, end, getCost, (window as any).pack); +window.getVertexPath = (cellsArray) => + getVertexPath(cellsArray, (window as any).pack); + +import { + capitalize, + isValidJSON, + parseTransform, + round, + safeParseJSON, + sanitizeId, + splitInTwo, +} from "./stringUtils"; -import { round, capitalize, splitInTwo, parseTransform, isValidJSON, safeParseJSON, sanitizeId } from "./stringUtils"; window.round = round; window.capitalize = capitalize; window.splitInTwo = splitInTwo; @@ -76,6 +136,7 @@ JSON.isValid = isValidJSON; JSON.safeParse = safeParseJSON; import { byId } from "./shorthands"; + window.byId = byId; Node.prototype.on = function (name, fn, options) { this.addEventListener(name, fn, options); @@ -87,27 +148,63 @@ Node.prototype.off = function (name, fn) { }; declare global { - interface JSON { isValid: (str: string) => boolean; safeParse: (str: string) => any; } interface Node { - on: (name: string, fn: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => Node; + on: ( + name: string, + fn: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) => Node; off: (name: string, fn: EventListenerOrEventListenerObject) => Node; } } -import { shouldRegenerateGrid, generateGrid, findGridAll, findGridCell, findClosestCell, calculateVoronoi, findAllCellsInRadius, getPackPolygon, getGridPolygon, poissonDiscSampler, isLand, isWater, findAllInQuadtree, drawHeights } from "./graphUtils"; -window.shouldRegenerateGrid = (grid: any, expectedSeed: number) => shouldRegenerateGrid(grid, expectedSeed, (window as any).graphWidth, (window as any).graphHeight); -window.generateGrid = () => generateGrid((window as any).seed, (window as any).graphWidth, (window as any).graphHeight); -window.findGridAll = (x: number, y: number, radius: number) => findGridAll(x, y, radius, (window as any).grid); -window.findGridCell = (x: number, y: number) => findGridCell(x, y, (window as any).grid); -window.findCell = (x: number, y: number, radius?: number) => findClosestCell(x, y, radius, (window as any).pack); -window.findAll = (x: number, y: number, radius: number) => findAllCellsInRadius(x, y, radius, (window as any).pack); -window.getPackPolygon = (cellIndex: number) => getPackPolygon(cellIndex, (window as any).pack); -window.getGridPolygon = (cellIndex: number) => getGridPolygon(cellIndex, (window as any).grid); +import { + calculateVoronoi, + drawHeights, + findAllCellsInRadius, + findAllInQuadtree, + findClosestCell, + findGridAll, + findGridCell, + generateGrid, + getGridPolygon, + getPackPolygon, + isLand, + isWater, + poissonDiscSampler, + shouldRegenerateGrid, +} from "./graphUtils"; + +window.shouldRegenerateGrid = (grid: any, expectedSeed: number) => + shouldRegenerateGrid( + grid, + expectedSeed, + (window as any).graphWidth, + (window as any).graphHeight, + ); +window.generateGrid = () => + generateGrid( + (window as any).seed, + (window as any).graphWidth, + (window as any).graphHeight, + ); +window.findGridAll = (x: number, y: number, radius: number) => + findGridAll(x, y, radius, (window as any).grid); +window.findGridCell = (x: number, y: number) => + findGridCell(x, y, (window as any).grid); +window.findCell = (x: number, y: number, radius?: number) => + findClosestCell(x, y, radius, (window as any).pack); +window.findAll = (x: number, y: number, radius: number) => + findAllCellsInRadius(x, y, radius, (window as any).pack); +window.getPackPolygon = (cellIndex: number) => + getPackPolygon(cellIndex, (window as any).pack); +window.getGridPolygon = (cellIndex: number) => + getGridPolygon(cellIndex, (window as any).grid); window.calculateVoronoi = calculateVoronoi; window.poissonDiscSampler = poissonDiscSampler; window.findAllInQuadtree = findAllInQuadtree; @@ -115,8 +212,26 @@ window.drawHeights = drawHeights; window.isLand = (i: number) => isLand(i, (window as any).pack); window.isWater = (i: number) => isWater(i, (window as any).pack); -import { clipPoly, getSegmentId, debounce, throttle, parseError, getBase64, openURL, wiki, link, isCtrlClick, generateDate, getLongitude, getLatitude, getCoordinates, initializePrompt } from "./commonUtils"; -window.clipPoly = (points: [number, number][], secure?: number) => clipPoly(points, graphWidth, graphHeight, secure); +import { + clipPoly, + debounce, + generateDate, + getBase64, + getCoordinates, + getLatitude, + getLongitude, + getSegmentId, + initializePrompt, + isCtrlClick, + link, + openURL, + parseError, + throttle, + wiki, +} from "./commonUtils"; + +window.clipPoly = (points: [number, number][], secure?: number) => + clipPoly(points, graphWidth, graphHeight, secure); window.getSegmentId = getSegmentId; window.debounce = debounce; window.throttle = throttle; @@ -127,25 +242,37 @@ window.wiki = wiki; window.link = link; window.isCtrlClick = isCtrlClick; window.generateDate = generateDate; -window.getLongitude = (x: number, decimals?: number) => getLongitude(x, mapCoordinates, graphWidth, decimals); -window.getLatitude = (y: number, decimals?: number) => getLatitude(y, mapCoordinates, graphHeight, decimals); -window.getCoordinates = (x: number, y: number, decimals?: number) => getCoordinates(x, y, mapCoordinates, graphWidth, graphHeight, decimals); +window.getLongitude = (x: number, decimals?: number) => + getLongitude(x, mapCoordinates, graphWidth, decimals); +window.getLatitude = (y: number, decimals?: number) => + getLatitude(y, mapCoordinates, graphHeight, decimals); +window.getCoordinates = (x: number, y: number, decimals?: number) => + getCoordinates(x, y, mapCoordinates, graphWidth, graphHeight, decimals); // Initialize prompt when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializePrompt); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializePrompt); } else { initializePrompt(); } -import { drawCellsValue, drawPolygons, drawRouteConnections, drawPoint, drawPath } from "./debugUtils"; -window.drawCellsValue = (data:any[]) => drawCellsValue(data, (window as any).pack); -window.drawPolygons = (data: any[]) => drawPolygons(data, (window as any).terrs, (window as any).grid); -window.drawRouteConnections = () => drawRouteConnections((window as any).packedGraph); +import { + drawCellsValue, + drawPath, + drawPoint, + drawPolygons, + drawRouteConnections, +} from "./debugUtils"; + +window.drawCellsValue = (data: any[]) => + drawCellsValue(data, (window as any).pack); +window.drawPolygons = (data: any[]) => + drawPolygons(data, (window as any).terrs, (window as any).grid); +window.drawRouteConnections = () => + drawRouteConnections((window as any).packedGraph); window.drawPoint = drawPoint; window.drawPath = drawPath; - export { rn, lim, @@ -232,5 +359,5 @@ export { drawPolygons, drawRouteConnections, drawPoint, - drawPath -} \ No newline at end of file + drawPath, +}; diff --git a/src/utils/languageUtils.ts b/src/utils/languageUtils.ts index 0fbd20c8..ea7c8ebb 100644 --- a/src/utils/languageUtils.ts +++ b/src/utils/languageUtils.ts @@ -9,7 +9,7 @@ import { P } from "./probabilityUtils"; export const isVowel = (c: string): boolean => { const VOWELS = `aeiouyɑ'əøɛœæɶɒɨɪɔɐʊɤɯаоиеёэыуюяàèìòùỳẁȁȅȉȍȕáéíóúýẃőűâêîôûŷŵäëïöüÿẅãẽĩõũỹąęįǫųāēīōūȳăĕĭŏŭǎěǐǒǔȧėȯẏẇạẹịọụỵẉḛḭṵṳ`; return VOWELS.includes(c); -} +}; /** * Remove trailing vowels from a string until it reaches a minimum length. @@ -22,8 +22,7 @@ export const trimVowels = (string: string, minLength: number = 3) => { string = string.slice(0, -1); } return string; -} - +}; /** * Get adjective form of a noun based on predefined rules. @@ -35,131 +34,133 @@ export const getAdjective = (nounToBeAdjective: string) => { { name: "guo", probability: 1, - condition: new RegExp(" Guo$"), - action: (noun: string) => noun.slice(0, -4) + condition: / Guo$/, + action: (noun: string) => noun.slice(0, -4), }, { name: "orszag", probability: 1, - condition: new RegExp("orszag$"), - action: (noun: string) => (noun.length < 9 ? noun + "ian" : noun.slice(0, -6)) + condition: /orszag$/, + action: (noun: string) => + noun.length < 9 ? `${noun}ian` : noun.slice(0, -6), }, { name: "stan", probability: 1, - condition: new RegExp("stan$"), - action: (noun: string) => (noun.length < 9 ? noun + "i" : trimVowels(noun.slice(0, -4))) + condition: /stan$/, + action: (noun: string) => + noun.length < 9 ? `${noun}i` : trimVowels(noun.slice(0, -4)), }, { name: "land", probability: 1, - condition: new RegExp("land$"), + condition: /land$/, action: (noun: string) => { if (noun.length > 9) return noun.slice(0, -4); const root = trimVowels(noun.slice(0, -4), 0); - if (root.length < 3) return noun + "ic"; - if (root.length < 4) return root + "lish"; - return root + "ish"; - } + if (root.length < 3) return `${noun}ic`; + if (root.length < 4) return `${root}lish`; + return `${root}ish`; + }, }, { name: "que", probability: 1, - condition: new RegExp("que$"), - action: (noun: string) => noun.replace(/que$/, "can") + condition: /que$/, + action: (noun: string) => noun.replace(/que$/, "can"), }, { name: "a", probability: 1, - condition: new RegExp("a$"), - action: (noun: string) => noun + "n" + condition: /a$/, + action: (noun: string) => `${noun}n`, }, { name: "o", probability: 1, - condition: new RegExp("o$"), - action: (noun: string) => noun.replace(/o$/, "an") + condition: /o$/, + action: (noun: string) => noun.replace(/o$/, "an"), }, { name: "u", probability: 1, - condition: new RegExp("u$"), - action: (noun: string) => noun + "an" + condition: /u$/, + action: (noun: string) => `${noun}an`, }, { name: "i", probability: 1, - condition: new RegExp("i$"), - action: (noun: string) => noun + "an" + condition: /i$/, + action: (noun: string) => `${noun}an`, }, { name: "e", probability: 1, - condition: new RegExp("e$"), - action: (noun: string) => noun + "an" + condition: /e$/, + action: (noun: string) => `${noun}an`, }, { name: "ay", probability: 1, - condition: new RegExp("ay$"), - action: (noun: string) => noun + "an" + condition: /ay$/, + action: (noun: string) => `${noun}an`, }, { name: "os", probability: 1, - condition: new RegExp("os$"), + condition: /os$/, action: (noun: string) => { const root = trimVowels(noun.slice(0, -2), 0); if (root.length < 4) return noun.slice(0, -1); - return root + "ian"; - } + return `${root}ian`; + }, }, { name: "es", probability: 1, - condition: new RegExp("es$"), + condition: /es$/, action: (noun: string) => { const root = trimVowels(noun.slice(0, -2), 0); if (root.length > 7) return noun.slice(0, -1); - return root + "ian"; - } + return `${root}ian`; + }, }, { name: "l", probability: 0.8, - condition: new RegExp("l$"), - action: (noun: string) => noun + "ese" + condition: /l$/, + action: (noun: string) => `${noun}ese`, }, { name: "n", probability: 0.8, - condition: new RegExp("n$"), - action: (noun: string) => noun + "ese" + condition: /n$/, + action: (noun: string) => `${noun}ese`, }, { name: "ad", probability: 0.8, - condition: new RegExp("ad$"), - action: (noun: string) => noun + "ian" + condition: /ad$/, + action: (noun: string) => `${noun}ian`, }, { name: "an", probability: 0.8, - condition: new RegExp("an$"), - action: (noun: string) => noun + "ian" + condition: /an$/, + action: (noun: string) => `${noun}ian`, }, { name: "ish", probability: 0.25, - condition: new RegExp("^[a-zA-Z]{6}$"), - action: (noun: string) => trimVowels(noun.slice(0, -1)) + "ish" + condition: /^[a-zA-Z]{6}$/, + action: (noun: string) => `${trimVowels(noun.slice(0, -1))}ish`, }, { name: "an", probability: 0.5, - condition: new RegExp("^[a-zA-Z]{0,7}$"), - action: (noun: string) => trimVowels(noun) + "an" - } + condition: /^[a-zA-Z]{0,7}$/, + action: (noun: string) => `${trimVowels(noun)}an`, + }, ]; for (const rule of adjectivizationRules) { if (P(rule.probability) && rule.condition.test(nounToBeAdjective)) { @@ -167,14 +168,15 @@ export const getAdjective = (nounToBeAdjective: string) => { } } return nounToBeAdjective; // no rule applied, return noun as is -} +}; /** * Get the ordinal suffix for a given number. * @param n - The number. * @returns The number with its ordinal suffix. */ -export const nth = (n: number) => n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th"); +export const nth = (n: number) => + n + (["st", "nd", "rd"][((((n + 90) % 100) - 10) % 10) - 1] || "th"); /** * Generate an abbreviation for a given name, avoiding restricted codes. @@ -187,12 +189,13 @@ export const abbreviate = (name: string, restricted: string[] = []) => { const words = parsed.split(" "); const letters = words.join(""); - let code = words.length === 2 ? words[0][0] + words[1][0] : letters.slice(0, 2); + let code = + words.length === 2 ? words[0][0] + words[1][0] : letters.slice(0, 2); for (let i = 1; i < letters.length - 1 && restricted.includes(code); i++) { code = letters[0] + letters[i].toUpperCase(); } return code; -} +}; /** * Format a list of strings into a human-readable list. @@ -201,9 +204,12 @@ export const abbreviate = (name: string, restricted: string[] = []) => { */ export const list = (array: string[]) => { if (!Intl.ListFormat) return array.join(", "); - const conjunction = new Intl.ListFormat(document.documentElement.lang || "en", {style: "long", type: "conjunction"}); + const conjunction = new Intl.ListFormat( + document.documentElement.lang || "en", + { style: "long", type: "conjunction" }, + ); return conjunction.format(array); -} +}; declare global { interface Window { @@ -214,4 +220,4 @@ declare global { abbreviate: typeof abbreviate; list: typeof list; } -} \ No newline at end of file +} diff --git a/src/utils/nodeUtils.ts b/src/utils/nodeUtils.ts index 6213840f..f5c66705 100644 --- a/src/utils/nodeUtils.ts +++ b/src/utils/nodeUtils.ts @@ -3,14 +3,14 @@ * @param {Node | Window} node - The starting node or window * @returns {Array} - The composed path as an array */ -export const getComposedPath = function(node: any): Array { - let parent; +export const getComposedPath = (node: any): Array => { + let parent: Node | Window | undefined; if (node.parentNode) parent = node.parentNode; else if (node.host) parent = node.host; else if (node.defaultView) parent = node.defaultView; if (parent !== undefined) return [node].concat(getComposedPath(parent)); return [node]; -} +}; /** * Generate a unique ID for a given core string @@ -18,14 +18,14 @@ export const getComposedPath = function(node: any): Array { * @param {number} [i=1] - The starting index * @returns {string} - The unique ID */ -export const getNextId = function(core: string, i: number = 1): string { +export const getNextId = (core: string, i: number = 1): string => { while (document.getElementById(core + i)) i++; return core + i; -} +}; declare global { interface Window { getComposedPath: typeof getComposedPath; getNextId: typeof getNextId; } -} \ No newline at end of file +} diff --git a/src/utils/numberUtils.ts b/src/utils/numberUtils.ts index a2ab6220..d7516624 100644 --- a/src/utils/numberUtils.ts +++ b/src/utils/numberUtils.ts @@ -5,9 +5,9 @@ * @returns The rounded number. */ export const rn = (v: number, d: number = 0) => { - const m = Math.pow(10, d); + const m = 10 ** d; return Math.round(v * m) / m; -} +}; /** * Clamps a number between a minimum and maximum value. @@ -18,7 +18,7 @@ export const rn = (v: number, d: number = 0) => { */ export const minmax = (value: number, min: number, max: number) => { return Math.min(Math.max(value, min), max); -} +}; /** * Clamps a number between 0 and 100. @@ -27,7 +27,7 @@ export const minmax = (value: number, min: number, max: number) => { */ export const lim = (v: number) => { return minmax(v, 0, 100); -} +}; /** * Normalizes a number within a specified range to a value between 0 and 1. @@ -38,7 +38,7 @@ export const lim = (v: number) => { */ export const normalize = (val: number, min: number, max: number) => { return minmax((val - min) / (max - min), 0, 1); -} +}; /** * Performs linear interpolation between two values. @@ -49,7 +49,7 @@ export const normalize = (val: number, min: number, max: number) => { */ export const lerp = (a: number, b: number, t: number) => { return a + (b - a) * t; -} +}; declare global { interface Window { @@ -59,4 +59,4 @@ declare global { normalize: typeof normalize; lerp: typeof lerp; } -} \ No newline at end of file +} diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts index b37f17fb..36baec86 100644 --- a/src/utils/pathUtils.ts +++ b/src/utils/pathUtils.ts @@ -8,10 +8,10 @@ import { rn } from "./numberUtils"; * @returns {string} SVG path data for the filled shape. */ const getFillPath = (vertices: any, vertexChain: number[]) => { - const points = vertexChain.map(vertexId => vertices.p[vertexId]); + const points = vertexChain.map((vertexId) => vertices.p[vertexId]); const firstPoint = points.shift(); return `M${firstPoint} L${points.join(" ")} Z`; -} +}; /** * Generates SVG path data for borders based on a chain of vertices and a discontinuation condition. @@ -20,10 +20,14 @@ const getFillPath = (vertices: any, vertexChain: number[]) => { * @param {(vertexId: number) => boolean} discontinue - A function that determines if the path should discontinue at a vertex. * @returns {string} SVG path data for the border. */ -const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (vertexId: number) => boolean) => { +const getBorderPath = ( + vertices: any, + vertexChain: number[], + discontinue: (vertexId: number) => boolean, +) => { let discontinued = true; let lastOperation = ""; - const path = vertexChain.map(vertexId => { + const path = vertexChain.map((vertexId) => { if (discontinue(vertexId)) { discontinued = true; return ""; @@ -33,12 +37,13 @@ const getBorderPath = (vertices: any, vertexChain: number[], discontinue: (verte discontinued = false; lastOperation = operation; - const command = operation === "L" && operation === lastOperation ? "" : operation; + const command = + operation === "L" && operation === lastOperation ? "" : operation; return ` ${command}${vertices.p[vertexId]}`; }); return path.join("").trim(); -} +}; /** * Restores the path from exit to start using the 'from' mapping. @@ -62,7 +67,7 @@ const restorePath = (exit: number, start: number, from: number[]) => { pathCells.push(current); return pathCells.reverse(); -} +}; /** * Returns isolines (borders) for different types of cells in the graph. @@ -75,12 +80,23 @@ const restorePath = (exit: number, start: number, from: number[]) => { * @param {boolean} [options.waterGap=false] - Whether to generate water gap paths for each type. * @returns {object} An object containing isolines for each type based on the specified options. */ -export const getIsolines = (graph: any, getType: (cellId: number) => any, options: {polygons?: boolean, fill?: boolean, halo?: boolean, waterGap?: boolean} = {polygons: false, fill: false, halo: false, waterGap: false}): any => { - const {cells, vertices} = graph; +export const getIsolines = ( + graph: any, + getType: (cellId: number) => any, + options: { + polygons?: boolean; + fill?: boolean; + halo?: boolean; + waterGap?: boolean; + } = { polygons: false, fill: false, halo: false, waterGap: false }, +): any => { + const { cells, vertices } = graph; const isolines: any = {}; const checkedCells = new Uint8Array(cells.i.length); - const addToChecked = (cellId: number) => (checkedCells[cellId] = 1); + const addToChecked = (cellId: number) => { + checkedCells[cellId] = 1; + }; const isChecked = (cellId: number) => checkedCells[cellId] === 1; for (const cellId of cells.i) { @@ -96,12 +112,22 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option // check if inner lake. Note there is no shoreline for grid features const feature = graph.features[cells.f[onborderCell]]; - if (feature.type === "lake" && feature.shoreline?.every(ofSameType)) continue; + if (feature.type === "lake" && feature.shoreline?.every(ofSameType)) + continue; - const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType)); - if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); + const startingVertex = cells.v[cellId].find((v: number) => + vertices.c[v].some(ofDifferentType), + ); + if (startingVertex === undefined) + throw new Error(`Starting vertex for cell ${cellId} is not found`); - const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true}); + const vertexChain = connectVertices({ + vertices, + startingVertex, + ofSameType, + addToChecked, + closeRing: true, + }); if (vertexChain.length < 3) continue; addIsolineTo(type, vertices, vertexChain, isolines, options); @@ -109,12 +135,20 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option return isolines; - function addIsolineTo(type: any, vertices: any, vertexChain: number[], isolines: any, options: any) { + function addIsolineTo( + type: any, + vertices: any, + vertexChain: number[], + isolines: any, + options: any, + ) { if (!isolines[type]) isolines[type] = {}; if (options.polygons) { if (!isolines[type].polygons) isolines[type].polygons = []; - isolines[type].polygons.push(vertexChain.map(vertexId => vertices.p[vertexId])); + isolines[type].polygons.push( + vertexChain.map((vertexId) => vertices.p[vertexId]), + ); } if (options.fill) { @@ -124,18 +158,27 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option if (options.waterGap) { if (!isolines[type].waterGap) isolines[type].waterGap = ""; - const isLandVertex = (vertexId: number) => vertices.c[vertexId].every((i: number) => cells.h[i] >= 20); - isolines[type].waterGap += getBorderPath(vertices, vertexChain, isLandVertex); + const isLandVertex = (vertexId: number) => + vertices.c[vertexId].every((i: number) => cells.h[i] >= 20); + isolines[type].waterGap += getBorderPath( + vertices, + vertexChain, + isLandVertex, + ); } if (options.halo) { if (!isolines[type].halo) isolines[type].halo = ""; - const isBorderVertex = (vertexId: number) => vertices.c[vertexId].some((i: number) => cells.b[i]); - isolines[type].halo += getBorderPath(vertices, vertexChain, isBorderVertex); + const isBorderVertex = (vertexId: number) => + vertices.c[vertexId].some((i: number) => cells.b[i]); + isolines[type].halo += getBorderPath( + vertices, + vertexChain, + isBorderVertex, + ); } } -} - +}; /** * Generates SVG path data for the border of a shape defined by a chain of vertices. @@ -144,14 +187,18 @@ export const getIsolines = (graph: any, getType: (cellId: number) => any, option * @returns {string} SVG path data for the border of the shape. */ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => { - const {cells, vertices} = packedGraph; + const { cells, vertices } = packedGraph; - const cellsObj = Object.fromEntries(cellsArray.map(cellId => [cellId, true])); + const cellsObj = Object.fromEntries( + cellsArray.map((cellId) => [cellId, true]), + ); const ofSameType = (cellId: number) => cellsObj[cellId]; const ofDifferentType = (cellId: number) => !cellsObj[cellId]; const checkedCells = new Uint8Array(cells.c.length); - const addToChecked = (cellId: number) => (checkedCells[cellId] = 1); + const addToChecked = (cellId: number) => { + checkedCells[cellId] = 1; + }; const isChecked = (cellId: number) => checkedCells[cellId] === 1; let path = ""; @@ -166,17 +213,26 @@ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => { if (feature.shoreline.every(ofSameType)) continue; // inner lake } - const startingVertex = cells.v[cellId].find((v: number) => vertices.c[v].some(ofDifferentType)); - if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); + const startingVertex = cells.v[cellId].find((v: number) => + vertices.c[v].some(ofDifferentType), + ); + if (startingVertex === undefined) + throw new Error(`Starting vertex for cell ${cellId} is not found`); - const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true}); + const vertexChain = connectVertices({ + vertices, + startingVertex, + ofSameType, + addToChecked, + closeRing: true, + }); if (vertexChain.length < 3) continue; path += getFillPath(vertices, vertexChain); } return path; -} +}; /** * Finds the poles of inaccessibility for each type of cell in the graph. @@ -184,17 +240,22 @@ export const getVertexPath = (cellsArray: number[], packedGraph: any = {}) => { * @param {(cellId: number) => any} getType - A function that returns the type of a cell given its ID. * @returns {object} An object mapping each type to its pole of inaccessibility coordinates [x, y]. */ -export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number) => any) => { - const isolines = getIsolines(graph, getType, {polygons: true}); +export const getPolesOfInaccessibility = ( + graph: any, + getType: (cellId: number) => any, +) => { + const isolines = getIsolines(graph, getType, { polygons: true }); const poles = Object.entries(isolines).map(([id, isoline]) => { - const multiPolygon = (isoline as any).polygons.sort((a: any, b: any) => b.length - a.length); + const multiPolygon = (isoline as any).polygons.sort( + (a: any, b: any) => b.length - a.length, + ); const [x, y] = polylabel(multiPolygon, 20); return [id, [rn(x), rn(y)]]; }); return Object.fromEntries(poles); -} +}; /** * Connects vertices to form a closed path based on cell type. @@ -206,7 +267,19 @@ export const getPolesOfInaccessibility = (graph: any, getType: (cellId: number) * @param {boolean} [options.closeRing=false] - Whether to close the path into a ring. * @returns {number[]} An array of vertex IDs forming the connected path. */ -export const connectVertices = ({vertices, startingVertex, ofSameType, addToChecked, closeRing}: {vertices: any, startingVertex: number, ofSameType: (cellId: number) => boolean, addToChecked?: (cellId: number) => void, closeRing?: boolean}) => { +export const connectVertices = ({ + vertices, + startingVertex, + ofSameType, + addToChecked, + closeRing, +}: { + vertices: any; + startingVertex: number; + ofSameType: (cellId: number) => boolean; + addToChecked?: (cellId: number) => void; + closeRing?: boolean; +}) => { const MAX_ITERATIONS = vertices.c.length; const chain = []; // vertices chain to form a path @@ -227,24 +300,30 @@ export const connectVertices = ({vertices, startingVertex, ofSameType, addToChec else if (v3 !== previous && c1 !== c3) next = v3; if (next >= vertices.c.length) { - window.ERROR && console.error("ConnectVertices: next vertex is out of bounds"); + window.ERROR && + console.error("ConnectVertices: next vertex is out of bounds"); break; } if (next === current) { - window.ERROR && console.error("ConnectVertices: next vertex is not found"); + window.ERROR && + console.error("ConnectVertices: next vertex is not found"); break; } if (i === MAX_ITERATIONS) { - window.ERROR && console.error("ConnectVertices: max iterations reached", MAX_ITERATIONS); + window.ERROR && + console.error( + "ConnectVertices: max iterations reached", + MAX_ITERATIONS, + ); break; } } if (closeRing) chain.push(startingVertex); return chain; -} +}; /** * Finds the shortest path between two cells using a cost-based pathfinding algorithm. @@ -254,7 +333,12 @@ export const connectVertices = ({vertices, startingVertex, ofSameType, addToChec * @param {object} packedGraph - The packed graph object containing cells and their connections. * @returns {number[] | null} An array of cell IDs of the path from start to exit, or null if no path is found or start and exit are the same. */ -export const findPath = (start: number, isExit: (id: number) => boolean, getCost: (current: number, next: number) => number, packedGraph: any = {}): number[] | null => { +export const findPath = ( + start: number, + isExit: (id: number) => boolean, + getCost: (current: number, next: number) => number, + packedGraph: any = {}, +): number[] | null => { if (isExit(start)) return null; const from = []; @@ -284,7 +368,7 @@ export const findPath = (start: number, isExit: (id: number) => boolean, getCost } return null; -} +}; declare global { interface Window { @@ -297,4 +381,4 @@ declare global { findPath: typeof findPath; getVertexPath: typeof getVertexPath; } -} \ No newline at end of file +} diff --git a/src/utils/polyfills.ts b/src/utils/polyfills.ts index 18f5f1bd..594e7a2f 100644 --- a/src/utils/polyfills.ts +++ b/src/utils/polyfills.ts @@ -1,7 +1,11 @@ // replaceAll if (String.prototype.replaceAll === undefined) { - String.prototype.replaceAll = function (str: string | RegExp, newStr: string | ((substring: string, ...args: any[]) => string)): string { - if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") return this.replace(str as RegExp, newStr as any); + String.prototype.replaceAll = function ( + str: string | RegExp, + newStr: string | ((substring: string, ...args: any[]) => string), + ): string { + if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") + return this.replace(str as RegExp, newStr as any); return this.replace(new RegExp(str, "g"), newStr as any); }; } @@ -9,7 +13,13 @@ if (String.prototype.replaceAll === undefined) { // flat if (Array.prototype.flat === undefined) { Array.prototype.flat = function (this: T[], depth?: number): any[] { - return (this as Array).reduce((acc: any[], val: unknown) => (Array.isArray(val) ? acc.concat((val as any).flat(depth)) : acc.concat(val)), []); + return (this as Array).reduce( + (acc: any[], val: unknown) => + Array.isArray(val) + ? acc.concat((val as any).flat(depth)) + : acc.concat(val), + [], + ); }; } @@ -24,11 +34,13 @@ if (Array.prototype.at === undefined) { // readable stream iterator: https://bugs.chromium.org/p/chromium/issues/detail?id=929585#c10 if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) { - (ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* (this: ReadableStream): AsyncGenerator { + (ReadableStream.prototype as any)[Symbol.asyncIterator] = async function* ( + this: ReadableStream, + ): AsyncGenerator { const reader = this.getReader(); try { while (true) { - const {done, value} = await reader.read(); + const { done, value } = await reader.read(); if (done) return; yield value; } @@ -40,7 +52,10 @@ if ((ReadableStream.prototype as any)[Symbol.asyncIterator] === undefined) { declare global { interface String { - replaceAll(searchValue: string | RegExp, replaceValue: string | ((substring: string, ...args: any[]) => string)): string; + replaceAll( + searchValue: string | RegExp, + replaceValue: string | ((substring: string, ...args: any[]) => string), + ): string; } interface Array { diff --git a/src/utils/probabilityUtils.ts b/src/utils/probabilityUtils.ts index a526c981..ba9806b9 100644 --- a/src/utils/probabilityUtils.ts +++ b/src/utils/probabilityUtils.ts @@ -1,5 +1,5 @@ -import { minmax, rn } from "./numberUtils"; import { randomNormal } from "d3"; +import { minmax, rn } from "./numberUtils"; /** * Creates a random number between min and max (inclusive). @@ -14,7 +14,7 @@ export const rand = (min: number, max?: number): number => { min = 0; } return Math.floor(Math.random() * (max - min + 1)) + min; -} +}; /** * Returns a boolean based on the given probability. @@ -25,7 +25,7 @@ export const P = (probability: number): boolean => { if (probability >= 1) return true; if (probability <= 0) return false; return Math.random() < probability; -} +}; /** * Returns true every n times. @@ -34,7 +34,7 @@ export const P = (probability: number): boolean => { */ export const each = (n: number) => { return (i: number) => i % n === 0; -} +}; /** * Random Gaussian number generator @@ -46,10 +46,23 @@ export const each = (n: number) => { * @param {number} round - round value to n decimals * @return {number} random number */ -export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => { +export const gauss = ( + expected = 100, + deviation = 30, + min = 0, + max = 300, + round = 0, +) => { // Use .source() to get a version that uses the current Math.random (which may be seeded) - return rn(minmax(randomNormal.source(() => Math.random())(expected, deviation)(), min, max), round); -} + return rn( + minmax( + randomNormal.source(() => Math.random())(expected, deviation)(), + min, + max, + ), + round, + ); +}; /** * Returns the integer part of a float plus one with the probability of the decimal part. @@ -58,7 +71,7 @@ export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round */ export const Pint = (float: number): number => { return ~~float + +P(float % 1); -} +}; /** * Returns a random element from an array. @@ -67,18 +80,18 @@ export const Pint = (float: number): number => { */ export const ra = (array: any[]): any => { return array[Math.floor(Math.random() * array.length)]; -} +}; /** * Returns a random key from an object where values are weights. * @param {Object} object - object with keys and their weights * @return {string} a random key based on weights - * + * * @example * const obj = { a: 1, b: 3, c: 6 }; * const randomKey = rw(obj); // 'a' has 10% chance, 'b' has 30% chance, 'c' has 60% chance */ -export const rw = (object: {[key: string]: number}): string => { +export const rw = (object: { [key: string]: number }): string => { const array = []; for (const key in object) { for (let i = 0; i < object[key]; i++) { @@ -86,7 +99,7 @@ export const rw = (object: {[key: string]: number}): string => { } } return array[Math.floor(Math.random() * array.length)]; -} +}; /** * Returns a random integer from min to max biased towards one end based on exponent distribution (the bigger ex the higher bias towards min). @@ -96,8 +109,8 @@ export const rw = (object: {[key: string]: number}): string => { * @return {number} biased random integer */ export const biased = (min: number, max: number, ex: number): number => { - return Math.round(min + (max - min) * Math.pow(Math.random(), ex)); -} + return Math.round(min + (max - min) * Math.random() ** ex); +}; const ERROR = false; /** @@ -110,28 +123,28 @@ export const getNumberInRange = (r: string): number => { ERROR && console.error("Range value should be a string", r); return 0; } - if (!isNaN(+r)) return ~~r + +P(+r - ~~r); + if (!Number.isNaN(+r)) return ~~r + +P(+r - ~~r); const sign = r[0] === "-" ? -1 : 1; - if (isNaN(+r[0])) r = r.slice(1); + if (Number.isNaN(+r[0])) r = r.slice(1); const range = r.includes("-") ? r.split("-") : null; if (!range) { ERROR && console.error("Cannot parse the number. Check the format", r); return 0; } const count = rand(parseFloat(range[0]) * sign, +parseFloat(range[1])); - if (isNaN(count) || count < 0) { + if (Number.isNaN(count) || count < 0) { ERROR && console.error("Cannot parse number. Check the format", r); return 0; } return count; -} +}; /** * Generate a random seed string * @return {string} random seed */ export const generateSeed = (): string => { return String(Math.floor(Math.random() * 1e9)); -} +}; declare global { interface Window { @@ -146,4 +159,4 @@ declare global { getNumberInRange: typeof getNumberInRange; generateSeed: typeof generateSeed; } -} \ No newline at end of file +} diff --git a/src/utils/stringUtils.test.ts b/src/utils/stringUtils.test.ts index 10da484f..86af5c39 100644 --- a/src/utils/stringUtils.test.ts +++ b/src/utils/stringUtils.test.ts @@ -1,8 +1,8 @@ -import { expect, describe, it } from 'vitest' -import { round } from './stringUtils' +import { describe, expect, it } from "vitest"; +import { round } from "./stringUtils"; -describe('round', () => { - it('should be able to handle undefined input', () => { +describe("round", () => { + it("should be able to handle undefined input", () => { expect(round(undefined)).toBe(""); }); -}) \ No newline at end of file +}); diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts index dc00a23a..01d3f38d 100644 --- a/src/utils/stringUtils.ts +++ b/src/utils/stringUtils.ts @@ -7,10 +7,10 @@ import { rn } from "./numberUtils"; * @returns {string} - The string with rounded numbers */ export const round = (inputString: string = "", decimals: number = 1) => { - return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => { + return inputString.replace(/[\d.-][\d.e-]*/g, (n: string) => { return rn(parseFloat(n), decimals).toString(); }); -} +}; /** * Capitalize the first letter of a string @@ -19,7 +19,7 @@ export const round = (inputString: string = "", decimals: number = 1) => { */ export const capitalize = (inputString: string) => { return inputString.charAt(0).toUpperCase() + inputString.slice(1); -} +}; /** * Split a string into two parts, trying to balance their lengths @@ -46,13 +46,13 @@ export const splitInTwo = (inputString: string): string[] => { if (!last) return [first, middle]; if (first.length < last.length) return [first + middle, last]; return [first, middle + last]; -} +}; /** * Parse an SVG transform string into an array of numbers * @param {string} string - The SVG transform string * @returns {[number, number, number, number, number, number]} - The parsed transform as an array - * + * * @example * parseTransform("matrix(1, 0, 0, 1, 100, 200)") // returns [1, 0, 0, 1, 100, 200] * parseTransform("translate(50, 75)") // returns [50, 75, 0, 0, 0, 1] @@ -65,7 +65,7 @@ export const parseTransform = (string: string) => { .replace(/[ ]/g, ",") .split(","); return [a[0] || 0, a[1] || 0, a[2] || 0, a[3] || 0, a[4] || 0, a[5] || 1]; -} +}; /** * Check if a string is valid JSON @@ -76,7 +76,7 @@ export const isValidJSON = (str: string): boolean => { try { JSON.parse(str); return true; - } catch (e) { + } catch (_e) { return false; } }; @@ -89,7 +89,7 @@ export const isValidJSON = (str: string): boolean => { export const safeParseJSON = (str: string) => { try { return JSON.parse(str); - } catch (e) { + } catch (_e) { return null; } }; @@ -109,10 +109,10 @@ export const sanitizeId = (inputString: string) => { .replace(/\s+/g, "-"); // replace spaces with hyphens // remove leading numbers - if (sanitized.match(/^\d/)) sanitized = "_" + sanitized; + if (sanitized.match(/^\d/)) sanitized = `_${sanitized}`; return sanitized; -} +}; declare global { interface Window { @@ -122,4 +122,4 @@ declare global { parseTransform: typeof parseTransform; sanitizeId: typeof sanitizeId; } -} \ No newline at end of file +} diff --git a/src/utils/unitUtils.ts b/src/utils/unitUtils.ts index 072c0b38..142e139c 100644 --- a/src/utils/unitUtils.ts +++ b/src/utils/unitUtils.ts @@ -7,19 +7,23 @@ type TemperatureScale = "°C" | "°F" | "K" | "°R" | "°De" | "°N" | "°Ré" | * @param {string} targetScale - Target temperature scale * @returns {string} - Converted temperature with unit */ -export const convertTemperature = (temperatureInCelsius: number, targetScale: TemperatureScale = "°C") => { - const temperatureConversionMap: {[key: string]: (temp: number) => string} = { - "°C": (temp: number) => rn(temp) + "°C", - "°F": (temp: number) => rn((temp * 9) / 5 + 32) + "°F", - K: (temp: number) => rn(temp + 273.15) + "K", - "°R": (temp: number) => rn(((temp + 273.15) * 9) / 5) + "°R", - "°De": (temp: number) => rn(((100 - temp) * 3) / 2) + "°De", - "°N": (temp: number) => rn((temp * 33) / 100) + "°N", - "°Ré": (temp: number) => rn((temp * 4) / 5) + "°Ré", - "°Rø": (temp: number) => rn((temp * 21) / 40 + 7.5) + "°Rø" - }; +export const convertTemperature = ( + temperatureInCelsius: number, + targetScale: TemperatureScale = "°C", +) => { + const temperatureConversionMap: { [key: string]: (temp: number) => string } = + { + "°C": (temp: number) => `${rn(temp)}°C`, + "°F": (temp: number) => `${rn((temp * 9) / 5 + 32)}°F`, + K: (temp: number) => `${rn(temp + 273.15)}K`, + "°R": (temp: number) => `${rn(((temp + 273.15) * 9) / 5)}°R`, + "°De": (temp: number) => `${rn(((100 - temp) * 3) / 2)}°De`, + "°N": (temp: number) => `${rn((temp * 33) / 100)}°N`, + "°Ré": (temp: number) => `${rn((temp * 4) / 5)}°Ré`, + "°Rø": (temp: number) => `${rn((temp * 21) / 40 + 7.5)}°Rø`, + }; return temperatureConversionMap[targetScale](temperatureInCelsius); -} +}; /** * Convert number to short string with SI postfix @@ -27,13 +31,13 @@ export const convertTemperature = (temperatureInCelsius: number, targetScale: Te * @returns {string} - The converted string */ export const si = (n: number): string => { - if (n >= 1e9) return rn(n / 1e9, 1) + "B"; - if (n >= 1e8) return rn(n / 1e6) + "M"; - if (n >= 1e6) return rn(n / 1e6, 1) + "M"; - if (n >= 1e4) return rn(n / 1e3) + "K"; - if (n >= 1e3) return rn(n / 1e3, 1) + "K"; + if (n >= 1e9) return `${rn(n / 1e9, 1)}B`; + if (n >= 1e8) return `${rn(n / 1e6)}M`; + if (n >= 1e6) return `${rn(n / 1e6, 1)}M`; + if (n >= 1e4) return `${rn(n / 1e3)}K`; + if (n >= 1e3) return `${rn(n / 1e3, 1)}K`; return rn(n).toString(); -} +}; /** * Convert string with SI postfix to integer @@ -42,11 +46,11 @@ export const si = (n: number): string => { */ export const getIntegerFromSI = (value: string): number => { const metric = value.slice(-1); - if (metric === "K") return parseInt(value.slice(0, -1)) * 1e3; - if (metric === "M") return parseInt(value.slice(0, -1)) * 1e6; - if (metric === "B") return parseInt(value.slice(0, -1)) * 1e9; - return parseInt(value); -} + if (metric === "K") return parseInt(value.slice(0, -1), 10) * 1e3; + if (metric === "M") return parseInt(value.slice(0, -1), 10) * 1e6; + if (metric === "B") return parseInt(value.slice(0, -1), 10) * 1e9; + return parseInt(value, 10); +}; declare global { interface Window { From 260ccd76a3d8354ddeec8b1175c66d7118d49586 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Tue, 27 Jan 2026 19:29:37 +0100 Subject: [PATCH 12/24] refactor: migrate names-generator (#1285) * refactor: migrate names-generator * Update src/types/global.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/index.ts * fix: failing builds after merge * chore: update biome version to 2.3.13 and adjust name validation regex for ASCII characters --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- biome.json | 2 +- package-lock.json | 73 +-- package.json | 2 +- public/modules/names-generator.js | 328 -------------- src/index.html | 1 - src/modules/index.ts | 3 +- src/modules/names-generator.ts | 721 ++++++++++++++++++++++++++++++ src/types/PackedGraph.ts | 1 + src/types/global.ts | 13 +- 9 files changed, 775 insertions(+), 369 deletions(-) delete mode 100644 public/modules/names-generator.js create mode 100644 src/modules/names-generator.ts diff --git a/biome.json b/biome.json index 84644252..fc1bbe74 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/package-lock.json b/package-lock.json index 428c81c7..53616e24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "polylabel": "^2.0.1" }, "devDependencies": { - "@biomejs/biome": "2.3.12", + "@biomejs/biome": "2.3.13", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", @@ -33,9 +33,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.12.tgz", - "integrity": "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", + "integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -49,20 +49,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.12", - "@biomejs/cli-darwin-x64": "2.3.12", - "@biomejs/cli-linux-arm64": "2.3.12", - "@biomejs/cli-linux-arm64-musl": "2.3.12", - "@biomejs/cli-linux-x64": "2.3.12", - "@biomejs/cli-linux-x64-musl": "2.3.12", - "@biomejs/cli-win32-arm64": "2.3.12", - "@biomejs/cli-win32-x64": "2.3.12" + "@biomejs/cli-darwin-arm64": "2.3.13", + "@biomejs/cli-darwin-x64": "2.3.13", + "@biomejs/cli-linux-arm64": "2.3.13", + "@biomejs/cli-linux-arm64-musl": "2.3.13", + "@biomejs/cli-linux-x64": "2.3.13", + "@biomejs/cli-linux-x64-musl": "2.3.13", + "@biomejs/cli-win32-arm64": "2.3.13", + "@biomejs/cli-win32-x64": "2.3.13" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.12.tgz", - "integrity": "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz", + "integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==", "cpu": [ "arm64" ], @@ -77,9 +77,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.12.tgz", - "integrity": "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz", + "integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==", "cpu": [ "x64" ], @@ -94,9 +94,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.12.tgz", - "integrity": "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz", + "integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==", "cpu": [ "arm64" ], @@ -111,9 +111,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.12.tgz", - "integrity": "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz", + "integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==", "cpu": [ "arm64" ], @@ -128,9 +128,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.12.tgz", - "integrity": "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz", + "integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==", "cpu": [ "x64" ], @@ -145,9 +145,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.12.tgz", - "integrity": "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz", + "integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==", "cpu": [ "x64" ], @@ -162,9 +162,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.12.tgz", - "integrity": "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz", + "integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==", "cpu": [ "arm64" ], @@ -179,9 +179,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.12.tgz", - "integrity": "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz", + "integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==", "cpu": [ "x64" ], @@ -2163,6 +2163,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 4656ff9a..37c2ba5a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "format": "biome format --write" }, "devDependencies": { - "@biomejs/biome": "2.3.12", + "@biomejs/biome": "2.3.13", "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", diff --git a/public/modules/names-generator.js b/public/modules/names-generator.js deleted file mode 100644 index c35afedc..00000000 --- a/public/modules/names-generator.js +++ /dev/null @@ -1,328 +0,0 @@ -"use strict"; - -window.Names = (function () { - let chains = []; - - // calculate Markov chain for a namesbase - const calculateChain = function (string) { - const chain = []; - const array = string.split(","); - - for (const n of array) { - let name = n.trim().toLowerCase(); - const basic = !/[^\u0000-\u007f]/.test(name); // basic chars and English rules can be applied - - // split word into pseudo-syllables - for (let i = -1, syllable = ""; i < name.length; i += syllable.length || 1, syllable = "") { - let prev = name[i] || ""; // pre-onset letter - let v = 0; // 0 if no vowels in syllable - - for (let c = i + 1; name[c] && syllable.length < 5; c++) { - const that = name[c], - next = name[c + 1]; // next char - syllable += that; - if (syllable === " " || syllable === "-") break; // syllable starts with space or hyphen - if (!next || next === " " || next === "-") break; // no need to check - - if (vowel(that)) v = 1; // check if letter is vowel - - // do not split some diphthongs - if (that === "y" && next === "e") continue; // 'ye' - if (basic) { - // English-like - if (that === "o" && next === "o") continue; // 'oo' - if (that === "e" && next === "e") continue; // 'ee' - if (that === "a" && next === "e") continue; // 'ae' - if (that === "c" && next === "h") continue; // 'ch' - } - - if (vowel(that) === next) break; // two same vowels in a row - if (v && vowel(name[c + 2])) break; // syllable has vowel and additional vowel is expected soon - } - - if (chain[prev] === undefined) chain[prev] = []; - chain[prev].push(syllable); - } - } - - return chain; - }; - - const updateChain = i => { - chains[i] = nameBases[i]?.b ? calculateChain(nameBases[i].b) : null; - }; - - const clearChains = () => { - chains = []; - }; - - // generate name using Markov's chain - const getBase = function (base, min, max, dupl) { - if (base === undefined) return ERROR && console.error("Please define a base"); - - if (nameBases[base] === undefined) { - if (nameBases[0]) { - WARN && console.warn("Namebase " + base + " is not found. First available namebase will be used"); - base = 0; - } else { - ERROR && console.error("Namebase " + base + " is not found"); - return "ERROR"; - } - } - - if (!chains[base]) updateChain(base); - - const data = chains[base]; - if (!data || data[""] === undefined) { - tip("Namesbase " + base + " is incorrect. Please check in namesbase editor", false, "error"); - ERROR && console.error("Namebase " + base + " is incorrect!"); - return "ERROR"; - } - - if (!min) min = nameBases[base].min; - if (!max) max = nameBases[base].max; - if (dupl !== "") dupl = nameBases[base].d; - - let v = data[""], - cur = ra(v), - w = ""; - for (let i = 0; i < 20; i++) { - if (cur === "") { - // end of word - if (w.length < min) { - cur = ""; - w = ""; - v = data[""]; - } else break; - } else { - if (w.length + cur.length > max) { - // word too long - if (w.length < min) w += cur; - break; - } else v = data[last(cur)] || data[""]; - } - - w += cur; - cur = ra(v); - } - - // parse word to get a final name - const l = last(w); // last letter - if (l === "'" || l === " " || l === "-") w = w.slice(0, -1); // not allow some characters at the end - - let name = [...w].reduce(function (r, c, i, d) { - if (c === d[i + 1] && !dupl.includes(c)) return r; // duplication is not allowed - if (!r.length) return c.toUpperCase(); - if (r.slice(-1) === "-" && c === " ") return r; // remove space after hyphen - if (r.slice(-1) === " ") return r + c.toUpperCase(); // capitalize letter after space - if (r.slice(-1) === "-") return r + c.toUpperCase(); // capitalize letter after hyphen - if (c === "a" && d[i + 1] === "e") return r; // "ae" => "e" - if (i + 2 < d.length && c === d[i + 1] && c === d[i + 2]) return r; // remove three same letters in a row - return r + c; - }, ""); - - // join the word if any part has only 1 letter - if (name.split(" ").some(part => part.length < 2)) - name = name - .split(" ") - .map((p, i) => (i ? p.toLowerCase() : p)) - .join(""); - - if (name.length < 2) { - ERROR && console.error("Name is too short! Random name will be selected"); - name = ra(nameBases[base].b.split(",")); - } - - return name; - }; - - // generate name for culture - const getCulture = function (culture, min, max, dupl) { - if (culture === undefined) return ERROR && console.error("Please define a culture"); - const base = pack.cultures[culture].base; - return getBase(base, min, max, dupl); - }; - - // generate short name for culture - const getCultureShort = function (culture) { - if (culture === undefined) return ERROR && console.error("Please define a culture"); - return getBaseShort(pack.cultures[culture].base); - }; - - // generate short name for base - const getBaseShort = function (base) { - const min = nameBases[base] ? nameBases[base].min - 1 : null; - const max = min ? Math.max(nameBases[base].max - 2, min) : null; - return getBase(base, min, max, "", 0); - }; - - // generate state name based on capital or random name and culture-specific suffix - const getState = function (name, culture, base) { - if (name === undefined) return ERROR && console.error("Please define a base name"); - if (culture === undefined && base === undefined) return ERROR && console.error("Please define a culture"); - if (base === undefined) base = pack.cultures[culture].base; - - // exclude endings inappropriate for states name - if (name.includes(" ")) name = capitalize(name.replace(/ /g, "").toLowerCase()); // don't allow multiword state names - if (name.length > 6 && name.slice(-4) === "berg") name = name.slice(0, -4); // remove -berg for any - if (name.length > 5 && name.slice(-3) === "ton") name = name.slice(0, -3); // remove -ton for any - - if (base === 5 && ["sk", "ev", "ov"].includes(name.slice(-2))) name = name.slice(0, -2); - // remove -sk/-ev/-ov for Ruthenian - else if (base === 12) return vowel(name.slice(-1)) ? name : name + "u"; - // Japanese ends on any vowel or -u - else if (base === 18 && P(0.4)) - name = vowel(name.slice(0, 1).toLowerCase()) ? "Al" + name.toLowerCase() : "Al " + name; // Arabic starts with -Al - - // no suffix for fantasy bases - if (base > 32 && base < 42) return name; - - // define if suffix should be used - if (name.length > 3 && vowel(name.slice(-1))) { - if (vowel(name.slice(-2, -1)) && P(0.85)) name = name.slice(0, -2); - // 85% for vv - else if (P(0.7)) name = name.slice(0, -1); - // ~60% for cv - else return name; - } else if (P(0.4)) return name; // 60% for cc and vc - - // define suffix - let suffix = "ia"; // standard suffix - - const rnd = Math.random(), - l = name.length; - if (base === 3 && rnd < 0.03 && l < 7) suffix = "terra"; - // Italian - else if (base === 4 && rnd < 0.03 && l < 7) suffix = "terra"; - // Spanish - else if (base === 13 && rnd < 0.03 && l < 7) suffix = "terra"; - // Portuguese - else if (base === 2 && rnd < 0.03 && l < 7) suffix = "terre"; - // French - else if (base === 0 && rnd < 0.5 && l < 7) suffix = "land"; - // German - else if (base === 1 && rnd < 0.4 && l < 7) suffix = "land"; - // English - else if (base === 6 && rnd < 0.3 && l < 7) suffix = "land"; - // Nordic - else if (base === 32 && rnd < 0.1 && l < 7) suffix = "land"; - // generic Human - else if (base === 7 && rnd < 0.1) suffix = "eia"; - // Greek - else if (base === 9 && rnd < 0.35) suffix = "maa"; - // Finnic - else if (base === 15 && rnd < 0.4 && l < 6) suffix = "orszag"; - // Hungarian - else if (base === 16) suffix = rnd < 0.6 ? "yurt" : "eli"; - // Turkish - else if (base === 10) suffix = "guk"; - // Korean - else if (base === 11) suffix = " Guo"; - // Chinese - else if (base === 14) suffix = rnd < 0.5 && l < 6 ? "tlan" : "co"; - // Nahuatl - else if (base === 17 && rnd < 0.8) suffix = "a"; - // Berber - else if (base === 18 && rnd < 0.8) suffix = "a"; // Arabic - - return validateSuffix(name, suffix); - }; - - function validateSuffix(name, suffix) { - if (name.slice(-1 * suffix.length) === suffix) return name; // no suffix if name already ends with it - const s1 = suffix.charAt(0); - if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter - if (vowel(s1) === vowel(name.slice(-1)) && vowel(s1) === vowel(name.slice(-2, -1))) name = name.slice(0, -1); // remove name last char if 2 last chars are the same type as suffix's 1st - if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter - return name + suffix; - } - - // generato name for the map - const getMapName = function (force) { - if (!force && locked("mapName")) return; - if (force && locked("mapName")) unlock("mapName"); - const base = P(0.7) ? 2 : P(0.5) ? rand(0, 6) : rand(0, 31); - if (!nameBases[base]) { - tip("Namebase is not found", false, "error"); - return ""; - } - const min = nameBases[base].min - 1; - const max = Math.max(nameBases[base].max - 3, min); - const baseName = getBase(base, min, max, "", 0); - const name = P(0.7) ? addSuffix(baseName) : baseName; - mapName.value = name; - }; - - function addSuffix(name) { - const suffix = P(0.8) ? "ia" : "land"; - if (suffix === "ia" && name.length > 6) name = name.slice(0, -(name.length - 3)); - else if (suffix === "land" && name.length > 6) name = name.slice(0, -(name.length - 5)); - return validateSuffix(name, suffix); - } - - const getNameBases = function () { - // name, min length, max length, letters to allow duplication, multi-word name rate [deprecated] - // prettier-ignore - return [ - // real-world bases by Azgaar: - {name: "German", i: 0, min: 5, max: 12, d: "lt", m: 0, b: "Achern,Aichhalden,Aitern,Albbruck,Alpirsbach,Altensteig,Althengstett,Appenweier,Auggen,Badenen,Badenweiler,Baiersbronn,Ballrechten,Bellingen,Berghaupten,Bernau,Biberach,Biederbach,Binzen,Birkendorf,Birkenfeld,Bischweier,Blumberg,Bollen,Bollschweil,Bonndorf,Bosingen,Braunlingen,Breisach,Breisgau,Breitnau,Brigachtal,Buchenbach,Buggingen,Buhl,Buhlertal,Calw,Dachsberg,Dobel,Donaueschingen,Dornhan,Dornstetten,Dottingen,Dunningen,Durbach,Durrheim,Ebhausen,Ebringen,Efringen,Egenhausen,Ehrenkirchen,Ehrsberg,Eimeldingen,Eisenbach,Elzach,Elztal,Emmendingen,Endingen,Engelsbrand,Enz,Enzklosterle,Eschbronn,Ettenheim,Ettlingen,Feldberg,Fischerbach,Fischingen,Fluorn,Forbach,Freiamt,Freiburg,Freudenstadt,Friedenweiler,Friesenheim,Frohnd,Furtwangen,Gaggenau,Geisingen,Gengenbach,Gernsbach,Glatt,Glatten,Glottertal,Gorwihl,Gottenheim,Grafenhausen,Grenzach,Griesbach,Gutach,Gutenbach,Hag,Haiterbach,Hardt,Harmersbach,Hasel,Haslach,Hausach,Hausen,Hausern,Heitersheim,Herbolzheim,Herrenalb,Herrischried,Hinterzarten,Hochenschwand,Hofen,Hofstetten,Hohberg,Horb,Horben,Hornberg,Hufingen,Ibach,Ihringen,Inzlingen,Kandern,Kappel,Kappelrodeck,Karlsbad,Karlsruhe,Kehl,Keltern,Kippenheim,Kirchzarten,Konigsfeld,Krozingen,Kuppenheim,Kussaberg,Lahr,Lauchringen,Lauf,Laufenburg,Lautenbach,Lauterbach,Lenzkirch,Liebenzell,Loffenau,Loffingen,Lorrach,Lossburg,Mahlberg,Malsburg,Malsch,March,Marxzell,Marzell,Maulburg,Monchweiler,Muhlenbach,Mullheim,Munstertal,Murg,Nagold,Neubulach,Neuenburg,Neuhausen,Neuried,Neuweiler,Niedereschach,Nordrach,Oberharmersbach,Oberkirch,Oberndorf,Oberbach,Oberried,Oberwolfach,Offenburg,Ohlsbach,Oppenau,Ortenberg,otigheim,Ottenhofen,Ottersweier,Peterstal,Pfaffenweiler,Pfalzgrafenweiler,Pforzheim,Rastatt,Renchen,Rheinau,Rheinfelden,Rheinmunster,Rickenbach,Rippoldsau,Rohrdorf,Rottweil,Rummingen,Rust,Sackingen,Sasbach,Sasbachwalden,Schallbach,Schallstadt,Schapbach,Schenkenzell,Schiltach,Schliengen,Schluchsee,Schomberg,Schonach,Schonau,Schonenberg,Schonwald,Schopfheim,Schopfloch,Schramberg,Schuttertal,Schwenningen,Schworstadt,Seebach,Seelbach,Seewald,Sexau,Simmersfeld,Simonswald,Sinzheim,Solden,Staufen,Stegen,Steinach,Steinen,Steinmauern,Straubenhardt,Stuhlingen,Sulz,Sulzburg,Teinach,Tiefenbronn,Tiengen,Titisee,Todtmoos,Todtnau,Todtnauberg,Triberg,Tunau,Tuningen,uhlingen,Unterkirnach,Reichenbach,Utzenfeld,Villingen,Villingendorf,Vogtsburg,Vohrenbach,Waldachtal,Waldbronn,Waldkirch,Waldshut,Wehr,Weil,Weilheim,Weisenbach,Wembach,Wieden,Wiesental,Wildbad,Wildberg,Winzeln,Wittlingen,Wittnau,Wolfach,Wutach,Wutoschingen,Wyhlen,Zavelstein"}, - {name: "English", i: 1, min: 6, max: 11, d: "", m: .1, b: "Abingdon,Albrighton,Alcester,Almondbury,Altrincham,Amersham,Andover,Appleby,Ashboume,Atherstone,Aveton,Axbridge,Aylesbury,Baldock,Bamburgh,Barton,Basingstoke,Berden,Bere,Berkeley,Berwick,Betley,Bideford,Bingley,Birmingham,Blandford,Blechingley,Bodmin,Bolton,Bootham,Boroughbridge,Boscastle,Bossinney,Bramber,Brampton,Brasted,Bretford,Bridgetown,Bridlington,Bromyard,Bruton,Buckingham,Bungay,Burton,Calne,Cambridge,Canterbury,Carlisle,Castleton,Caus,Charmouth,Chawleigh,Chichester,Chillington,Chinnor,Chipping,Chisbury,Cleobury,Clifford,Clifton,Clitheroe,Cockermouth,Coleshill,Combe,Congleton,Crafthole,Crediton,Cuddenbeck,Dalton,Darlington,Dodbrooke,Drax,Dudley,Dunstable,Dunster,Dunwich,Durham,Dymock,Exeter,Exning,Faringdon,Felton,Fenny,Finedon,Flookburgh,Fowey,Frampton,Gateshead,Gatton,Godmanchester,Grampound,Grantham,Guildford,Halesowen,Halton,Harbottle,Harlow,Hatfield,Hatherleigh,Haydon,Helston,Henley,Hertford,Heytesbury,Hinckley,Hitchin,Holme,Hornby,Horsham,Kendal,Kenilworth,Kilkhampton,Kineton,Kington,Kinver,Kirby,Knaresborough,Knutsford,Launceston,Leighton,Lewes,Linton,Louth,Luton,Lyme,Lympstone,Macclesfield,Madeley,Malborough,Maldon,Manchester,Manningtree,Marazion,Marlborough,Marshfield,Mere,Merryfield,Middlewich,Midhurst,Milborne,Mitford,Modbury,Montacute,Mousehole,Newbiggin,Newborough,Newbury,Newenden,Newent,Norham,Northleach,Noss,Oakham,Olney,Orford,Ormskirk,Oswestry,Padstow,Paignton,Penkneth,Penrith,Penzance,Pershore,Petersfield,Pevensey,Pickering,Pilton,Pontefract,Portsmouth,Preston,Quatford,Reading,Redcliff,Retford,Rockingham,Romney,Rothbury,Rothwell,Salisbury,Saltash,Seaford,Seasalter,Sherston,Shifnal,Shoreham,Sidmouth,Skipsea,Skipton,Solihull,Somerton,Southam,Southwark,Standon,Stansted,Stapleton,Stottesdon,Sudbury,Swavesey,Tamerton,Tarporley,Tetbury,Thatcham,Thaxted,Thetford,Thornbury,Tintagel,Tiverton,Torksey,Totnes,Towcester,Tregoney,Trematon,Tutbury,Uxbridge,Wallingford,Wareham,Warenmouth,Wargrave,Warton,Watchet,Watford,Wendover,Westbury,Westcheap,Weymouth,Whitford,Wickwar,Wigan,Wigmore,Winchelsea,Winkleigh,Wiscombe,Witham,Witheridge,Wiveliscombe,Woodbury,Yeovil"}, - {name: "French", i: 2, min: 5, max: 13, d: "nlrs", m: .1, b: "Adon,Aillant,Amilly,Andonville,Ardon,Artenay,Ascheres,Ascoux,Attray,Aubin,Audeville,Aulnay,Autruy,Auvilliers,Auxy,Aveyron,Baccon,Bardon,Barville,Batilly,Baule,Bazoches,Beauchamps,Beaugency,Beaulieu,Beaune,Bellegarde,Boesses,Boigny,Boiscommun,Boismorand,Boisseaux,Bondaroy,Bonnee,Bonny,Bordes,Bou,Bougy,Bouilly,Boulay,Bouzonville,Bouzy,Boynes,Bray,Breteau,Briare,Briarres,Bricy,Bromeilles,Bucy,Cepoy,Cercottes,Cerdon,Cernoy,Cesarville,Chailly,Chaingy,Chalette,Chambon,Champoulet,Chanteau,Chantecoq,Chapell,Charme,Charmont,Charsonville,Chateau,Chateauneuf,Chatel,Chatenoy,Chatillon,Chaussy,Checy,Chevannes,Chevillon,Chevilly,Chevry,Chilleurs,Choux,Chuelles,Clery,Coinces,Coligny,Combleux,Combreux,Conflans,Corbeilles,Corquilleroy,Cortrat,Coudroy,Coullons,Coulmiers,Courcelles,Courcy,Courtemaux,Courtempierre,Courtenay,Cravant,Crottes,Dadonville,Dammarie,Dampierre,Darvoy,Desmonts,Dimancheville,Donnery,Dordives,Dossainville,Douchy,Dry,Echilleuses,Egry,Engenville,Epieds,Erceville,Ervauville,Escrennes,Escrignelles,Estouy,Faverelles,Fay,Feins,Ferolles,Ferrieres,Fleury,Fontenay,Foret,Foucherolles,Freville,Gatinais,Gaubertin,Gemigny,Germigny,Gidy,Gien,Girolles,Givraines,Gondreville,Grangermont,Greneville,Griselles,Guigneville,Guilly,Gyleslonains,Huetre,Huisseau,Ingrannes,Ingre,Intville,Isdes,Ivre,Jargeau,Jouy,Juranville,Bussiere,Laas,Ladon,Lailly,Langesse,Leouville,Ligny,Lombreuil,Lorcy,Lorris,Loury,Louzouer,Malesherbois,Marcilly,Mardie,Mareau,Marigny,Marsainvilliers,Melleroy,Menestreau,Merinville,Messas,Meung,Mezieres,Migneres,Mignerette,Mirabeau,Montargis,Montbarrois,Montbouy,Montcresson,Montereau,Montigny,Montliard,Mormant,Morville,Moulinet,Moulon,Nancray,Nargis,Nesploy,Neuville,Neuvy,Nevoy,Nibelle,Nogent,Noyers,Ocre,Oison,Olivet,Ondreville,Onzerain,Orleans,Ormes,Orville,Oussoy,Outarville,Ouzouer,Pannecieres,Pannes,Patay,Paucourt,Pers,Pierrefitte,Pithiverais,Pithiviers,Poilly,Potier,Prefontaines,Presnoy,Pressigny,Puiseaux,Quiers,Ramoulu,Rebrechien,Rouvray,Rozieres,Rozoy,Ruan,Sandillon,Santeau,Saran,Sceaux,Seichebrieres,Semoy,Sennely,Sermaises,Sigloy,Solterre,Sougy,Sully,Sury,Tavers,Thignonville,Thimory,Thorailles,Thou,Tigy,Tivernon,Tournoisis,Trainou,Treilles,Trigueres,Trinay,Vannes,Varennes,Vennecy,Vieilles,Vienne,Viglain,Vignes,Villamblain,Villemandeur,Villemoutiers,Villemurlin,Villeneuve,Villereau,Villevoques,Villorceau,Vimory,Vitry,Vrigny"}, - {name: "Italian", i: 3, min: 5, max: 12, d: "cltr", m: .1, b: "Accumoli,Acquafondata,Acquapendente,Acuto,Affile,Agosta,Alatri,Albano,Allumiere,Alvito,Amaseno,Amatrice,Anagni,Anguillara,Anticoli,Antrodoco,Anzio,Aprilia,Aquino,Arcinazzo,Ariccia,Arpino,Arsoli,Ausonia,Bagnoregio,Bassiano,Bellegra,Belmonte,Bolsena,Bomarzo,Borgorose,Boville,Bracciano,Broccostella,Calcata,Camerata,Campagnano,Campoli,Canale,Canino,Cantalice,Cantalupo,Capranica,Caprarola,Carbognano,Casalattico,Casalvieri,Castelforte,Castelnuovo,Castiglione,Castro,Castrocielo,Ceccano,Celleno,Cellere,Cerreto,Cervara,Cerveteri,Ciampino,Ciciliano,Cittaducale,Cittareale,Civita,Civitella,Colfelice,Colleferro,Collepardo,Colonna,Concerviano,Configni,Contigliano,Cori,Cottanello,Esperia,Faleria,Farnese,Ferentino,Fiamignano,Filacciano,Fiuggi,Fiumicino,Fondi,Fontana,Fonte,Fontechiari,Formia,Frascati,Frasso,Frosinone,Fumone,Gaeta,Gallese,Gavignano,Genazzano,Giuliano,Gorga,Gradoli,Grottaferrata,Grotte,Guarcino,Guidonia,Ischia,Isola,Labico,Labro,Ladispoli,Latera,Lenola,Leonessa,Licenza,Longone,Lubriano,Maenza,Magliano,Marano,Marcellina,Marcetelli,Marino,Mazzano,Mentana,Micigliano,Minturno,Montalto,Montasola,Montebuono,Monteflavio,Montelanico,Monteleone,Montenero,Monterosi,Moricone,Morlupo,Nazzano,Nemi,Nerola,Nespolo,Nettuno,Norma,Olevano,Onano,Oriolo,Orte,Orvinio,Paganico,Paliano,Palombara,Patrica,Pescorocchiano,Petrella,Piansano,Picinisco,Pico,Piedimonte,Piglio,Pignataro,Poggio,Poli,Pomezia,Pontecorvo,Pontinia,Ponzano,Posta,Pozzaglia,Priverno,Proceno,Rignano,Riofreddo,Ripi,Rivodutri,Rocca,Roccagorga,Roccantica,Roccasecca,Roiate,Ronciglione,Roviano,Salisano,Sambuci,Santa,Santini,Scandriglia,Segni,Selci,Sermoneta,Serrone,Settefrati,Sezze,Sgurgola,Sonnino,Sora,Soriano,Sperlonga,Spigno,Subiaco,Supino,Sutri,Tarano,Tarquinia,Terelle,Terracina,Tivoli,Toffia,Tolfa,Torrice,Torricella,Trevi,Trevignano,Trivigliano,Turania,Tuscania,Valentano,Vallecorsa,Vallemaio,Vallepietra,Vallerano,Vasanello,Vejano,Velletri,Ventotene,Veroli,Vetralla,Vicalvi,Vico,Vicovaro,Vignanello,Viterbo,Viticuso,Vitorchiano,Vivaro,Zagarolo"}, - {name: "Castillian", i: 4, min: 5, max: 11, d: "lr", m: 0, b: "Ajofrin,Alameda,Alaminos,Albares,Albarreal,Albendiego,Alcanizo,Alcaudete,Alcolea,Aldea,Aldeanueva,Algar,Algora,Alhondiga,Almadrones,Almendral,Alovera,Anguita,Arbancon,Argecilla,Arges,Arroyo,Atanzon,Atienza,Azuqueca,Baides,Banos,Bargas,Barriopedro,Belvis,Berninches,Brihuega,Buenaventura,Burgos,Burguillos,Bustares,Cabanillas,Calzada,Camarena,Campillo,Cantalojas,Cardiel,Carmena,Casas,Castejon,Castellar,Castilforte,Castillo,Castilnuevo,Cazalegas,Centenera,Cervera,Checa,Chozas,Chueca,Cifuentes,Cincovillas,Ciruelas,Cogollor,Cogolludo,Consuegra,Copernal,Corral,Cuerva,Domingo,Dosbarrios,Driebes,Duron,Escalona,Escalonilla,Escamilla,Escopete,Espinosa,Esplegares,Esquivias,Estables,Estriegana,Fontanar,Fuembellida,Fuensalida,Fuentelsaz,Gajanejos,Galvez,Gascuena,Gerindote,Guadamur,Heras,Herreria,Herreruela,Hinojosa,Hita,Hombrados,Hontanar,Hormigos,Huecas,Huerta,Humanes,Illana,Illescas,Iniestola,Irueste,Jadraque,Jirueque,Lagartera,Ledanca,Lillo,Lominchar,Loranca,Lucillos,Luzaga,Luzon,Madrid,Magan,Malaga,Malpica,Manzanar,Maqueda,Masegoso,Matillas,Medranda,Megina,Mejorada,Millana,Milmarcos,Mirabueno,Miralrio,Mocejon,Mochales,Molina,Mondejar,Montarron,Mora,Moratilla,Morenilla,Navas,Negredo,Noblejas,Numancia,Nuno,Ocana,Ocentejo,Olias,Olmeda,Ontigola,Orea,Orgaz,Oropesa,Otero,Palma,Pardos,Paredes,Penalver,Pepino,Peralejos,Pinilla,Pioz,Piqueras,Portillo,Poveda,Pozo,Pradena,Prados,Puebla,Puerto,Quero,Quintanar,Rebollosa,Retamoso,Riba,Riofrio,Robledo,Romanillos,Romanones,Rueda,Salmeron,Santiuste,Santo,Sauca,Segura,Selas,Semillas,Sesena,Setiles,Sevilla,Siguenza,Solanillos,Somolinos,Sonseca,Sotillo,Talavera,Taravilla,Tembleque,Tendilla,Tierzo,Torralba,Torre,Torrejon,Torrijos,Tortola,Tortuera,Totanes,Trillo,Uceda,Ugena,Urda,Utande,Valdesotos,Valhermoso,Valtablado,Valverde,Velada,Viana,Yebra,Yuncos,Yunquera,Zaorejas,Zarzuela,Zorita"}, - {name: "Ruthenian", i: 5, min: 5, max: 10, d: "", m: 0, b: "Belgorod,Beloberezhye,Belyi,Belz,Berestiy,Berezhets,Berezovets,Berezutsk,Bobruisk,Bolonets,Borisov,Borovsk,Bozhesk,Bratslav,Bryansk,Brynsk,Buryn,Byhov,Chechersk,Chemesov,Cheremosh,Cherlen,Chern,Chernigov,Chernitsa,Chernobyl,Chernogorod,Chertoryesk,Chetvertnia,Demyansk,Derevesk,Devyagoresk,Dichin,Dmitrov,Dorogobuch,Dorogobuzh,Drestvin,Drokov,Drutsk,Dubechin,Dubichi,Dubki,Dubkov,Dveren,Galich,Glebovo,Glinsk,Goloty,Gomiy,Gorodets,Gorodische,Gorodno,Gorohovets,Goroshin,Gorval,Goryshon,Holm,Horobor,Hoten,Hotin,Hotmyzhsk,Ilovech,Ivan,Izborsk,Izheslavl,Kamenets,Kanev,Karachev,Karna,Kavarna,Klechesk,Klyapech,Kolomyya,Kolyvan,Kopyl,Korec,Kornik,Korochunov,Korshev,Korsun,Koshkin,Kotelno,Kovyla,Kozelsk,Kozelsk,Kremenets,Krichev,Krylatsk,Ksniatin,Kulatsk,Kursk,Kursk,Lebedev,Lida,Logosko,Lomihvost,Loshesk,Loshichi,Lubech,Lubno,Lubutsk,Lutsk,Luchin,Luki,Lukoml,Luzha,Lvov,Mtsensk,Mdin,Medniki,Melecha,Merech,Meretsk,Mescherskoe,Meshkovsk,Metlitsk,Mezetsk,Mglin,Mihailov,Mikitin,Mikulino,Miloslavichi,Mogilev,Mologa,Moreva,Mosalsk,Moschiny,Mozyr,Mstislav,Mstislavets,Muravin,Nemech,Nemiza,Nerinsk,Nichan,Novgorod,Novogorodok,Obolichi,Obolensk,Obolensk,Oleshsk,Olgov,Omelnik,Opoka,Opoki,Oreshek,Orlets,Osechen,Oster,Ostrog,Ostrov,Perelai,Peremil,Peremyshl,Pererov,Peresechen,Perevitsk,Pereyaslav,Pinsk,Ples,Polotsk,Pronsk,Proposhesk,Punia,Putivl,Rechitsa,Rodno,Rogachev,Romanov,Romny,Roslavl,Rostislavl,Rostovets,Rsha,Ruza,Rybchesk,Rylsk,Rzhavesk,Rzhev,Rzhischev,Sambor,Serensk,Serensk,Serpeysk,Shilov,Shuya,Sinech,Sizhka,Skala,Slovensk,Slutsk,Smedin,Sneporod,Snitin,Snovsk,Sochevo,Sokolec,Starica,Starodub,Stepan,Sterzh,Streshin,Sutesk,Svinetsk,Svisloch,Terebovl,Ternov,Teshilov,Teterin,Tiversk,Torchevsk,Toropets,Torzhok,Tripolye,Trubchevsk,Tur,Turov,Usvyaty,Uteshkov,Vasilkov,Velil,Velye,Venev,Venicha,Verderev,Vereya,Veveresk,Viazma,Vidbesk,Vidychev,Voino,Volodimer,Volok,Volyn,Vorobesk,Voronich,Voronok,Vorotynsk,Vrev,Vruchiy,Vselug,Vyatichsk,Vyatka,Vyshegorod,Vyshgorod,Vysokoe,Yagniatin,Yaropolch,Yasenets,Yuryev,Yuryevets,Zaraysk,Zhitomel,Zholvazh,Zizhech,Zubkov,Zudechev,Zvenigorod"}, - {name: "Nordic", i: 6, min: 6, max: 10, d: "kln", m: .1, b: "Akureyri,Aldra,Alftanes,Andenes,Austbo,Auvog,Bakkafjordur,Ballangen,Bardal,Beisfjord,Bifrost,Bildudalur,Bjerka,Bjerkvik,Bjorkosen,Bliksvaer,Blokken,Blonduos,Bolga,Bolungarvik,Borg,Borgarnes,Bosmoen,Bostad,Bostrand,Botsvika,Brautarholt,Breiddalsvik,Bringsli,Brunahlid,Budardalur,Byggdakjarni,Dalvik,Djupivogur,Donnes,Drageid,Drangsnes,Egilsstadir,Eiteroga,Elvenes,Engavogen,Ertenvog,Eskifjordur,Evenes,Eyrarbakki,Fagernes,Fallmoen,Fellabaer,Fenes,Finnoya,Fjaer,Fjelldal,Flakstad,Flateyri,Flostrand,Fludir,Gardaber,Gardur,Gimstad,Givaer,Gjeroy,Gladstad,Godoya,Godoynes,Granmoen,Gravdal,Grenivik,Grimsey,Grindavik,Grytting,Hafnir,Halsa,Hauganes,Haugland,Hauknes,Hella,Helland,Hellissandur,Hestad,Higrav,Hnifsdalur,Hofn,Hofsos,Holand,Holar,Holen,Holkestad,Holmavik,Hopen,Hovden,Hrafnagil,Hrisey,Husavik,Husvik,Hvammstangi,Hvanneyri,Hveragerdi,Hvolsvollur,Igeroy,Indre,Inndyr,Innhavet,Innes,Isafjordur,Jarklaustur,Jarnsreykir,Junkerdal,Kaldvog,Kanstad,Karlsoy,Kavosen,Keflavik,Kjelde,Kjerstad,Klakk,Kopasker,Kopavogur,Korgen,Kristnes,Krutoga,Krystad,Kvina,Lande,Laugar,Laugaras,Laugarbakki,Laugarvatn,Laupstad,Leines,Leira,Leiren,Leland,Lenvika,Loding,Lodingen,Lonsbakki,Lopsmarka,Lovund,Luroy,Maela,Melahverfi,Meloy,Mevik,Misvaer,Mornes,Mosfellsber,Moskenes,Myken,Naurstad,Nesberg,Nesjahverfi,Nesset,Nevernes,Obygda,Ofoten,Ogskardet,Okervika,Oknes,Olafsfjordur,Oldervika,Olstad,Onstad,Oppeid,Oresvika,Orsnes,Orsvog,Osmyra,Overdal,Prestoya,Raudalaekur,Raufarhofn,Reipo,Reykholar,Reykholt,Reykjahlid,Rif,Rinoya,Rodoy,Rognan,Rosvika,Rovika,Salhus,Sanden,Sandgerdi,Sandoker,Sandset,Sandvika,Saudarkrokur,Selfoss,Selsoya,Sennesvik,Setso,Siglufjordur,Silvalen,Skagastrond,Skjerstad,Skonland,Skorvogen,Skrova,Sleneset,Snubba,Softing,Solheim,Solheimar,Sorarnoy,Sorfugloy,Sorland,Sormela,Sorvaer,Sovika,Stamsund,Stamsvika,Stave,Stokka,Stokkseyri,Storjord,Storo,Storvika,Strand,Straumen,Strendene,Sudavik,Sudureyri,Sundoya,Sydalen,Thingeyri,Thorlakshofn,Thorshofn,Tjarnabyggd,Tjotta,Tosbotn,Traelnes,Trofors,Trones,Tverro,Ulvsvog,Unnstad,Utskor,Valla,Vandved,Varmahlid,Vassos,Vevelstad,Vidrek,Vik,Vikholmen,Vogar,Vogehamn,Vopnafjordur"}, - {name: "Greek", i: 7, min: 5, max: 11, d: "s", m: .1, b: "Abdera,Acharnae,Aegae,Aegina,Agrinion,Aigosthena,Akragas,Akroinon,Akrotiri,Alalia,Alexandria,Amarynthos,Amaseia,Amphicaea,Amphigeneia,Amphipolis,Antipatrea,Antiochia,Apamea,Aphidna,Apollonia,Argos,Artemita,Argyropolis,Asklepios,Athenai,Athmonia,Bhrytos,Borysthenes,Brauron,Byblos,Byzantion,Bythinion,Calydon,Chamaizi,Chalcis,Chios,Cleona,Corcyra,Croton,Cyrene,Cythera,Decelea,Delos,Delphi,Dicaearchia,Didyma,Dion,Dioscurias,Dodona,Dorylaion,Elateia,Eleusis,Eleutherna,Emporion,Ephesos,Epidamnos,Epidauros,Epizephyrian,Erythrae,Eubea,Golgi,Gonnos,Gorgippia,Gournia,Gortyn,Gytion,Hagios,Halicarnassos,Heliopolis,Hellespontos,Heloros,Heraclea,Hierapolis,Himera,Histria,Hubla,Hyele,Ialysos,Iasos,Idalion,Imbros,Iolcos,Itanos,Ithaca,Juktas,Kallipolis,Kameiros,Karistos,Kasmenai,Kepoi,Kimmerikon,Knossos,Korinthos,Kos,Kourion,Kydonia,Kyrenia,Lamia,Lampsacos,Laodicea,Lapithos,Larissa,Lebena,Lefkada,Lekhaion,Leibethra,Leontinoi,Lilaea,Lindos,Lissos,Magnesia,Mantineia,Marathon,Marmara,Massalia,Megalopolis,Megara,Metapontion,Methumna,Miletos,Morgantina,Mulai,Mukenai,Myonia,Myra,Myrmekion,Myos,Nauplios,Naucratis,Naupaktos,Naxos,Neapolis,Nemea,Nicaea,Nicopolis,Nymphaion,Nysa,Odessos,Olbia,Olympia,Olynthos,Opos,Orchomenos,Oricos,Orestias,Oreos,Onchesmos,Pagasae,Palaikastro,Pandosia,Panticapaion,Paphos,Pargamon,Paros,Pegai,Pelion,Peiraies,Phaistos,Phaleron,Pharos,Pithekussa,Philippopolis,Phocaea,Pinara,Pisa,Pitane,Plataea,Poseidonia,Potidaea,Pseira,Psychro,Pteleos,Pydna,Pylos,Pyrgos,Rhamnos,Rhithymna,Rhypae,Rizinia,Rodos,Salamis,Samos,Skyllaion,Seleucia,Semasos,Sestos,Scidros,Sicyon,,Sinope,Siris,Smyrna,Sozopolis,Sparta,Stagiros,Stratos,Stymphalos,Sybaris,Surakousai,Taras,Tanagra,Tanais,Tauromenion,Tegea,Temnos,Teos,Thapsos,Thassos,Thebai,Theodosia,Therma,Thespian,Thronion,Thoricos,Thurii,Thyreum,Thyria,Tithoraea,Tomis,Tragurion,Tripolis,Troliton,Troy,Tylissos,Tyros,Vathypetros,Zakynthos,Zakros"}, - {name: "Roman", i: 8, min: 6, max: 11, d: "ln", m: .1, b: "Abila,Adflexum,Adnicrem,Aelia,Aelius,Aeminium,Aequum,Agrippina,Agrippinae,Ala,Albanianis,Aleria,Ambianum,Andautonia,Apulum,Aquae,Aquaegranni,Aquensis,Aquileia,Aquincum,Arae,Argentoratum,Ariminum,Ascrivium,Asturica,Atrebatum,Atuatuca,Augusta,Aurelia,Aurelianorum,Batavar,Batavorum,Belum,Biriciana,Blestium,Bonames,Bonna,Bononia,Borbetomagus,Bovium,Bracara,Brigantium,Burgodunum,Caesaraugusta,Caesarea,Caesaromagus,Calleva,Camulodunum,Cannstatt,Cantiacorum,Capitolina,Caralis,Castellum,Castra,Castrum,Cibalae,Clausentum,Colonia,Concangis,Condate,Confluentes,Conimbriga,Corduba,Coria,Corieltauvorum,Corinium,Coriovallum,Cornoviorum,Danum,Deva,Dianium,Divodurum,Dobunnorum,Drusi,Dubris,Dumnoniorum,Durnovaria,Durocobrivis,Durocornovium,Duroliponte,Durovernum,Durovigutum,Eboracum,Ebusus,Edetanorum,Emerita,Emona,Emporiae,Euracini,Faventia,Flaviae,Florentia,Forum,Gerulata,Gerunda,Gesoscribate,Glevensium,Hadriani,Herculanea,Isca,Italica,Iulia,Iuliobrigensium,Iuvavum,Lactodurum,Lagentium,Lapurdum,Lauri,Legionis,Lemanis,Lentia,Lepidi,Letocetum,Lindinis,Lindum,Lixus,Londinium,Lopodunum,Lousonna,Lucus,Lugdunum,Luguvalium,Lutetia,Mancunium,Marsonia,Martius,Massa,Massilia,Matilo,Mattiacorum,Mediolanum,Mod,Mogontiacum,Moridunum,Mursa,Naissus,Nervia,Nida,Nigrum,Novaesium,Noviomagus,Olicana,Olisippo,Ovilava,Parisiorum,Partiscum,Paterna,Pistoria,Placentia,Pollentia,Pomaria,Pompeii,Pons,Portus,Praetoria,Praetorium,Pullum,Ragusium,Ratae,Raurica,Ravenna,Regina,Regium,Regulbium,Rigomagus,Roma,Romula,Rutupiae,Salassorum,Salernum,Salona,Scalabis,Segovia,Silurum,Sirmium,Siscia,Sorviodurum,Sumelocenna,Tarraco,Taurinorum,Theranda,Traiectum,Treverorum,Tungrorum,Turicum,Ulpia,Valentia,Venetiae,Venta,Verulamium,Vesontio,Vetera,Victoriae,Victrix,Villa,Viminacium,Vindelicorum,Vindobona,Vinovia,Viroconium"}, - {name: "Finnic", i: 9, min: 5, max: 11, d: "akiut", m: 0, b: "Aanekoski,Ahlainen,Aholanvaara,Ahtari,Aijala,Akaa,Alajarvi,Antsla,Aspo,Bennas,Bjorkoby,Elva,Emasalo,Espoo,Esse,Evitskog,Forssa,Haapamaki,Haapavesi,Haapsalu,Hameenlinna,Hanko,Harjavalta,Hattuvaara,Hautajarvi,Havumaki,Heinola,Hetta,Hinkabole,Hirmula,Hossa,Huittinen,Husula,Hyryla,Hyvinkaa,Ikaalinen,Iskmo,Itakoski,Jamsa,Jarvenpaa,Jeppo,Jioesuu,Jiogeva,Joensuu,Jokikyla,Jungsund,Jyvaskyla,Kaamasmukka,Kajaani,Kalajoki,Kallaste,Kankaanpaa,Karkku,Karpankyla,Kaskinen,Kasnas,Kauhajoki,Kauhava,Kauniainen,Kauvatsa,Kehra,Kellokoski,Kelottijarvi,Kemi,Kemijarvi,Kerava,Keuruu,Kiljava,Kiuruvesi,Kivesjarvi,Kiviioli,Kivisuo,Klaukkala,Klovskog,Kohtlajarve,Kokemaki,Kokkola,Kolho,Koskue,Kotka,Kouva,Kaupunki,Kuhmo,Kunda,Kuopio,Kuressaare,Kurikka,Kuusamo,Kylmalankyla,Lahti,Laitila,Lankipohja,Lansikyla,Lapua,Laurila,Lautiosaari,Lempaala,Lepsama,Liedakkala,Lieksa,Littoinen,Lohja,Loimaa,Loksa,Loviisa,Malmi,Mantta,Matasvaara,Maula,Miiluranta,Mioisakula,Munapirtti,Mustvee,Muurahainen,Naantali,Nappa,Narpio,Niinimaa,Niinisalo,Nikkila,Nilsia,Nivala,Nokia,Nummela,Nuorgam,Nuvvus,Obbnas,Oitti,Ojakkala,Onninen,Orimattila,Orivesi,Otanmaki,Otava,Otepaa,Oulainen,Oulu,Paavola,Paide,Paimio,Pakankyla,Paldiski,Parainen,Parkumaki,Parola,Perttula,Pieksamaki,Pioltsamaa,Piolva,Pohjavaara,Porhola,Porrasa,Porvoo,Pudasjarvi,Purmo,Pyhajarvi,Raahe,Raasepori,Raisio,Rajamaki,Rakvere,Rapina,Rapla,Rauma,Rautio,Reposaari,Riihimaki,Rovaniemi,Roykka,Ruonala,Ruottala,Rutalahti,Saarijarvi,Salo,Sastamala,Saue,Savonlinna,Seinajoki,Sillamae,Siuntio,Sompujarvi,Suonenjoki,Suurejaani,Syrjantaka,Tamsalu,Tapa,Temmes,Tiorva,Tormasenvaara,Tornio,Tottijarvi,Tulppio,Turenki,Turi,Tuukkala,Tuurala,Tuuri,Tuuski,Tuusniemi,Ulvila,Unari,Upinniemi,Utti,Uusikaupunki,Vaaksy,Vaalimaa,Vaarinmaja,Vaasa,Vainikkala,Valga,Valkeakoski,Vantaa,Varkaus,Vehkapera,Vehmasmaki,Vieki,Vierumaki,Viitasaari,Viljandi,Vilppula,Viohma,Vioru,Virrat,Ylike,Ylivieska,Ylojarvi"}, - {name: "Korean", i: 10, min: 5, max: 11, d: "", m: 0, b: "Anjung,Ansan,Anseong,Anyang,Aphae,Apo,Baekseok,Baeksu,Beolgyo,Boeun,Boseong,Busan,Buyeo,Changnyeong,Changwon,Cheonan,Cheongdo,Cheongjin,Cheongsong,Cheongyang,Cheorwon,Chirwon,Chuncheon,Chungju,Daedeok,Daegaya,Daejeon,Damyang,Dangjin,Dasa,Donghae,Dongsong,Doyang,Eonyang,Gaeseong,Ganggyeong,Ganghwa,Gangneung,Ganseong,Gaun,Geochang,Geoje,Geoncheon,Geumho,Geumil,Geumwang,Gijang,Gimcheon,Gimhwa,Gimje,Goa,Gochang,Gohan,Gongdo,Gongju,Goseong,Goyang,Gumi,Gunpo,Gunsan,Guri,Gurye,Gwangju,Gwangyang,Gwansan,Gyeongseong,Hadong,Hamchang,Hampyeong,Hamyeol,Hanam,Hapcheon,Hayang,Heungnam,Hongnong,Hongseong,Hwacheon,Hwando,Hwaseong,Hwasun,Hwawon,Hyangnam,Incheon,Inje,Iri,Janghang,Jangheung,Jangseong,Jangseungpo,Jangsu,Jecheon,Jeju,Jeomchon,Jeongeup,Jeonggwan,Jeongju,Jeongok,Jeongseon,Jeonju,Jido,Jiksan,Jinan,Jincheon,Jindo,Jingeon,Jinjeop,Jinnampo,Jinyeong,Jocheon,Jochiwon,Jori,Maepo,Mangyeong,Mokpo,Muju,Munsan,Naesu,Naju,Namhae,Namwon,Namyang,Namyangju,Nongong,Nonsan,Ocheon,Okcheon,Okgu,Onam,Onsan,Onyang,Opo,Paengseong,Pogok,Poseung,Pungsan,Pyeongchang,Pyeonghae,Pyeongyang,Sabi,Sacheon,Samcheok,Samho,Samrye,Sancheong,Sangdong,Sangju,Sapgyo,Sariwon,Sejong,Seocheon,Seogwipo,Seonghwan,Seongjin,Seongju,Seongnam,Seongsan,Seosan,Seungju,Siheung,Sindong,Sintaein,Soheul,Sokcho,Songak,Songjeong,Songnim,Songtan,Suncheon,Taean,Taebaek,Tongjin,Uijeongbu,Uiryeong,Uiwang,Uljin,Ulleung,Unbong,Ungcheon,Ungjin,Waegwan,Wando,Wayang,Wiryeseong,Wondeok,Yangju,Yangsan,Yangyang,Yecheon,Yeomchi,Yeoncheon,Yeongam,Yeongcheon,Yeongdeok,Yeongdong,Yeonggwang,Yeongju,Yeongwol,Yeongyang,Yeonil,Yongin,Yongjin,Yugu"}, - {name: "Chinese", i: 11, min: 5, max: 10, d: "", m: 0, b: "Anding,Anlu,Anqing,Anshun,Baixing,Banyang,Baoqing,Binzhou,Caozhou,Changbai,Changchun,Changde,Changling,Changsha,Changzhou,Chengdu,Chenzhou,Chizhou,Chongqing,Chuxiong,Chuzhou,Dading,Daming,Datong,Daxing,Dengzhou,Deqing,Dihua,Dingli,Dongan,Dongchang,Dongchuan,Dongping,Duyun,Fengtian,Fengxiang,Fengyang,Fenzhou,Funing,Fuzhou,Ganzhou,Gaoyao,Gaozhou,Gongchang,Guangnan,Guangning,Guangping,Guangxin,Guangzhou,Guiyang,Hailong,Hangzhou,Hanyang,Hanzhong,Heihe,Hejian,Henan,Hengzhou,Hezhong,Huaian,Huaiqing,Huanglong,Huangzhou,Huining,Hulan,Huzhou,Jiading,Jian,Jianchang,Jiangning,Jiankang,Jiaxing,Jiayang,Jilin,Jinan,Jingjiang,Jingzhao,Jinhua,Jinzhou,Jiujiang,Kaifeng,Kaihua,Kangding,Kuizhou,Laizhou,Lianzhou,Liaoyang,Lijiang,Linan,Linhuang,Lintao,Liping,Liuzhou,Longan,Longjiang,Longxing,Luan,Lubin,Luzhou,Mishan,Nanan,Nanchang,Nandian,Nankang,Nanyang,Nenjiang,Ningbo,Ningguo,Ningwu,Ningxia,Ningyuan,Pingjiang,Pingliang,Pingyang,Puer,Puzhou,Qianzhou,Qingyang,Qingyuan,Qingzhou,Qujing,Quzhou,Raozhou,Rende,Ruian,Ruizhou,Shafeng,Shajing,Shaoqing,Shaowu,Shaoxing,Shaozhou,Shinan,Shiqian,Shouchun,Shuangcheng,Shulei,Shunde,Shuntian,Shuoping,Sicheng,Sinan,Sizhou,Songjiang,Suiding,Suihua,Suining,Suzhou,Taian,Taibei,Taiping,Taiwan,Taiyuan,Taizhou,Taonan,Tengchong,Tingzhou,Tongchuan,Tongqing,Tongzhou,Weihui,Wensu,Wenzhou,Wuchang,Wuding,Wuzhou,Xian,Xianchun,Xianping,Xijin,Xiliang,Xincheng,Xingan,Xingde,Xinghua,Xingjing,Xingyi,Xingyuan,Xingzhong,Xining,Xinmen,Xiping,Xuanhua,Xunzhou,Xuzhou,Yanan,Yangzhou,Yanji,Yanping,Yanzhou,Yazhou,Yichang,Yidu,Yilan,Yili,Yingchang,Yingde,Yingtian,Yingzhou,Yongchang,Yongping,Yongshun,Yuanzhou,Yuezhou,Yulin,Yunnan,Yunyang,Zezhou,Zhang,Zhangzhou,Zhaoqing,Zhaotong,Zhenan,Zhending,Zhenhai,Zhenjiang,Zhenxi,Zhenyun,Zhongshan,Zunyi"}, - {name: "Japanese", i: 12, min: 4, max: 10, d: "", m: 0, b: "Abira,Aga,Aikawa,Aizumisato,Ajigasawa,Akkeshi,Amagi,Ami,Ando,Asakawa,Ashikita,Bandai,Biratori,Chonan,Esashi,Fuchu,Fujimi,Funagata,Genkai,Godo,Goka,Gonohe,Gyokuto,Haboro,Hamatonbetsu,Harima,Hashikami,Hayashima,Heguri,Hidaka,Higashiura,Hiranai,Hirogawa,Hiroo,Hodatsushimizu,Hoki,Hokuei,Hokuryu,Horokanai,Ibigawa,Ichikai,Ichikawa,Ichinohe,Iijima,Iizuna,Ikawa,Inagawa,Itakura,Iwaizumi,Iwate,Kaisei,Kamifurano,Kamiita,Kamijima,Kamikawa,Kamishihoro,Kamiyama,Kanda,Kanna,Kasagi,Kasuya,Katsuura,Kawabe,Kawamoto,Kawanehon,Kawanishi,Kawara,Kawasaki,Kawatana,Kawazu,Kihoku,Kikonai,Kin,Kiso,Kitagata,Kitajima,Kiyama,Kiyosato,Kofu,Koge,Kohoku,Kokonoe,Kora,Kosa,Kotohira,Kudoyama,Kumejima,Kumenan,Kumiyama,Kunitomi,Kurate,Kushimoto,Kutchan,Kyonan,Kyotamba,Mashike,Matsumae,Mifune,Mihama,Minabe,Minami,Minamiechizen,Minamitane,Misaki,Misasa,Misato,Miyashiro,Miyoshi,Mori,Moseushi,Mutsuzawa,Nagaizumi,Nagatoro,Nagayo,Nagomi,Nakadomari,Nakanojo,Nakashibetsu,Namegawa,Nanbu,Nanporo,Naoshima,Nasu,Niseko,Nishihara,Nishiizu,Nishikatsura,Nishikawa,Nishinoshima,Nishiwaga,Nogi,Noto,Nyuzen,Oarai,Obuse,Odai,Ogawara,Oharu,Oirase,Oishida,Oiso,Oizumi,Oji,Okagaki,Okutama,Omu,Ono,Osaka,Otobe,Otsuki,Owani,Reihoku,Rifu,Rikubetsu,Rishiri,Rokunohe,Ryuo,Saka,Sakuho,Samani,Satsuma,Sayo,Saza,Setana,Shakotan,Shibayama,Shikama,Shimamoto,Shimizu,Shintomi,Shirakawa,Shisui,Shitara,Sobetsu,Sue,Sumita,Suooshima,Suttsu,Tabuse,Tachiarai,Tadami,Tadaoka,Taiji,Taiki,Takachiho,Takahama,Taketoyo,Taragi,Tateshina,Tatsugo,Tawaramoto,Teshikaga,Tobe,Tokigawa,Toma,Tomioka,Tonosho,Tosa,Toyokoro,Toyotomi,Toyoyama,Tsubata,Tsubetsu,Tsukigata,Tsuno,Tsuwano,Umi,Wakasa,Yamamoto,Yamanobe,Yamatsuri,Yanaizu,Yasuda,Yoichi,Yonaguni,Yoro,Yoshino,Yubetsu,Yugawara,Yuni,Yusuhara,Yuza"}, - {name: "Portuguese", i: 13, min: 5, max: 11, d: "", m: .1, b: "Abrigada,Afonsoeiro,Agueda,Aguilada,Alagoas,Alagoinhas,Albufeira,Alcanhoes,Alcobaca,Alcoutim,Aldoar,Alenquer,Alfeizerao,Algarve,Almada,Almagreira,Almeirim,Alpalhao,Alpedrinha,Alvorada,Amieira,Anapolis,Apelacao,Aranhas,Arganil,Armacao,Assenceira,Aveiro,Avelar,Balsas,Barcarena,Barreiras,Barretos,Batalha,Beira,Benavente,Betim,Braga,Braganca,Brasilia,Brejo,Cabeceiras,Cabedelo,Cachoeiras,Cadafais,Calhandriz,Calheta,Caminha,Campinas,Canidelo,Canoas,Capinha,Carmoes,Cartaxo,Carvalhal,Carvoeiro,Cascavel,Castanhal,Caxias,Chapadinha,Chaves,Cocais,Coentral,Coimbra,Comporta,Conde,Coqueirinho,Coruche,Damaia,Dourados,Enxames,Ericeira,Ervidel,Escalhao,Esmoriz,Espinhal,Estela,Estoril,Eunapolis,Evora,Famalicao,Fanhoes,Faro,Fatima,Felgueiras,Ferreira,Figueira,Flecheiras,Florianopolis,Fornalhas,Fortaleza,Freiria,Freixeira,Fronteira,Fundao,Gracas,Gradil,Grainho,Gralheira,Guimaraes,Horta,Ilhavo,Ilheus,Lages,Lagos,Laranjeiras,Lavacolhos,Leiria,Limoeiro,Linhares,Lisboa,Lomba,Lorvao,Lourical,Lourinha,Luziania,Macedo,Machava,Malveira,Marinhais,Maxial,Mealhada,Milharado,Mira,Mirandela,Mogadouro,Montalegre,Mourao,Nespereira,Nilopolis,Obidos,Odemira,Odivelas,Oeiras,Oleiros,Olhalvo,Olinda,Olival,Oliveira,Oliveirinha,Palheiros,Palmeira,Palmital,Pampilhosa,Pantanal,Paradinha,Parelheiros,Pedrosinho,Pegoes,Penafiel,Peniche,Pinhao,Pinheiro,Pombal,Pontal,Pontinha,Portel,Portimao,Quarteira,Queluz,Ramalhal,Reboleira,Recife,Redinha,Ribadouro,Ribeira,Ribeirao,Rosais,Sabugal,Sacavem,Sagres,Sandim,Sangalhos,Santarem,Santos,Sarilhos,Seixas,Seixezelo,Seixo,Silvares,Silveira,Sinhaem,Sintra,Sobral,Sobralinho,Tabuaco,Tabuleiro,Taveiro,Teixoso,Telhado,Telheiro,Tomar,Torreira,Trancoso,Troviscal,Vagos,Varzea,Velas,Viamao,Viana,Vidigal,Vidigueira,Vidual,Vilamar,Vimeiro,Vinhais,Vitoria"}, - {name: "Nahuatl", i: 14, min: 6, max: 13, d: "l", m: 0, b: "Acapulco,Acatepec,Acatlan,Acaxochitlan,Acolman,Actopan,Acuamanala,Ahuacatlan,Almoloya,Amacuzac,Amanalco,Amaxac,Apaxco,Apetatitlan,Apizaco,Atenco,Atizapan,Atlacomulco,Atlapexco,Atotonilco,Axapusco,Axochiapan,Axocomanitla,Axutla,Azcapotzalco,Aztahuacan,Calimaya,Calnali,Calpulalpan,Camotlan,Capulhuac,Chalco,Chapulhuacan,Chapultepec,Chiapan,Chiautempan,Chiconautla,Chihuahua,Chilcuautla,Chimalhuacan,Cholollan,Cihuatlan,Coahuila,Coatepec,Coatetelco,Coatlan,Coatlinchan,Coatzacoalcos,Cocotitlan,Cohetzala,Colima,Colotlan,Coyoacan,Coyohuacan,Cuapiaxtla,Cuauhnahuac,Cuauhtemoc,Cuauhtitlan,Cuautepec,Cuautla,Cuaxomulco,Culhuacan,Ecatepec,Eloxochitlan,Epatlan,Epazoyucan,Huamantla,Huascazaloya,Huatlatlauca,Huautla,Huehuetlan,Huehuetoca,Huexotla,Hueyapan,Hueyotlipan,Hueypoxtla,Huichapan,Huimilpan,Huitzilac,Ixtapallocan,Iztacalco,Iztaccihuatl,Iztapalapa,Lolotla,Malinalco,Mapachtlan,Mazatepec,Mazatlan,Metepec,Metztitlan,Mexico,Miacatlan,Michoacan,Minatitlan,Mixcoac,Mixtla,Molcaxac,Nanacamilpa,Naucalpan,Naupan,Nextlalpan,Nezahualcoyotl,Nopalucan,Oaxaca,Ocotepec,Ocotitlan,Ocotlan,Ocoyoacac,Ocuilan,Ocuituco,Omitlan,Otompan,Otzoloapan,Pacula,Pahuatlan,Panotla,Papalotla,Patlachican,Piaztla,Popocatepetl,Sultepec,Tecamac,Tecolotlan,Tecozautla,Temamatla,Temascalapa,Temixco,Temoac,Temoaya,Tenayuca,Tenochtitlan,Teocuitlatlan,Teotihuacan,Teotlalco,Tepeacac,Tepeapulco,Tepehuacan,Tepetitlan,Tepeyanco,Tepotzotlan,Tepoztlan,Tetecala,Tetlatlahuca,Texcalyacac,Texcoco,Tezontepec,Tezoyuca,Timilpan,Tizapan,Tizayuca,Tlacopan,Tlacotenco,Tlahuac,Tlahuelilpan,Tlahuiltepa,Tlalmanalco,Tlalnepantla,Tlalpan,Tlanchinol,Tlatelolco,Tlaxcala,Tlaxcoapan,Tlayacapan,Tocatlan,Tolcayuca,Toluca,Tonanitla,Tonantzintla,Tonatico,Totolac,Totolapan,Tototlan,Tuchtlan,Tulantepec,Tultepec,Tzompantepec,Xalatlaco,Xaloztoc,Xaltocan,Xiloxoxtla,Xochiatipan,Xochicoatlan,Xochimilco,Xochitepec,Xolotlan,Xonacatlan,Yahualica,Yautepec,Yecapixtla,Yehaultepec,Zacatecas,Zacazonapan,Zacoalco,Zacualpan,Zacualtipan,Zapotlan,Zimapan,Zinacantepec,Zoyaltepec,Zumpahuacan"}, - {name: "Hungarian", i: 15, min: 6, max: 13, d: "", m: 0.1, b: "Aba,Abadszalok,Adony,Ajak,Albertirsa,Alsozsolca,Aszod,Babolna,Bacsalmas,Baktaloranthaza,Balassagyarmat,Balatonalmadi,Balatonboglar,Balkany,Balmazujvaros,Barcs,Bataszek,Batonyterenye,Battonya,Bekes,Berettyoujfalu,Berhida,Biatorbagy,Bicske,Biharkeresztes,Bodajk,Boly,Bonyhad,Budakalasz,Budakeszi,Celldomolk,Csakvar,Csenger,Csongrad,Csorna,Csorvas,Csurgo,Dabas,Demecser,Derecske,Devavanya,Devecser,Dombovar,Dombrad,Dunafoldvar,Dunaharaszti,Dunavarsany,Dunavecse,Edeleny,Elek,Emod,Encs,Enying,Ercsi,Fegyvernek,Fehergyarmat,Felsozsolca,Fertoszentmiklos,Fonyod,Fot,Fuzesabony,Fuzesgyarmat,Gardony,God,Gyal,Gyomaendrod,Gyomro,Hajdudorog,Hajduhadhaz,Hajdusamson,Hajduszoboszlo,Halasztelek,Harkany,Hatvan,Heves,Heviz,Ibrany,Isaszeg,Izsak,Janoshalma,Janossomorja,Jaszapati,Jaszarokszallas,Jaszfenyszaru,Jaszkiser,Kaba,Kalocsa,Kapuvar,Karcag,Kecel,Kemecse,Kenderes,Kerekegyhaza,Keszthely,Kisber,Kiskunmajsa,Kistarcsa,Kistelek,Kisujszallas,Kisvarda,Komadi,Komarom,Komlo,Kormend,Korosladany,Koszeg,Kozarmisleny,Kunhegyes,Kunszentmarton,Kunszentmiklos,Labatlan,Lajosmizse,Lenti,Letavertes,Letenye,Lorinci,Maglod,Mako,Mandok,Marcali,Martonvasar,Mateszalka,Melykut,Mezobereny,Mezocsat,Mezohegyes,Mezokeresztes,Mezokovesd,Mezotur,Mindszent,Mohacs,Monor,Mor,Morahalom,Nadudvar,Nagyatad,Nagyecsed,Nagyhalasz,Nagykallo,Nagykoros,Nagymaros,Nyekladhaza,Nyergesujfalu,Nyirbator,Nyirmada,Nyirtelek,Ocsa,Orkeny,Oroszlany,Paks,Pannonhalma,Paszto,Pecel,Pecsvarad,Pilisvorosvar,Polgar,Polgardi,Pomaz,Puspokladany,Pusztaszabolcs,Putnok,Racalmas,Rackeve,Rakamaz,Rakoczifalva,Sajoszent,Sandorfalva,Sarbogard,Sarkad,Sarospatak,Sarvar,Satoraljaujhely,Siklos,Simontornya,Soltvadkert,Sumeg,Szabadszallas,Szarvas,Szazhalombatta,Szecseny,Szeghalom,Szentgotthard,Szentlorinc,Szerencs,Szigethalom,Szigetvar,Szikszo,Tab,Tamasi,Tapioszele,Tapolca,Teglas,Tet,Tiszafoldvar,Tiszafured,Tiszakecske,Tiszalok,Tiszaujvaros,Tiszavasvari,Tokaj,Tokol,Tompa,Torokbalint,Torokszentmiklos,Totkomlos,Tura,Turkeve,Ujkigyos,ujszasz,Vamospercs,Varpalota,Vasarosnameny,Vasvar,Vecses,Veresegyhaz,Verpelet,Veszto,Zahony,Zalaszentgrot,Zirc,Zsambek"}, - {name: "Turkish", i: 16, min: 4, max: 10, d: "", m: 0, b: "Yelkaya,Buyrukkaya,Erdemtepe,Alakesen,Baharbeyli,Bozbay,Karaoklu,Altunbey,Yalkale,Yalkut,Akardere,Altayburnu,Esentepe,Okbelen,Derinsu,Alaoba,Yamanbeyli,Aykor,Ekinova,Saztepe,Baharkale,Devrekdibi,Alpseki,Ormanseki,Erkale,Yalbelen,Aytay,Yamanyaka,Altaydelen,Esen,Yedieli,Alpkor,Demirkor,Yediyol,Erdemkaya,Yayburnu,Ganiler,Bayatyurt,Kopuzteke,Aytepe,Deniz,Ayan,Ayazdere,Tepe,Kayra,Ayyaka,Deren,Adatepe,Kalkaneli,Bozkale,Yedidelen,Kocayolu,Sazdere,Bozkesen,Oguzeli,Yayladibi,Uluyol,Altay,Ayvar,Alazyaka,Yaloba,Suyaka,Baltaberi,Poyrazdelen,Eymir,Yediyuva,Kurt,Yeltepe,Oktar,Kara Ok,Ekinberi,Er Yurdu,Eren,Erenler,Ser,Oguz,Asay,Bozokeli,Aykut,Ormanyol,Yazkaya,Kalkanova,Yazbeyli,Dokuz Teke,Bilge,Ertensuyu,Kopuzyuva,Buyrukkut,Akardiken,Aybaray,Aslanbeyli,Altun Kaynak,Atikobasi,Yayla Eli,Kor Tepe,Salureli,Kor Kaya,Aybarberi,Kemerev,Yanaray,Beydileli,Buyrukoba,Yolduman,Tengri Tepe,Dokuzsu,Uzunkor,Erdem Yurdu,Kemer,Korteke,Bozokev,Bozoba,Ormankale,Askale,Oguztoprak,Yolberi,Kumseki,Esenobasi,Turkbelen,Ayazseki,Cereneli,Taykut,Bayramdelen,Beydilyaka,Boztepe,Uluoba,Yelyaka,Ulgardiken,Esensu,Baykale,Cerenkor,Bozyol,Duranoba,Aladuman,Denizli,Bahar,Yarkesen,Dokuzer,Yamankaya,Kocatarla,Alayaka,Toprakeli,Sarptarla,Sarpkoy,Serkaynak,Adayaka,Ayazkaynak,Kopuz,Turk,Kart,Kum,Erten,Buyruk,Yel,Ada,Alazova,Ayvarduman,Buyrukok,Ayvartoprak,Uzuntepe,Binseki,Yedibey,Durankale,Alaztoprak,Sarp Ok,Yaparobasi,Yaytepe,Asberi,Kalkankor,Beydiltepe,Adaberi,Bilgeyolu,Ganiyurt,Alkanteke,Esenerler,Asbey,Erdemkale,Erenkaynak,Oguzkoyu,Ayazoba,Boynuztoprak,Okova,Yaloklu,Sivriberi,Yuladiken,Sazbey,Karakaynak,Kopuzkoyu,Buyrukay,Kocakaya,Tepeduman,Yanarseki,Atikyurt,Esenev,Akarbeyli,Yayteke,Devreksungur,Akseki,Baykut,Kalkandere,Ulgarova,Devrekev,Yulabey,Bayatev,Yazsu,Vuraleli,Sivribeyli,Alaova,Alpobasi,Yalyurt,Elmatoprak,Alazkaynak,Esenay,Ertenev,Salurkor,Ekinok,Yalbey,Yeldere,Ganibay,Altaykut,Baltaboy,Ereli,Ayvarsu,Uzunsaz,Bayeli,Erenyol,Kocabay,Derintay,Ayazyol,Aslanoba,Esenkaynak,Ekinlik,Alpyolu,Alayunt,Bozeski,Erkil,Duransuyu,Yulak,Kut,Dodurga,Kutlubey,Kutluyurt,Boynuz,Alayol,Aybar,Aslaneli,Kemerseki,Baltasuyu,Akarer,Ayvarburnu,Boynuzbeyli,Adasungur,Esenkor,Yamanoba,Toprakkor,Uzunyurt,Sungur,Bozok,Kemerli,Alaz,Demirci,Kartepe"}, - {name: "Berber", i: 17, min: 4, max: 10, d: "s", m: .2, b: "Abkhouch,Adrar,Aeraysh,Afrag,Agadir,Agelmam,Aghmat,Agrakal,Agulmam,Ahaggar,Ait Baha,Ajdir,Akka,Almou,Amegdul,Amizmiz,Amknas,Amlil,Amurakush,Anfa,Annaba,Aousja,Arbat,Arfud,Argoub,Arif,Asfi,Asfru,Ashawen,Assamer,Assif,Awlluz,Ayt Melel,Azaghar,Azila,Azilal,Azmour,Azro,Azrou,Beccar,Beja,Bennour,Benslimane,Berkane,Berrechid,Bizerte,Bjaed,Bouayach,Boudenib,Boufrah,Bouskoura,Boutferda,Darallouch,Dar Bouazza,Darchaabane,Dcheira,Demnat,Denden,Djebel,Djedeida,Drargua,Elhusima,Essaouira,Ezzahra,Fas,Fnideq,Ghezeze,Goubellat,Grisaffen,Guelmim,Guercif,Hammamet,Harrouda,Hdifa,Hoceima,Houara,Idhan,Idurar,Ifendassen,Ifoghas,Ifrane,Ighoud,Ikbir,Imilchil,Imzuren,Inezgane,Irherm,Izoughar,Jendouba,Kacem,Kelibia,Kenitra,Kerrando,Khalidia,Khemisset,Khenifra,Khouribga,Khourigba,Kidal,Korba,Korbous,Lahraouyine,Larache,Leyun,Lqliaa,Manouba,Martil,Mazagan,Mcherga,Mdiq,Megrine,Mellal,Melloul,Midelt,Misur,Mohammedia,Mornag,Mrirt,Nabeul,Nadhour,Nador,Nawaksut,Nefza,Ouarzazate,Ouazzane,Oued Zem,Oujda,Ouladteima,Qsentina,Rades,Rafraf,Safi,Sefrou,Sejnane,Settat,Sijilmassa,Skhirat,Slimane,Somaa,Sraghna,Susa,Tabarka,Tadrart,Taferka,Tafilalt,Tafrawt,Tafza,Tagbalut,Tagerdayt,Taghzut,Takelsa,Taliouine,Tanja,Tantan,Taourirt,Targuist,Taroudant,Tarudant,Tasfelalayt,Tassort,Tata,Tattiwin,Tawnat,Taza,Tazagurt,Tazerka,Tazizawt,Taznakht,Tebourba,Teboursouk,Temara,Testour,Tetouan,Tibeskert,Tifelt,Tijdit,Tinariwen,Tinduf,Tinja,Tittawan,Tiznit,Toubkal,Trables,Tubqal,Tunes,Ultasila,Urup,Wagguten,Wararni,Warzazat,Watlas,Wehran,Wejda,Xamida,Yedder,Youssoufia,Zaghouan,Zahret,Zemmour,Zriba"}, - {name: "Arabic", i: 18, min: 4, max: 9, d: "ae", m: .2, b: "Abha,Ajman,Alabar,Alarjam,Alashraf,Alawali,Albawadi,Albirk,Aldhabiyah,Alduwaid,Alfareeq,Algayed,Alhazim,Alhrateem,Alhudaydah,Alhuwaya,Aljahra,Aljubail,Alkhafah,Alkhalas,Alkhawaneej,Alkhen,Alkhobar,Alkhuznah,Allisafah,Almshaykh,Almurjan,Almuwayh,Almuzaylif,Alnaheem,Alnashifah,Alqah,Alqouz,Alqurayyat,Alradha,Alraqmiah,Alsadyah,Alsafa,Alshagab,Alshuqaiq,Alsilaa,Althafeer,Alwasqah,Amaq,Amran,Annaseem,Aqbiyah,Arafat,Arar,Ardah,Asfan,Ashayrah,Askar,Ayaar,Aziziyah,Baesh,Bahrah,Balhaf,Banizayd,Bidiyah,Bisha,Biyatah,Buqhayq,Burayda,Dafiyat,Damad,Dammam,Dariyah,Dhafar,Dhahran,Dhalkut,Dhurma,Dibab,Doha,Dukhan,Duwaibah,Enaker,Fadhla,Fahaheel,Fanateer,Farasan,Fardah,Fujairah,Ghalilah,Ghar,Ghizlan,Ghomgyah,Ghran,Hadiyah,Haffah,Hajanbah,Hajrah,Haqqaq,Haradh,Hasar,Hawiyah,Hebaa,Hefar,Hijal,Husnah,Huwailat,Huwaitah,Irqah,Isharah,Ithrah,Jamalah,Jarab,Jareef,Jazan,Jeddah,Jiblah,Jihanah,Jilah,Jizan,Joraibah,Juban,Jumeirah,Kamaran,Keyad,Khab,Khaiybar,Khasab,Khathirah,Khawarah,Khulais,Kumzar,Limah,Linah,Madrak,Mahab,Mahalah,Makhtar,Mashwar,Masirah,Masliyah,Mastabah,Mazhar,Medina,Meeqat,Mirbah,Mokhtara,Muharraq,Muladdah,Musaykah,Mushayrif,Musrah,Mussafah,Nafhan,Najran,Nakhab,Nizwa,Oman,Qadah,Qalhat,Qamrah,Qasam,Qosmah,Qurain,Quriyat,Qurwa,Radaa,Rafha,Rahlah,Rakamah,Rasheedah,Rasmadrakah,Risabah,Rustaq,Ryadh,Sabtaljarah,Sadah,Safinah,Saham,Saihat,Salalah,Salmiya,Shabwah,Shalim,Shaqra,Sharjah,Sharurah,Shatifiyah,Shidah,Shihar,Shoqra,Shuwaq,Sibah,Sihmah,Sinaw,Sirwah,Sohar,Suhailah,Sulaibiya,Sunbah,Tabuk,Taif,Taqah,Tarif,Tharban,Thuqbah,Thuwal,Tubarjal,Turaif,Turbah,Tuwaiq,Ubar,Umaljerem,Urayarah,Urwah,Wabrah,Warbah,Yabreen,Yadamah,Yafur,Yarim,Yemen,Yiyallah,Zabid,Zahwah,Zallaq,Zinjibar,Zulumah"}, - {name: "Inuit", i: 19, min: 5, max: 15, d: "alutsn", m: 0, b: "Aaluik,Aappilattoq,Aasiaat,Agissat,Agssaussat,Akuliarutsip,Akunnaaq,Alluitsup,Alluttoq,Amitsorsuaq,Ammassalik,Anarusuk,Anguniartarfik,Annertussoq,Annikitsoq,Apparsuit,Apusiaajik,Arsivik,Arsuk,Atammik,Ateqanaq,Atilissuaq,Attu,Augpalugtoq,Aukarnersuaq,Aumat,Auvilkikavsaup,Avadtlek,Avallersuaq,Bjornesk,Blabaerdalen,Blomsterdalen,Brattalhid,Bredebrae,Brededal,Claushavn,Edderfulegoer,Egger,Eqalugalinnguit,Eqalugarssuit,Eqaluit,Eqqua,Etah,Graah,Hakluyt,Haredalen,Hareoen,Hundeo,Igaliku,Igdlorssuit,Igdluluarssuk,Iginniafik,Ikamiut,Ikarissat,Ikateq,Ikermiut,Ikermoissuaq,Ikorfarssuit,Ilimanaq,Illorsuit,Illunnguit,Iluileq,Ilulissat,Imaarsivik,Imartunarssuk,Immikkoortukajik,Innaarsuit,Inneruulalik,Inussullissuaq,Iperaq,Ippik,Iqek,Isortok,Isungartussoq,Itileq,Itissaalik,Itivdleq,Ittit,Ittoqqortoormiit,Ivingmiut,Ivittuut,Kanajoorartuut,Kangaamiut,Kangeq,Kangerluk,Kangerlussuaq,Kanglinnguit,Kapisillit,Kekertamiut,Kiatak,Kiataussaq,Kigatak,Kinaussak,Kingittorsuaq,Kitak,Kitsissuarsuit,Kitsissut,Klenczner,Kook,Kraulshavn,Kujalleq,Kullorsuaq,Kulusuk,Kuurmiit,Kuusuaq,Laksedalen,Maniitsoq,Marrakajik,Mattaangassut,Mernoq,Mittivakkat,Moriusaq,Myggbukta,Naajaat,Nangissat,Nanuuseq,Nappassoq,Narsarmijt,Narsarsuaq,Narssaq,Nasiffik,Natsiarsiorfik,Naujanguit,Niaqornaarsuk,Niaqornat,Nordfjordspasset,Nugatsiaq,Nunarssit,Nunarsuaq,Nunataaq,Nunatakavsaup,Nutaarmiut,Nuugaatsiaq,Nuuk,Nuukullak,Olonkinbyen,Oodaaq,Oqaatsut,Oqaitsunguit,Oqonermiut,Paagussat,Paamiut,Paatuut,Palungataq,Pamialluk,Perserajoq,Pituffik,Puugutaa,Puulkuip,Qaanaq,Qaasuitsup,Qaersut,Qajartalik,Qallunaat,Qaneq,Qaqortok,Qasigiannguit,Qassimiut,Qeertartivaq,Qeqertaq,Qeqertasussuk,Qeqqata,Qernertoq,Qernertunnguit,Qianarreq,Qingagssat,Qoornuup,Qorlortorsuaq,Qullikorsuit,Qunnerit,Qutdleq,Ravnedalen,Ritenbenk,Rypedalen,Saarloq,Saatorsuaq,Saattut,Salliaruseq,Sammeqqat,Sammisoq,Sanningassoq,Saqqaq,Saqqarlersuaq,Saqqarliit,Sarfannguit,Sattiaatteq,Savissivik,Serfanguaq,Sermersooq,Sermiligaaq,Sermilik,Sermitsiaq,Simitakaja,Simiutaq,Singamaq,Siorapaluk,Sisimiut,Sisuarsuit,Sullorsuaq,Suunikajik,Sverdrup,Taartoq,Takiseeq,Tasirliaq,Tasiusak,Tiilerilaaq,Timilersua,Timmiarmiut,Tukingassoq,Tussaaq,Tuttulissuup,Tuujuk,Uiivaq,Uilortussoq,Ujuaakajiip,Ukkusissat,Upernavik,Uttorsiutit,Uumannaq,Uunartoq,Uvkusigssat,Ymer"}, - {name: "Basque", i: 20, min: 4, max: 11, d: "r", m: .1, b: "Agurain,Aia,Aiara,Albiztur,Alkiza,Altzaga,Amorebieta,Amurrio,Andoain,Anoeta,Antzuola,Arakaldo,Arantzazu,Arbatzegi,Areatza,Arratzua,Arrieta,Artea,Artziniega,Asteasu,Astigarraga,Ataun,Atxondo,Aulesti,Azkoitia,Azpeitia,Bakio,Baliarrain,Barakaldo,Barrika,Barrundia,Basauri,Beasain,Bedia,Beizama,Belauntza,Berastegi,Bergara,Bermeo,Bernedo,Berriatua,Berriz,Bidania,Bilar,Bilbao,Busturia,Deba,Derio,Donostia,Dulantzi,Durango,Ea,Eibar,Elantxobe,Elduain,Elgeta,Elgoibar,Elorrio,Erandio,Ergoitia,Ermua,Errenteria,Errezil,Eskoriatza,Eskuernaga,Etxebarri,Etxebarria,Ezkio,Forua,Gabiria,Gaintza,Galdakao,Gamiz,Garai,Gasteiz,Gatzaga,Gaubea,Gautegiz,Gaztelu,Gernika,Gerrikaitz,Getaria,Getxo,Gizaburuaga,Goiatz,Gorliz,Gorriaga,Harana,Hernani,Hondarribia,Ibarra,Ibarrangelu,Idiazabal,Iekora,Igorre,Ikaztegieta,Irun,Irura,Iruraiz,Itsaso,Itsasondo,Iurreta,Izurtza,Jatabe,Kanpezu,Karrantza,Kortezubi,Kripan,Kuartango,Lanestosa,Lantziego,Larrabetzu,Lasarte,Laukiz,Lazkao,Leaburu,Legazpi,Legorreta,Legutio,Leintz,Leioa,Lekeitio,Lemoa,Lemoiz,Leza,Lezama,Lezo,Lizartza,Maeztu,Mallabia,Manaria,Markina,Maruri,Menaka,Mendaro,Mendata,Mendexa,Morga,Mundaka,Mungia,Munitibar,Murueta,Muskiz,Mutiloa,Mutriku,Nabarniz,Oiartzun,Oion,Okondo,Olaberria,Onati,Ondarroa,Ordizia,Orendain,Orexa,Oria,Orio,Ormaiztegi,Orozko,Ortuella,Otegi,Otxandio,Pasaia,Plentzia,Santurtzi,Sestao,Sondika,Soraluze,Sukarrieta,Tolosa,Trapagaran,Turtzioz,Ubarrundia,Ubide,Ugao,Urdua,Urduliz,Urizaharra,Urkabustaiz,Urnieta,Urretxu,Usurbil,Xemein,Zabaleta,Zaia,Zaldibar,Zambrana,Zamudio,Zaratamo,Zarautz,Zeberio,Zegama,Zerain,Zestoa,Zierbena,Zigoitia,Ziortza,Zuia,Zumaia,Zumarraga"}, - {name: "Nigerian", i: 21, min: 4, max: 10, d: "", m: .3, b: "Abadogo,Abafon,Adealesu,Adeto,Adyongo,Afaga,Afamju,Agigbigi,Agogoke,Ahute,Aiyelaboro,Ajebe,Ajola,Akarekwu,Akunuba,Alawode,Alkaijji,Amangam,Amgbaye,Amtasa,Amunigun,Animahun,Anyoko,Arapagi,Asande,Awgbagba,Awhum,Awodu,Babateduwa,Bandakwai,Bangdi,Bilikani,Birnindodo,Braidu,Bulakawa,Buriburi,Cainnan,Chakum,Chondugh,Dagwarga,Darpi,Dokatofa,Dozere,Ebelibri,Efem,Ekoku,Ekpe,Ewhoeviri,Galea,Gamen,Ganjin,Gantetudu,Gargar,Garinbode,Gbure,Gerti,Gidan,Gitabaremu,Giyagiri,Giyawa,Gmawa,Golakochi,Golumba,Gunji,Gwambula,Gwodoti,Hayinlere,Hayinmaialewa,Hirishi,Hombo,Ibefum,Iberekodo,Icharge,Idofin,Idofinoka,Igbogo,Ijoko,Ijuwa,Ikawga,Ikhin,Ikpakidout,Ikpeoniong,Imuogo,Ipawo,Ipinlerere,Isicha,Itakpa,Jangi,Jare,Jataudakum,Jaurogomki,Jepel,Kafinmalama,Katab,Katanga,Katinda,Katirije,Kaurakimba,Keffinshanu,Kellumiri,Kiagbodor,Kirbutu,Kita,Kogogo,Kopje,Korokorosei,Kotoku,Kuata,Kujum,Kukau,Kunboon,Kuonubogbene,Kurawe,Kushinahu,Kwaramakeri,Ladimeji,Lafiaro,Lahaga,Laindebajanle,Laindegoro,Lakati,Litenswa,Maba,Madarzai,Maianita,Malikansaa,Mata,Megoyo,Meku,Miama,Modi,Mshi,Msugh,Muduvu,Murnachehu,Namnai,Ndamanma,Ndiwulunbe,Ndonutim,Ngbande,Nguengu,Ntoekpe,Nyajo,Nyior,Odajie,Ogbaga,Ogultu,Ogunbunmi,Ojopode,Okehin,Olugunna,Omotunde,Onipede,Onma,Orhere,Orya,Otukwang,Otunade,Rampa,Rimi,Rugan,Rumbukawa,Sabiu,Sangabama,Sarabe,Seboregetore,Shafar,Shagwa,Shata,Shengu,Sokoron,Sunnayu,Tafoki,Takula,Talontan,Tarhemba,Tayu,Ter,Timtim,Timyam,Tindirke,Tokunbo,Torlwam,Tseakaadza,Tseanongo,Tsebeeve,Tsepaegh,Tuba,Tumbo,Tungalombo,Tunganyakwe,Uhkirhi,Umoru,Umuabai,Umuajuju,Unchida,Ungua,Unguwar,Unongo,Usha,Utongbo,Vembera,Wuro,Yanbashi,Yanmedi,Yoku,Zarunkwari,Zilumo,Zulika"}, - {name: "Celtic", i: 22, min: 4, max: 12, d: "nld", m: 0, b: "Aberaman,Aberangell,Aberarth,Aberavon,Aberbanc,Aberbargoed,Aberbeeg,Abercanaid,Abercarn,Abercastle,Abercegir,Abercraf,Abercregan,Abercych,Abercynon,Aberdare,Aberdaron,Aberdaugleddau,Aberdeen,Aberdulais,Aberdyfi,Aberedw,Abereiddy,Abererch,Abereron,Aberfan,Aberffraw,Aberffrwd,Abergavenny,Abergele,Aberglasslyn,Abergorlech,Abergwaun,Abergwesyn,Abergwili,Abergwynfi,Abergwyngregyn,Abergynolwyn,Aberhafesp,Aberhonddu,Aberkenfig,Aberllefenni,Abermain,Abermaw,Abermorddu,Abermule,Abernant,Aberpennar,Aberporth,Aberriw,Abersoch,Abersychan,Abertawe,Aberteifi,Aberthin,Abertillery,Abertridwr,Aberystwyth,Achininver,Afonhafren,Alisaha,Anfosadh,Antinbhearmor,Ardenna,Attacon,Banwen,Beira,Bhrura,Bleddfa,Boioduro,Bona,Boskyny,Boslowenpolbrogh,Boudobriga,Bravon,Brigant,Briganta,Briva,Brosnach,Caersws,Cambodunum,Cambra,Caracta,Catumagos,Centobriga,Ceredigion,Chalain,Chearbhallain,Chlasaigh,Chormaic,Cuileannach,Dinn,Diwa,Dubingen,Duibhidighe,Duro,Ebora,Ebruac,Eburodunum,Eccles,Egloskuri,Eighe,Eireann,Elerghi,Ferkunos,Fhlaithnin,Gallbhuaile,Genua,Ghrainnse,Gwyles,Heartsease,Hebron,Hordh,Inbhear,Inbhir,Inbhirair,Innerleithen,Innerleven,Innerwick,Inver,Inveraldie,Inverallan,Inveralmond,Inveramsay,Inveran,Inveraray,Inverarnan,Inverbervie,Inverclyde,Inverell,Inveresk,Inverfarigaig,Invergarry,Invergordon,Invergowrie,Inverhaddon,Inverkeilor,Inverkeithing,Inverkeithney,Inverkip,Inverleigh,Inverleith,Inverloch,Inverlochlarig,Inverlochy,Invermay,Invermoriston,Inverness,Inveroran,Invershin,Inversnaid,Invertrossachs,Inverugie,Inveruglas,Inverurie,Iubhrach,Karardhek,Kilninver,Kirkcaldy,Kirkintilloch,Krake,Lanngorrow,Latense,Leming,Lindomagos,Llanaber,Llandidiwg,Llandyrnog,Llanfarthyn,Llangadwaldr,Llansanwyr,Lochinver,Lugduno,Magoduro,Mheara,Monmouthshire,Nanshiryarth,Narann,Novioduno,Nowijonago,Octoduron,Penning,Pheofharain,Ponsmeur,Raithin,Ricomago,Rossinver,Salodurum,Seguia,Sentica,Theorsa,Tobargeal,Trealaw,Trefesgob,Trewedhenek,Trewythelan,Tuaisceart,Uige,Vitodurum,Windobona"}, - {name: "Mesopotamian", i: 23, min: 4, max: 9, d: "srpl", m: .1, b: "Adab,Adamndun,Adma,Admatum,Agrab,Akkad,Akshak,Amnanum,Andarig,Anshan,Apiru,Apum,Arantu,Arbid,Arpachiyah,Arpad,Arrapha,Ashlakka,Assur,Awan,Babilim,Bad-Tibira,Balawat,Barsip,Birtu,Bit-Bunakki,Borsippa,Chuera,Dashrah,Der,Dilbat,Diniktum,Doura,Dur-Kurigalzu,Dur-Sharrukin,Dur-Untash,Dûr-gurgurri,Ebla,Ekallatum,Ekalte,Emar,Erbil,Eresh,Eridu,Eshnunn,Eshnunna,Gargamish,Gasur,Gawra,Gibil,Girsu,Gizza,Habirun,Habur,Hadatu,Hakkulan,Halab,Halabit,Hamazi,Hamoukar,Haradum,Harbidum,Harran,Harranu,Hassuna,Hatarikka,Hatra,Hissar,Hiyawa,Hormirzad,Ida-Maras,Idamaraz,Idu,Imerishu,Imgur-Enlil,Irisagrig,Irnina,Irridu,Isin,Issinnitum,Iturungal,Izubitum,Jarmo,Jemdet,Kabnak,Kadesh,Kahat,Kalhu,Kar-Shulmanu-Asharedu,Kar-Tukulti-Ninurta,Kar-shulmanu-asharedu,Karana,Karatepe,Kartukulti,Kazallu,Kesh,Kidsha,Kinza,Kish,Kisiga,Kisurra,Kuara,Kurda,Kurruhanni,Kutha,Lagaba,Lagash,Larak,Larsa,Leilan,Malgium,Marad,Mardaman,Mari,Marlik,Mashkan,Mashkan-shapir,Matutem,Me-Turan,Meliddu,Mumbaqat,Nabada,Nagar,Nanagugal,Nerebtum,Nigin,Nimrud,Nina,Nineveh,Ninua,Nippur,Niru,Niya,Nuhashe,Nuhasse,Nuzi,Puzrish-Dagan,Qalatjarmo,Qatara,Qatna,Qattunan,Qidshu,Rapiqum,Rawda,Sagaz,Shaduppum,Shaggaratum,Shalbatu,Shanidar,Sharrukin,Shawwan,Shehna,Shekhna,Shemshara,Shibaniba,Shubat-Enlil,Shurkutir,Shuruppak,Shusharra,Shushin,Sikan,Sippar,Sippar-Amnanum,Sippar-sha-Annunitum,Subatum,Susuka,Tadmor,Tarbisu,Telul,Terqa,Tirazish,Tisbon,Tuba,Tushhan,Tuttul,Tutub,Ubaid,Umma,Ur,Urah,Urbilum,Urkesh,Ursa'um,Uruk,Urum,Uzarlulu,Warka,Washukanni,Zabalam,Zarri-Amnan"}, - {name: "Iranian", i: 24, min: 5, max: 11, d: "", m: .1, b: "Abali,Abrisham,Absard,Abuzeydabad,Afus,Alavicheh,Alikosh,Amol,Anarak,Anbar,Andisheh,Anshan,Aran,Ardabil,Arderica,Ardestan,Arjomand,Asgaran,Asgharabad,Ashian,Awan,Babajan,Badrud,Bafran,Baghestan,Baghshad,Bahadoran,Baharan Shahr,Baharestan,Bakun,Bam,Baqershahr,Barzok,Bastam,Behistun,Bitistar,Bumahen,Bushehr,Chadegan,Chahardangeh,Chamgardan,Chermahin,Choghabonut,Chugan,Damaneh,Damavand,Darabgard,Daran,Dastgerd,Dehaq,Dehaqan,Dezful,Dizicheh,Dorcheh,Dowlatabad,Duruntash,Ecbatana,Eslamshahr,Estakhr,Ezhiyeh,Falavarjan,Farrokhi,Fasham,Ferdowsieh,Fereydunshahr,Ferunabad,Firuzkuh,Fuladshahr,Ganjdareh,Ganzak,Gaz,Geoy,Godin,Goldasht,Golestan,Golpayegan,Golshahr,Golshan,Gorgab,Guged,Habibabad,Hafshejan,Hajjifiruz,Hana,Harand,Hasanabad,Hasanlu,Hashtgerd,Hecatompylos,Hormirzad,Imanshahr,Isfahan,Jandaq,Javadabad,Jiroft,Jowsheqan ,Jowzdan,Kabnak,Kahrizak,Kahriz Sang,Kangavar,Karaj,Karkevand,Kashan,Kelishad,Kermanshah,Khaledabad,Khansar,Khorramabad,Khur,Khvorzuq,Kilan,Komeh,Komeshcheh,Konar,Kuhpayeh,Kul,Kushk,Lavasan,Laybid,Liyan,Lyan,Mahabad,Mahallat,Majlesi,Malard,Manzariyeh,Marlik,Meshkat,Meymeh,Miandasht,Mish,Mobarakeh,Nahavand,Nain,Najafabad,Naqshe,Narezzash,Nasimshahr,Nasirshahr,Nasrabad,Natanz,Neyasar,Nikabad,Nimvar,Nushabad,Pakdasht,Parand,Pardis,Parsa,Pasargadai,Patigrabana,Pir Bakran,Pishva,Qahderijan,Qahjaverestan,Qamsar,Qarchak,Qods,Rabat,Ray-shahr,Rezvanshahr,Rhages,Robat Karim,Rozveh,Rudehen,Sabashahr,Safadasht,Sagzi,Salehieh,Sandal,Sarvestan,Sedeh,Sefidshahr,Semirom,Semnan,Shadpurabad,Shah,Shahdad,Shahedshahr,Shahin,Shahpour,Shahr,Shahreza,Shahriar,Sharifabad,Shemshak,Shiraz,Shushan,Shushtar,Sialk,Sin,Sukhteh,Tabas,Tabriz,Takhte,Talkhuncheh,Talli,Tarq,Temukan,Tepe,Tiran,Tudeshk,Tureng,Urmia,Vahidieh,Vahrkana,Vanak,Varamin,Varnamkhast,Varzaneh,Vazvan,Yahya,Yarim,Yasuj,Zarrin Shahr,Zavareh,Zayandeh,Zazeran,Ziar,Zibashahr,Zranka"}, - {name: "Hawaiian", i: 25, min: 5, max: 10, d: "auo", m: 1, b: "Aapueo,Ahoa,Ahuakaio,Ahupau,Alaakua,Alae,Alaeloa,Alamihi,Aleamai,Alena,Alio,Aupokopoko,Halakaa,Haleu,Haliimaile,Hamoa,Hanakaoo,Hanaulu,Hanawana,Hanehoi,Haou,Hikiaupea,Hokuula,Honohina,Honokahua,Honokeana,Honokohau,Honolulu,Honomaele,Hononana,Honopou,Hoolawa,Huelo,Kaalaea,Kaapahu,Kaeo,Kahalehili,Kahana,Kahuai,Kailua,Kainehe,Kakalahale,Kakanoni,Kalenanui,Kaleoaihe,Kalialinui,Kalihi,Kalimaohe,Kaloi,Kamani,Kamehame,Kanahena,Kaniaula,Kaonoulu,Kapaloa,Kapohue,Kapuaikini,Kapunakea,Kauau,Kaulalo,Kaulanamoa,Kauluohana,Kaumakani,Kaumanu,Kaunauhane,Kaupakulua,Kawaloa,Keaa,Keaaula,Keahua,Keahuapono,Kealahou,Keanae,Keauhou,Kelawea,Keokea,Keopuka,Kikoo,Kipapa,Koakupuna,Koali,Kolokolo,Kopili,Kou,Kualapa,Kuhiwa,Kuholilea,Kuhua,Kuia,Kuikui,Kukoae,Kukohia,Kukuiaeo,Kukuipuka,Kukuiula,Kulahuhu,Lapakea,Lapueo,Launiupoko,Lole,Maalo,Mahinahina,Mailepai,Makaakini,Makaalae,Makaehu,Makaiwa,Makaliua,Makapipi,Makapuu,Maluaka,Manawainui,Mehamenui,Moalii,Moanui,Mohopili,Mokae,Mokuia,Mokupapa,Mooiki,Mooloa,Moomuku,Muolea,Nakaaha,Nakalepo,Nakaohu,Nakapehu,Nakula,Napili,Niniau,Nuu,Oloewa,Olowalu,Omaopio,Onau,Onouli,Opaeula,Opana,Opikoula,Paakea,Paeahu,Paehala,Paeohi,Pahoa,Paia,Pakakia,Palauea,Palemo,Paniau,Papaaea,Papaanui,Papaauhau,Papaka,Papauluana,Pauku,Paunau,Pauwalu,Pauwela,Pohakanele,Polaiki,Polanui,Polapola,Poopoo,Poponui,Poupouwela,Puahoowali,Puakea,Puako,Pualaea,Puehuehu,Pueokauiki,Pukaauhuhu,Pukuilua,Pulehu,Puolua,Puou,Puuhaehae,Puuiki,Puuki,Puulani,Puunau,Puuomaile,Uaoa,Uhao,Ukumehame,Ulaino,Ulumalu,Wahikuli,Waianae,Waianu,Waiawa,Waiehu,Waieli,Waikapu,Wailamoa,Wailaulau,Wainee,Waiohole,Waiohonu,Waiohuli,Waiokama,Waiokila,Waiopai,Waiopua,Waipao,Waipionui,Waipouli"}, - {name: "Karnataka", i: 26, min: 5, max: 11, d: "tnl", m: 0, b: "Adityapatna,Adyar,Afzalpur,Aland,Alnavar,Alur,Ambikanagara,Anekal,Ankola,Annigeri,Arkalgud,Arsikere,Athni,Aurad,Badami,Bagalkot,Bagepalli,Bail,Bajpe,Bangalore,Bangarapet,Bankapura,Bannur,Bantval,Basavakalyan,Basavana,Belgaum,Beltangadi,Belur,Bhadravati,Bhalki,Bhatkal,Bhimarayanagudi,Bidar,Bijapur,Bilgi,Birur,Bommasandra,Byadgi,Challakere,Chamarajanagar,Channagiri,Channapatna,Channarayapatna,Chik,Chikmagalur,Chiknayakanhalli,Chikodi,Chincholi,Chintamani,Chitapur,Chitgoppa,Chitradurga,Dandeli,Dargajogihalli,Devadurga,Devanahalli,Dod,Donimalai,Gadag,Gajendragarh,Gangawati,Gauribidanur,Gokak,Gonikoppal,Gubbi,Gudibanda,Gulbarga,Guledgudda,Gundlupet,Gurmatkal,Haliyal,Hangal,Harapanahalli,Harihar,Hassan,Hatti,Haveri,Hebbagodi,Heggadadevankote,Hirekerur,Holalkere,Hole,Homnabad,Honavar,Honnali,Hoovina,Hosakote,Hosanagara,Hosdurga,Hospet,Hubli,Hukeri,Hungund,Hunsur,Ilkal,Indi,Jagalur,Jamkhandi,Jevargi,Jog,Kadigenahalli,Kadur,Kalghatgi,Kamalapuram,Kampli,Kanakapura,Karkal,Karwar,Khanapur,Kodiyal,Kolar,Kollegal,Konnur,Koppa,Koppal,Koratagere,Kotturu,Krishnarajanagara,Krishnarajasagara,Krishnarajpet,Kudchi,Kudligi,Kudremukh,Kumta,Kundapura,Kundgol,Kunigal,Kurgunta,Kushalnagar,Kushtagi,Lakshmeshwar,Lingsugur,Londa,Maddur,Madhugiri,Madikeri,Mahalingpur,Malavalli,Mallar,Malur,Mandya,Mangalore,Manvi,Molakalmuru,Mudalgi,Mudbidri,Muddebihal,Mudgal,Mudhol,Mudigere,Mulbagal,Mulgund,Mulki,Mulur,Mundargi,Mundgod,Munirabad,Mysore,Nagamangala,Nanjangud,Narasimharajapura,Naregal,Nargund,Navalgund,Nipani,Pandavapura,Pavagada,Piriyapatna,Pudu,Puttur,Rabkavi,Raichur,Ramanagaram,Ramdurg,Ranibennur,Raybag,Robertson,Ron,Sadalgi,Sagar,Sakleshpur,Saligram,Sandur,Sankeshwar,Saundatti,Savanur,Sedam,Shahabad,Shahpur,Shaktinagar,Shiggaon,Shikarpur,Shirhatti,Shorapur,Shrirangapattana,Siddapur,Sidlaghatta,Sindgi,Sindhnur,Sira,Siralkoppa,Sirsi,Siruguppa,Somvarpet,Sorab,Sringeri,Srinivaspur,Sulya,Talikota,Tarikere,Tekkalakote,Terdal,Thumbe,Tiptur,Tirthahalli,Tirumakudal,Tumkur,Turuvekere,Udupi,Vijayapura,Wadi,Yadgir,Yelandur,Yelbarga,Yellapur,Yenagudde"}, - {name: "Quechua", i: 27, min: 6, max: 12, d: "l", m: 0, b: "Alpahuaycco,Anchihuay,Anqea,Apurimac,Arequipa,Atahuallpa,Atawalpa,Atico,Ayacucho,Ayahuanco,Ayllu,Cajamarca,Canayre,Canchacancha,Carapo,Carhuac,Carhuacatac,Cashan,Caullaraju,Caxamalca,Cayesh,Ccahuasno,Ccarhuacc,Ccopayoc,Chacchapunta,Chacraraju,Challhuamayo,Champara,Chanchan,Chekiacraju,Chillihua,Chinchey,Chontah,Chopicalqui,Chucuito,Chuito,Chullo,Chumpi,Chuncho,Chupahuacho,Chuquiapo,Chuquisaca,Churup,Cocapata,Cochabamba,Cojup,Collota,Conococha,Corihuayrachina,Cuchoquesera,Cusichaca,Haika,Hanpiq,Hatun,Haywarisqa,Huaca,Huachinga,Hualcan,Hualchancca,Huamanga,Huamashraju,Huancarhuas,Huandoy,Huantsan,Huanupampa,Huarmihuanusca,Huascaran,Huaylas,Huayllabamba,Huayrana,Huaytara,Huichajanca,Huinayhuayna,Huinche,Huinioch,Illiasca,Intipunku,Iquicha,Ishinca,Jahuacocha,Jirishanca,Juli,Jurau,Kakananpunta,Kamasqa,Karpay,Kausay,Khuya,Kuelap,Lanccochayocc,Llaca,Llactapata,Llanganuco,Llaqta,Lloqllasca,Llupachayoc,Luricocha,Machu,Mallku,Matarraju,Mechecc,Mikhuy,Milluacocha,Morochuco,Munay,Ocshapalca,Ollantaytambo,Oroccahua,Oronccoy,Oyolo,Pacamayo,Pacaycasa,Paccharaju,Pachacamac,Pachakamaq,Pachakuteq,Pachakuti,Pachamama,Paititi,Pajaten,Palcaraju,Pallccas,Pampa,Panaka,Paqarina,Paqo,Parap,Paria,Patahuasi,Patallacta,Patibamba,Pisac,Pisco,Pongos,Pucacolpa,Pucahirca,Pucaranra,Pumatambo,Puscanturpa,Putaca,Puyupatamarca,Qawaq,Qayqa,Qochamoqo,Qollana,Qorihuayrachina,Qorimoqo,Qotupuquio,Quenuaracra,Queshque,Quillcayhuanca,Quillya,Quitaracsa,Quitaraju,Qusqu,Rajucolta,Rajutakanan,Rajutuna,Ranrahirca,Ranrapalca,Raria,Rasac,Rimarima,Riobamba,Runkuracay,Rurec,Sacsa,Sacsamarca,Saiwa,Sarapo,Sayacmarca,Sayripata,Sinakara,Sonccopa,Taripaypacha,Taulliraju,Tawantinsuyu,Taytanchis,Tiwanaku,Tocllaraju,Tsacra,Tuco,Tucubamba,Tullparaju,Tumbes,Uchuraccay,Uchuraqay,Ulta,Urihuana,Uruashraju,Vallunaraju,Vilcabamba,Wacho,Wankawillka,Wayra,Yachay,Yahuarraju,Yanamarey,Yanaqucha,Yanesha,Yerupaja"}, - {name: "Swahili", i: 28, min: 4, max: 9, d: "", m: 0, b: "Abim,Adjumani,Alebtong,Amolatar,Amuru,Apac,Arua,Arusha,Babati,Baragoi,Bombo,Budaka,Bugembe,Bugiri,Buikwe,Bukedea,Bukoba,Bukomansimbi,Bukungu,Buliisa,Bundibugyo,Bungoma,Busembatya,Bushenyi,Busia,Busolwe,Butaleja,Butambala,Butere,Buwenge,Buyende,Dadaab,Dodoma,Dokolo,Eldoret,Elegu,Emali,Embu,Entebbe,Garissa,Gede,Gulu,Handeni,Hima,Hoima,Hola,Ibanda,Iganga,Iringa,Isingiro,Isiolo,Jinja,Kaabong,Kabuyanda,Kabwohe,Kagadi,Kajiado,Kakinga,Kakiri,Kakuma,Kalangala,Kaliro,Kalongo,Kalungu,Kampala,Kamwenge,Kanungu,Kapchorwa,Kasese,Kasulu,Katakwi,Kayunga,Keroka,Kiambu,Kibaale,Kibaha,Kibingo,Kibwezi,Kigoma,Kihiihi,Kilifi,Kiruhura,Kiryandongo,Kisii,Kisoro,Kisumu,Kitale,Kitgum,Kitui,Koboko,Korogwe,Kotido,Kumi,Kyazanga,Kyegegwa,Kyenjojo,Kyotera,Lamu,Langata,Lindi,Lodwar,Lokichoggio,Londiani,Loyangalani,Lugazi,Lukaya,Luweero,Lwakhakha,Lwengo,Lyantonde,Machakos,Mafinga,Makambako,Makindu,Malaba,Malindi,Manafwa,Mandera,Marsabit,Masaka,Masindi,Masulita,Matugga,Mayuge,Mbale,Mbarara,Mbeya,Meru,Mitooma,Mityana,Mombasa,Morogoro,Moroto,Moyale,Moyo,Mpanda,Mpigi,Mpondwe,Mtwara,Mubende,Mukono,Muranga,Musoma,Mutomo,Mutukula,Mwanza,Nagongera,Nairobi,Naivasha,Nakapiripirit,Nakaseke,Nakasongola,Nakuru,Namanga,Namayingo,Namutumba,Nansana,Nanyuki,Narok,Naromoru,Nebbi,Ngora,Njeru,Njombe,Nkokonjeru,Ntungamo,Nyahururu,Nyeri,Oyam,Pader,Paidha,Pakwach,Pallisa,Rakai,Ruiru,Rukungiri,Rwimi,Sanga,Sembabule,Shimoni,Shinyanga,Singida,Sironko,Songea,Soroti,Ssabagabo,Sumbawanga,Tabora,Takaungu,Tanga,Thika,Tororo,Tunduma,Vihiga,Voi,Wajir,Wakiso,Watamu,Webuye,Wobulenzi,Wote,Wundanyi,Yumbe,Zanzibar"}, - {name: "Vietnamese", i: 29, min: 3, max: 12, d: "", m: 1, b: "An Giang,Anh Son,An Khe,An Nhon,Ayun Pa,Bac Giang,Bac Kan,Bac Lieu,Bac Ninh,Ba Don,Bao Loc,Ba Ria,Ba Ria-Vung Tau,Ba Thuoc,Ben Cat,Ben Tre,Bien Hoa,Bim Son,Binh Dinh,Binh Duong,Binh Long,Binh Minh,Binh Phuoc,Binh Thuan,Buon Ho,Buon Ma Thuot,Cai Lay,Ca Mau,Cam Khe,Cam Pha,Cam Ranh,Cam Thuy,Can Tho,Cao Bang,Cao Lanh,Cao Phong,Chau Doc,Chi Linh,Con Cuong,Cua Lo,Da Bac,Dak Lak,Da Lat,Da Nang,Di An,Dien Ban,Dien Bien,Dien Bien Phu,Dien Chau,Do Luong,Dong Ha,Dong Hoi,Dong Trieu,Duc Pho,Duyen Hai,Duy Tien,Gia Lai,Gia Nghia,Gia Rai,Go Cong,Ha Giang,Ha Hoa,Hai Duong,Hai Phong,Ha Long,Ha Nam,Ha Noi,Ha Tinh,Ha Trung,Hau Giang,Hoa Binh,Hoang Mai,Hoa Thanh,Ho Chi Minh,Hoi An,Hong Linh,Hong Ngu,Hue,Hung Nguyen,Hung Yen,Huong Thuy,Huong Tra,Khanh Hoa,Kien Tuong,Kim Boi,Kinh Mon,Kon Tum,Ky Anh,Ky Son,Lac Son,Lac Thuy,La Gi,Lai Chau,Lam Thao,Lang Chanh,Lang Son,Lao Cai,Long An,Long Khanh,Long My,Long Xuyen,Luong Son,Mai Chau,Mong Cai,Muong Lat,Muong Lay,My Hao,My Tho,Nam Dan,Nam Dinh,Nga Bay,Nga Nam,Nga Son,Nghe An,Nghia Dan,Nghia Lo,Nghi Loc,Nghi Son,Ngoc Lac,Nha Trang,Nhu Thanh,Nhu Xuan,Ninh Binh,Ninh Hoa,Nong Cong,Phan Rang Thap Cham,Phan Thiet,Pho Yen,Phu Ly,Phu My,Phu Ninh,Phuoc Long,Phu Tho,Phu Yen,Pleiku,Quang Binh,Quang Nam,Quang Ngai,Quang Ninh,Quang Tri,Quang Xuong,Quang Yen,Quan Hoa,Quan Son,Que Phong,Quy Chau,Quy Hop,Quynh Luu,Quy Nhon,Rach Gia,Sa Dec,Sai Gon,Sam Son,Sa Pa,Soc Trang,Song Cau,Song Cong,Son La,Son Tay,Tam Diep,Tam Ky,Tan An,Tan Chau,Tan Ky,Tan Lac,Tan Son,Tan Uyen,Tay Ninh,Thach Thanh,Thai Binh,Thai Hoa,Thai Nguyen,Thanh Chuong,Thanh Hoa,Thieu Hoa,Thuan An,Thua Thien-Hue,Thu Dau Mot,Thu Duc,Thuong Xuan,Tien Giang,Trang Bang,Tra Vinh,Trieu Son,Tu Son,Tuyen Quang,Tuy Hoa,Uong Bi,Viet Tri,Vinh,Vinh Chau,Vinh Loc,Vinh Long,Vinh Yen,Vi Thanh,Vung Tau,Yen Bai,Yen Dinh,Yen Thanh,Yen Thuy"}, - {name: "Cantonese", i: 30, min: 5, max: 11, d: "", m: 0, b: "Chaiwan,Chingchung,Chinghoi,Chingsen,Chingshing,Chiunam,Chiuon,Chiuyeung,Chiyuen,Choihung,Chuehoi,Chuiman,Chungfu,Chungsan,Chunguktsuen,Dakhing,Daopo,Daumun,Dingwu,Dinpak,Donggun,Dongyuen,Duenchau,Fachau,Fanling,Fatgong,Fatshan,Fotan,Fuktien,Fumun,Funggong,Funghoi,Fungshun,Fungtei,Gamtin,Gochau,Goming,Gonghoi,Gongshing,Goyiu,Hanghau,Hangmei,Hengon,Heungchau,Heunggong,Heungkiu,Hingning,Hohfuktong,Hoichue,Hoifung,Hoiping,Hokong,Hokshan,Hoyuen,Hunghom,Hungshuikiu,Jiuling,Kamsheung,Kamwan,Kaulongtong,Keilun,Kinon,Kinsang,Kityeung,Kongmun,Kukgong,Kwaifong,Kwaihing,Kwongchau,Kwongling,Kwongming,Kwuntong,Laichikok,Laiking,Laiwan,Lamtei,Lamtin,Leitung,Leungking,Limkong,Linping,Linshan,Loding,Lokcheong,Lokfu,Longchuen,Longgong,Longmun,Longping,Longwa,Longwu,Lowu,Luichau,Lukfung,Lukho,Lungmun,Macheung,Maliushui,Maonshan,Mauming,Maunam,Meifoo,Mingkum,Mogong,Mongkok,Muichau,Muigong,Muiyuen,Naiwai,Namcheong,Namhoi,Namhong,Namsha,Nganwai,Ngautaukok,Ngchuen,Ngwa,Onting,Pakwun,Paotoishan,Pingshan,Pingyuen,Poklo,Pongon,Poning,Potau,Puito,Punyue,Saiwanho,Saiyingpun,Samshing,Samshui,Samtsen,Samyuenlei,Sanfung,Sanhing,Sanhui,Sanwai,Seiwui,Shamshuipo,Shanmei,Shantau,Shauking,Shekmun,Shekpai,Sheungshui,Shingkui,Shiuhing,Shundak,Shunyi,Shupinwai,Simshing,Siuhei,Siuhong,Siukwan,Siulun,Suikai,Taihing,Taikoo,Taipo,Taishuihang,Taiwai,Taiwohau,Tinhau,Tinshuiwai,Tiukengleng,Toishan,Tongfong,Tonglowan,Tsakyoochung,Tsamgong,Tsangshing,Tseungkwano,Tsimshatsui,Tsinggong,Tsingshantsuen,Tsingwun,Tsingyi,Tsingyuen,Tsiuchau,Tsuenshekshan,Tsuenwan,Tuenmun,Tungchung,Waichap,Waichau,Waidong,Wailoi,Waishing,Waiyeung,Wanchai,Wanfau,Wanshing,Wingon,Wongpo,Wongtaisin,Woping,Wukaisha,Yano,Yaumatei,Yautong,Yenfa,Yeungchun,Yeungdong,Yeungsai,Yeungshan,Yimtin,Yingdak,Yiuping,Yongshing,Yongyuen,Yuenlong,Yuenshing,Yuetsau,Yuknam,Yunping"}, - {name: "Mongolian", i: 31, min: 5, max: 12, d: "aou", m: .3, b: "Adaatsag,Airag,Alag Erdene,Altai,Altanshiree,Altantsogts,Arbulag,Baatsagaan,Batnorov,Batshireet,Battsengel,Bayan Adarga,Bayan Agt,Bayanbulag,Bayandalai,Bayandun,Bayangovi,Bayanjargalan,Bayankhongor,Bayankhutag,Bayanlig,Bayanmonkh,Bayannur,Bayannuur,Bayan Ondor,Bayan Ovoo,Bayantal,Bayantsagaan,Bayantumen,Bayan Uul,Bayanzurkh,Berkh,Biger,Binder,Bogd,Bombogor,Bor Ondor,Bugat,Bugt,Bulgan,Buregkhangai,Burentogtokh,Buutsagaan,Buyant,Chandmani,Chandmani Ondor,Choibalsan,Chuluunkhoroot,Chuluut,Dadal,Dalanjargalan,Dalanzadgad,Darhan Muminggan,Darkhan,Darvi,Dashbalbar,Dashinchilen,Delger,Delgerekh,Delgerkhaan,Delgerkhangai,Delgertsogt,Deluun,Deren,Dorgon,Duut,Erdene,Erdenebulgan,Erdeneburen,Erdenedalai,Erdenemandal,Erdenetsogt,Galshar,Galt,Galuut,Govi Ugtaal,Gurvan,Gurvanbulag,Gurvansaikhan,Gurvanzagal,Hinggan,Hodong,Holingol,Hondlon,Horin Ger,Horqin,Hulunbuir,Hure,Ikhkhet,Ikh Tamir,Ikh Uul,Jargalan,Jargalant,Jargaltkhaan,Jarud,Jinst,Khairkhan,Khalhgol,Khaliun,Khanbogd,Khangai,Khangal,Khankh,Khankhongor,Khashaat,Khatanbulag,Khatgal,Kherlen,Khishig Ondor,Khokh,Kholonbuir,Khongor,Khotont,Khovd,Khovsgol,Khuld,Khureemaral,Khurmen,Khutag Ondor,Luus,Mandakh,Mandal Ovoo,Mankhan,Manlai,Matad,Mogod,Monkhkhairkhan,Moron,Most,Myangad,Nogoonnuur,Nomgon,Norovlin,Noyon,Ogii,Olgii,Olziit,Omnodelger,Ondorkhaan,Ondorshil,Ondor Ulaan,Ongniud,Ordos,Orgon,Orkhon,Rashaant,Renchinlkhumbe,Sagsai,Saikhan,Saikhandulaan,Saikhan Ovoo,Sainshand,Saintsagaan,Selenge,Sergelen,Sevrei,Sharga,Sharyngol,Shine Ider,Shinejinst,Shiveegovi,Sumber,Taishir,Tarialan,Tariat,Teshig,Togrog,Togtoh,Tolbo,Tomorbulag,Tonkhil,Tosontsengel,Tsagaandelger,Tsagaannuur,Tsagaan Ovoo,Tsagaan Uur,Tsakhir,Tseel,Tsengel,Tsenkher,Tsenkhermandal,Tsetseg,Tsetserleg,Tsogt,Tsogt Ovoo,Tsogttsetsii,Tumed,Tunel,Tuvshruulekh,Ulaanbadrakh,Ulaankhus,Ulaan Uul,Ulanhad,Ulanqab,Uyench,Yesonbulag,Zag,Zalainur,Zamyn Uud,Zereg"}, - // fantasy bases by Dopu: - {name: "Human Generic", i: 32, min: 6, max: 11, d: "peolst", m: 0, b: "Amberglen,Angelhand,Arrowden,Autumnband,Autumnkeep,Basinfrost,Basinmore,Bayfrost,Beargarde,Bearmire,Bellcairn,Bellport,Bellreach,Blackwatch,Bleakward,Bonemouth,Boulder,Bridgefalls,Bridgeforest,Brinepeak,Brittlehelm,Bronzegrasp,Castlecross,Castlefair,Cavemire,Claymond,Claymouth,Clearguard,Cliffgate,Cliffshear,Cliffshield,Cloudbay,Cloudcrest,Cloudwood,Coldholde,Cragbury,Crowgrove,Crowvault,Crystalrock,Crystalspire,Cursefield,Curseguard,Cursespell,Dawnforest,Dawnwater,Deadford,Deadkeep,Deepcairn,Deerchill,Demonfall,Dewglen,Dewmere,Diredale,Direden,Dirtshield,Dogcoast,Dogmeadow,Dragonbreak,Dragonhold,Dragonward,Dryhost,Dustcross,Dustwatch,Eaglevein,Earthfield,Earthgate,Earthpass,Ebonfront,Edgehaven,Eldergate,Eldermere,Embervault,Everchill,Evercoast,Falsevale,Faypond,Fayvale,Fayyard,Fearpeak,Flameguard,Flamewell,Freyshell,Ghostdale,Ghostpeak,Gloomburn,Goldbreach,Goldyard,Grassplains,Graypost,Greeneld,Grimegrove,Grimeshire,Heartfall,Heartford,Heartvault,Highbourne,Hillpass,Hollowstorm,Honeywater,Houndcall,Houndholde,Iceholde,Icelight,Irongrave,Ironhollow,Knightlight,Knighttide,Lagoonpass,Lakecross,Lastmere,Laststar,Lightvale,Limeband,Littlehall,Littlehold,Littlemire,Lostcairn,Lostshield,Loststar,Madfair,Madham,Midholde,Mightglen,Millstrand,Mistvault,Mondpass,Moonacre,Moongulf,Moonwell,Mosshand,Mosstide,Mosswind,Mudford,Mudwich,Mythgulch,Mythshear,Nevercrest,Neverfront,Newfalls,Nighthall,Oakenbell,Oakenrun,Oceanstar,Oldreach,Oldwall,Oldwatch,Oxbrook,Oxlight,Pearlhaven,Pinepond,Pondfalls,Pondtown,Pureshell,Quickbell,Quickpass,Ravenside,Roguehaven,Roseborn,Rosedale,Rosereach,Rustmore,Saltmouth,Sandhill,Scorchpost,Scorchstall,Shadeforest,Shademeadow,Shadeville,Shimmerrun,Shimmerwood,Shroudrock,Silentkeep,Silvercairn,Silvergulch,Smallmire,Smoothcliff,Smoothgrove,Smoothtown,Snakemere,Snowbay,Snowshield,Snowtown,Southbreak,Springmire,Springview,Stagport,Steammouth,Steamwall,Steepmoor,Stillhall,Stoneguard,Stonespell,Stormhand,Stormhorn,Sungulf,Sunhall,Swampmaw,Swangarde,Swanwall,Swiftwell,Thorncairn,Thornhelm,Thornyard,Timberside,Tradewick,Westmeadow,Westpoint,Whiteshore,Whitvalley,Wildeden,Wildwell,Wildyard,Winterhaven,Wolfpass"}, - {name: "Elven", i: 33, min: 6, max: 12, d: "lenmsrg", m: 0, b: "Adrindest,Aethel,Afranthemar,Aiqua,Alari,Allanar,Almalian,Alora,Alyanasari,Alyelona,Alyran,Ammar,Anyndell,Arasari,Aren,Ashmebel,Aymlume,Bel-Didhel,Brinorion,Caelora,Chaulssad,Chaundra,Cyhmel,Cyrang,Dolarith,Dolonde,Draethe,Dranzan,Draugaust,E'ana,Eahil,Edhil,Eebel,Efranluma,Eld-Sinnocrin,Elelthyr,Ellanalin,Ellena,Ellorthond,Eltaesi,Elunore,Emyranserine,Entheas,Eriargond,Esari,Esath,Eserius,Eshsalin,Eshthalas,Evraland,Faellenor,Famelenora,Filranlean,Filsaqua,Gafetheas,Gaf Serine,Geliene,Gondorwin,Guallu,Haeth,Hanluna,Haulssad,Heloriath,Himlarien,Himliene,Hinnead,Hlinas,Hloireenil,Hluihei,Hlurthei,Hlynead,Iaenarion,Iaron,Illanathaes,Illfanora,Imlarlon,Imyse,Imyvelian,Inferius,Inlurth,innsshe,Iralserin,Irethtalos,Irholona,Ishal,Ishlashara,Ithelion,Ithlin,Iulil,Jaal,Jamkadi,Kaalume,Kaansera,Karanthanil,Karnosea,Kasethyr,Keatheas,Kelsya,Keth Aiqua,Kmlon,Kyathlenor,Kyhasera,Lahetheas,Lefdorei,Lelhamelle,Lilean,Lindeenil,Lindoress,Litys,Llaughei,Lya,Lyfa,Lylharion,Lynathalas,Machei,Masenoris,Mathethil,Mathentheas,Meethalas,Menyamar,Mithlonde,Mytha,Mythsemelle,Mythsthas,Naahona,Nalore,Nandeedil,Nasad Ilaurth,Nasin,Nathemar,Neadar,Neilon,Nelalon,Nellean,Nelnetaesi,Nilenathyr,Nionande,Nylm,Nytenanas,Nythanlenor,O'anlenora,Obeth,Ofaenathyr,Ollmnaes,Ollsmel,Olwen,Olyaneas,Omanalon,Onelion,Onelond,Orlormel,Ormrion,Oshana,Oshvamel,Raethei,Rauguall,Reisera,Reslenora,Ryanasera,Rymaserin,Sahnor,Saselune,Sel-Zedraazin,Selananor,Sellerion,Selmaluma,Shaeras,Shemnas,Shemserin,Sheosari,Sileltalos,Siriande,Siriathil,Srannor,Sshanntyr,Sshaulu,Syholume,Sylharius,Sylranbel,Taesi,Thalor,Tharenlon,Thelethlune,Thelhohil,Themar,Thene,Thilfalean,Thilnaenor,Thvethalas,Thylathlond,Tiregul,Tlauven,Tlindhe,Ulal,Ullve,Ulmetheas,Ulssin,Umnalin,Umye,Umyheserine,Unanneas,Unarith,Undraeth,Unysarion,Vel-Shonidor,Venas,Vin Argor,Wasrion,Wlalean,Yaeluma,Yeelume,Yethrion,Ymserine,Yueghed,Yuerran,Yuethin"}, - {name: "Dark Elven", i: 34, min: 6, max: 14, d: "nrslamg", m: .2, b: "Abaethaggar,Abburth,Afranthemar,Aharasplit,Aidanat,Ald'ruhn,Ashamanu,Ashesari,Ashletheas,Baerario,Baereghel,Baethei,Bahashae,Balmora,Bel-Didhel,Borethanil,Buiyrandyn,Caellagith,Caellathala,Caergroth,Caldras,Chaggar,Chaggaust,Channtar,Charrvhel'raugaust,Chaulssin,Chaundra,ChedNasad,ChetarIthlin,ChethRrhinn,Chymaer,Clarkarond,Cloibbra,Commoragh,Cyrangroth,Cilben,D'eldarc,Daedhrog,Dalkyn,Do'Urden,Doladress,Dolarith,Dolonde,Draethe,Dranzan,Dranzithl,Draugaust,Dreghei,Drelhei,Dryndlu,Dusklyngh,DyonG'ennivalz,Edraithion,Eld-Sinnocrin,Ellorthond,Enhethyr,Entheas,ErrarIthinn,Eryndlyn,Faladhell,Faneadar,Fethalas,Filranlean,Formarion,Ferdor,Gafetheas,Ghrond,Gilranel,Glamordis,Gnaarmok,Gnisis,Golothaer,Gondorwin,Guallidurth,Guallu,Gulshin,Haeth,Haggraef,Harganeth,Harkaldra,Haulssad,Haundrauth,Heloriath,Hlammachar,Hlaughei,Hloireenil,Hluitar,Inferius,Innsshe,Ithilaughym,Iz'aiogith,Jaal,Jhachalkhyn,Kaerabrae,Karanthanil,Karondkar,Karsoluthiyl,Kellyth,Khuul,Lahetheas,Lidurth,Lindeenil,Lirillaquen,LithMy'athar,LlurthDreier,Lolth,Lothuial,Luihaulen'tar,Maeralyn,Maerimydra,Mathathlona,Mathethil,Mellodona,Menagith,Menegwen,Menerrendil,Menzithl,Menzoberranzan,Mila-Nipal,Mithryn,Molagmar,Mundor,Myvanas,Naggarond,Nandeedil,NasadIlaurth,Nauthor,Navethas,Neadar,Nurtaleewe,Nidiel,Noruiben,Olwen,O'lalona,Obeth,Ofaenathyr,Orlormel,Orlytlar,Pelagiad,Raethei,Raugaust,Rauguall,Rilauven,Rrharrvhei,Sadrith,Sel-Zedraazin,Seydaneen,Shaz'rir,Skaal,Sschindylryn,Shamath,Shamenz,Shanntur,Sshanntynlan,Sshanntyr,Shaulssin,SzithMorcane,Szithlin,Szobaeth,Sirdhemben,T'lindhet,Tebh'zhor,Telmere,Telnarquel,Tharlarast,Thylathlond,Tlaughe,Trizex,Tyrybblyn,Ugauth,Ughym,Uhaelben,Ullmatalos,Ulmetheas,Ulrenserine,Uluitur,Undraeth,Undraurth,Undrek'Thoz,Ungethal,UstNatha,Uthaessien,V'elddrinnsshar,Vaajha,Vel-Shonidor,Velddra,Velothi,Venead,Vhalth'vha,Vinargothr,Vojha,Waethe,Waethei,Xaalkis,Yakaridan,Yeelume,Yridhremben,Yuethin,Yuethindrynn,Zirnakaynin"}, - {name: "Dwarven", i: 35, min: 4, max: 11, d: "dk", m: 0, b: "Addundad,Ahagzad,Ahazil,Akil,Akzizad,Anumush,Araddush,Arar,Arbhur,Badushund,Baragzig,Baragzund,Barakinb,Barakzig,Barakzinb,Barakzir,Baramunz,Barazinb,Barazir,Bilgabar,Bilgatharb,Bilgathaz,Bilgila,Bilnaragz,Bilnulbar,Bilnulbun,Bizaddum,Bizaddush,Bizanarg,Bizaram,Bizinbiz,Biziram,Bunaram,Bundinar,Bundushol,Bundushund,Bundushur,Buzaram,Buzundab,Buzundush,Gabaragz,Gabaram,Gabilgab,Gabilgath,Gabizir,Gabunal,Gabunul,Gabuzan,Gatharam,Gatharbhur,Gathizdum,Gathuragz,Gathuraz,Gila,Giledzir,Gilukkhath,Gilukkhel,Gunala,Gunargath,Gunargil,Gundumunz,Gundusharb,Gundushizd,Kharbharbiln,Kharbhatharb,Kharbhela,Kharbilgab,Kharbuzadd,Khatharbar,Khathizdin,Khathundush,Khazanar,Khazinbund,Khaziragz,Khaziraz,Khizdabun,Khizdusharbh,Khizdushath,Khizdushel,Khizdushur,Kholedzar,Khundabiln,Khundabuz,Khundinarg,Khundushel,Khuragzig,Khuramunz,Kibarak,Kibilnal,Kibizar,Kibunarg,Kibundin,Kibuzan,Kinbadab,Kinbaragz,Kinbarakz,Kinbaram,Kinbizah,Kinbuzar,Nala,Naledzar,Naledzig,Naledzinb,Naragzah,Naragzar,Naragzig,Narakzah,Narakzar,Naramunz,Narazar,Nargabad,Nargabar,Nargatharb,Nargila,Nargundum,Nargundush,Nargunul,Narukthar,Narukthel,Nula,Nulbadush,Nulbaram,Nulbilnarg,Nulbunal,Nulbundab,Nulbundin,Nulbundum,Nulbuzah,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhund,Nulukkhur,Sharakinb,Sharakzar,Sharamunz,Sharbarukth,Shatharbhizd,Shatharbiz,Shathazah,Shathizdush,Shathola,Shaziragz,Shizdinar,Shizdushund,Sholukkharb,Shundinulb,Shundushund,Shurakzund,Shuramunz,Tumunzadd,Tumunzan,Tumunzar,Tumunzinb,Tumunzir,Ukthad,Ulbirad,Ulbirar,Ulunzar,Ulur,Umunzad,Undalar,Undukkhil,Undun,Undur,Unduzur,Unzar,Unzathun,Usharar,Zaddinarg,Zaddushur,Zaharbad,Zaharbhizd,Zarakib,Zarakzar,Zaramunz,Zarukthel,Zinbarukth,Zirakinb,Zirakzir,Ziramunz,Ziruktharbh,Zirukthur,Zundumunz"}, - {name: "Goblin", i: 36, min: 4, max: 9, d: "eag", m: 0, b: "Asinx,Bhiagielt,Biokvish,Blix,Blus,Bratliaq,Breshass,Bridvelb,Brybsil,Bugbig,Buyagh,Cel,Chalk,Chiafzia,Chox,Cielb,Cosvil,Crekork,Crild,Croibieq,Diervaq,Dobruing,Driord,Eebligz,Een,Enissee,Esz,Far,Felhob,Froihiofz,Fruict,Fygsee,Gagablin,Gigganqi,Givzieqee,Glamzofs,Glernaahx,Gneabs,Gnoklig,Gobbledak,gobbok,Gobbrin,Heszai,Hiszils,Hobgar,Honk,Iahzaarm,Ialsirt,Ilm,Ish,Jasheafta,Joimtoilm,Kass,Katmelt,Kleabtong,Kleardeek,Klilm,Kluirm,Kuipuinx,Moft,Mogg,Nilbog,Oimzoishai,Onq,Ozbiard,Paas,Phax,Phigheldai,Preang,Prolkeh,Pyreazzi,Qeerags,Qosx,Rekx,Shaxi,Sios,Slehzit,Slofboif,Slukex,Srefs,Srurd,Stiaggaltia,Stiolx,Stioskurt,Stroir,Strytzakt,Stuikvact,Styrzangai,Suirx,Swaxi,Taxai,Thelt,Thresxea,Thult,Traglila,Treaq,Ulb,Ulm,Utha,Utiarm,Veekz,Vohniots,Vreagaald,Watvielx,Wrogdilk,Wruilt,Xurx,Ziggek,Zriokots"}, - {name: "Orc", i: 37, min: 4, max: 8, d: "gzrcu", m: 0, b: "Adgoz,Adgril-Gha,Adog,Adzurd,Agkadh,Agzil-Ghal,Akh,Ariz-Dru,Arkugzo,Arrordri,Ashnedh,Azrurdrekh,Bagzildre,Bashnud,Bedgez-Graz,Bhakh,Bhegh,Bhiccozdur,Bhicrur,Bhirgoshbel,Bhog,Bhurkrukh,Bod-Rugniz,Bogzel,Bozdra,Bozgrun,Bozziz,Bral-Lazogh,Brazadh,Brogved,Brogzozir,Brolzug,Brordegeg,Brorkril-Zrog,Brugroz,Brukh-Zrabrul,Brur-Korre,Bulbredh,Bulgragh,Chaz-Charard,Chegan-Khed,Chugga,Chuzar,Dhalgron-Mog,Dhazon-Ner,Dhezza,Dhoddud,Dhodh-Brerdrodh,Dhodh-Ghigin,Dhoggun-Bhogh,Dhulbazzol,Digzagkigh,Dirdrurd,Dodkakh,Dorgri,Drizdedh,Drobagh,Drodh-Ashnugh,Drogvukh-Drodh,Drukh-Qodgoz,Drurkuz,Dududh,Dur-Khaddol,Egmod,Ekh-Beccon,Ekh-Krerdrugh,Ekh-Mezred,Gagh-Druzred,Gazdrakh-Vrard,Gegnod,Gerkradh,Ghagrocroz,Ghared-Krin,Ghedgrolbrol,Gheggor,Ghizgil,Gho-Ugnud,Gholgard,Gidh-Ucceg,Goccogmurd,Golkon,Graz-Khulgag,Gribrabrokh,Gridkog,Grigh-Kaggaz,Grirkrun-Qur,Grughokh,Grurro,Gugh-Zozgrod,Gur-Ghogkagh,Ibagh-Chol,Ibruzzed,Ibul-Brad,Iggulzaz,Ikh-Ugnan,Irdrelzug,Irmekh-Bhor,Kacruz,Kalbrugh,Karkor-Zrid,Kazzuz-Zrar,Kezul-Bruz,Kharkiz,Khebun,Khorbric,Khuldrerra,Khuzdraz,Kirgol,Koggodh,Korkrir-Grar,Kraghird,Krar-Zurmurd,Krigh-Bhurdin,Kroddadh,Krudh-Khogzokh,Kudgroccukh,Kudrukh,Kudzal,Kuzgrurd-Dedh,Larud,Legvicrodh,Lorgran,Lugekh,Lulkore,Mazgar,Merkraz,Mocculdrer,Modh-Odod,Morbraz,Mubror,Muccug-Ghuz,Mughakh-Chil,Murmad,Nazad-Ludh,Negvidh,Nelzor-Zroz,Nirdrukh,Nogvolkar,Nubud,Nuccag,Nudh-Kuldra,Nuzecro,Oddigh-Krodh,Okh-Uggekh,Ordol,Orkudh-Bhur,Orrad,Qashnagh,Qiccad-Chal,Qiddolzog,Qidzodkakh,Qirzodh,Rarurd,Reradgri,Rezegh,Rezgrugh,Rodrekh,Rogh-Chirzaz,Rordrushnokh,Rozzez,Ruddirgrad,Rurguz-Vig,Ruzgrin,Ugh-Vruron,Ughudadh,Uldrukh-Bhudh,Ulgor,Ulkin,Ummugh-Ekh,Uzaggor,Uzdriboz,Uzdroz,Uzord,Uzron,Vaddog,Vagord-Khod,Velgrudh,Verrugh,Vrazin,Vrobrun,Vrugh-Nardrer,Vrurgu,Vuccidh,Vun-Gaghukh,Zacrad,Zalbrez,Zigmorbredh,Zordrordud,Zorrudh,Zradgukh,Zragmukh,Zragrizgrakh,Zraldrozzuz,Zrard-Krodog,Zrazzuz-Vaz,Zrigud,Zrulbukh-Dekh,Zubod-Ur,Zulbriz,Zun-Bergrord"}, - {name: "Giant", i: 38, min: 5, max: 10, d: "kdtng", m: 0, b: "Addund,Aerora,Agane,Anumush,Arangrim,Bahourg,Baragzund,Barakinb,Barakzig,Barakzinb,Baramunz,Barazinb,Beornelde,Beratira,Borgbert,Botharic,Bremrol,Brerstin,Brildung,Brozu,Bundushund,Burthug,Chazruc,Chergun,Churtec,Dagdhor,Dankuc,Darnaric,Debuch,Dina,Dinez,Diru,Drard,Druguk,Dugfast,Duhal,Dulkun,Eldond,Enuz,Eraddam,Eradhelm,Froththorn,Fynwyn,Gabaragz,Gabaram,Gabizir,Gabuzan,Gagkake,Galfald,Galgrim,Gatal,Gazin,Geru,Gila,Giledzir,Girkun,Glumvat,Gluthmark,Gomruch,Gorkege,Gortho,Gostuz,Grimor,Grimtira,Guddud,Gudgiz,Gulwo,Gunargath,Gundusharb,Guril,Gurkale,Guruge,Guzi,Hargarth,Hartreo,Heimfara,Hildlaug,Idgurth,Inez,Inginy,Iora,Irkin,Jaldhor,Jarwar,Jornangar,Jornmoth,Kakkek,Kaltoch,Kegkez,Kengord,Kharbharbiln,Khatharbar,Khathizdin,Khazanar,Khaziragz,Khizdabun,Khizdushel,Khundinarg,Kibarak,Kibizar,Kigine,Kilfond,Kilkan,Kinbadab,Kinbuzar,Koril,Kostand,Kuzake,Lindira,Lingarth,Maerdis,Magald,Marbold,Marbrand,Memron,Minu,Mistoch,Morluch,Mornkin,Morntaric,Nagu,Naragzah,Naramunz,Narazar,Nargabar,Nargatharb,Nargundush,Nargunul,Natan,Natil,Neliz,Nelkun,Noluch,Norginny,Nulbaram,Nulbilnarg,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhur,Nurkel,Oci,Olane,Oldstin,Orga,Ranava,Ranhera,Rannerg,Rirkan,Rizen,Rurki,Rurkoc,Sadgach,Sgandrol,Sharakzar,Shatharbiz,Shathizdush,Shathola,Shizdinar,Sholukkharb,Shundushund,Shurakzund,Sidga,Sigbeorn,Sigbi,Solfod,Somrud,Srokvan,Stighere,Sulduch,Talkale,Theoddan,Theodgrim,Throtrek,Tigkiz,Tolkeg,Toren,Tozage,Tulkug,Tumunzar,Umunzad,Undukkhil,Usharar,Valdhere,Varkud,Velfirth,Velhera,Vigkan,Vorkige,Vozig,Vylwed,Widhyrde,Wylaeya,Yili,Yotane,Yudgor,Yulkake,Zigez,Zugkan,Zugke"}, - {name: "Draconic", i: 39, min: 6, max: 14, d: "aliuszrox", m: 0, b: "Aaronarra,Adalon,Adamarondor,Aeglyl,Aerosclughpalar,Aghazstamn,Aglaraerose,Agoshyrvor,Alduin,Alhazmabad,Altagos,Ammaratha,Amrennathed,Anaglathos,Andrathanach,Araemra,Araugauthos,Arauthator,Arharzel,Arngalor,Arveiaturace,Athauglas,Augaurath,Auntyrlothtor,Azarvilandral,Azhaq,Balagos,Baratathlaer,Bleucorundum,BrazzPolis,Canthraxis,Capnolithyl,Charvekkanathor,Chellewis,Chelnadatilar,Cirrothamalan,Claugiyliamatar,Cragnortherma,Dargentum,Dendeirmerdammarar,Dheubpurcwenpyl,Domborcojh,Draconobalen,Dragansalor,Dupretiskava,Durnehviir,Eacoathildarandus,Eldrisithain,Enixtryx,Eormennoth,Esmerandanna,Evenaelorathos,Faenphaele,Felgolos,Felrivenser,Firkraag,Fll'Yissetat,Furlinastis,Galadaeros,Galglentor,Garnetallisar,Garthammus,Gaulauntyr,Ghaulantatra,Glouroth,Greshrukk,Guyanothaz,Haerinvureem,Haklashara,Halagaster,Halaglathgar,Havarlan,Heltipyre,Hethcypressarvil,Hoondarrh,Icehauptannarthanyx,Iiurrendeem,Ileuthra,Iltharagh,Ingeloakastimizilian,Irdrithkryn,Ishenalyr,Iymrith,Jaerlethket,Jalanvaloss,Jharakkan,Kasidikal,Kastrandrethilian,Khavalanoth,Khuralosothantar,Kisonraathiisar,Kissethkashaan,Kistarianth,Klauth,Klithalrundrar,Krashos,Kreston,Kriionfanthicus,Krosulhah,Krustalanos,Kruziikrel,Kuldrak,Lareth,Latovenomer,Lhammaruntosz,Llimark,Ma'fel'no'sei'kedeh'naar,MaelestorRex,Magarovallanthanz,Mahatnartorian,Mahrlee,Malaeragoth,Malagarthaul,Malazan,Maldraedior,Maldrithor,MalekSalerno,Maughrysear,Mejas,Meliordianix,Merah,Mikkaalgensis,Mirmulnir,Mistinarperadnacles,Miteach,Mithbarazak,Morueme,Moruharzel,Naaslaarum,Nahagliiv,Nalavarauthatoryl,Naxorlytaalsxar,Nevalarich,Nolalothcaragascint,Nurvureem,Nymmurh,Odahviing,Olothontor,Ormalagos,Otaaryliakkarnos,Paarthurnax,Pelath,Pelendralaar,Praelorisstan,Praxasalandos,Protanther,Qiminstiir,Quelindritar,Ralionate,Rathalylaug,Rathguul,Rauglothgor,Raumorthadar,Relonikiv,Ringreemeralxoth,Roraurim,Rynnarvyx,Sablaxaahl,Sahloknir,Sahrotaar,Samdralyrion,Saryndalaghlothtor,Sawaka,Shalamalauth,Shammagar,Sharndrel,Shianax,Skarlthoon,Skurge,Smergadas,Ssalangan,Sssurist,Sussethilasis,Sylvallitham,Tamarand,Tantlevgithus,Tarlacoal,Tenaarlaktor,Thalagyrt,Tharas'kalagram,Thauglorimorgorus,Thoklastees,Thyka,Tsenshivah,Ueurwen,Uinnessivar,Urnalithorgathla,Velcuthimmorhar,Velora,Vendrathdammarar,Venomindhar,Viinturuth,Voaraghamanthar,Voslaarum,Vr'tark,Vrondahorevos,Vuljotnaak,Vulthuryol,Wastirek,Worlathaugh,Xargithorvar,Xavarathimius,Yemere,Ylithargathril,Ylveraasahlisar,Za-Jikku,Zarlandris,Zellenesterex,Zilanthar,Zormapalearath,Zundaerazylym,Zz'Pzora"}, - {name: "Arachnid", i: 40, min: 4, max: 10, d: "erlsk", m: 0, b: "Aaqok'ser,Aiced,Aizachis,Allinqel,As'taq,Ashrash,Caaqtos,Ceek'sax,Ceezuq,Cek'sier,Cen'qi,Ceqzocer,Cezeed,Chachocaq,Charis,Chashilieth,Checib,Chernul,Chezi,Chiazu,Chishros,Chixhi,Chizhi,Chollash,Choq'sha,Cinchichail,Collul,Ecush'taid,Ekiqe,Eqas,Er'uria,Erikas,Es'tase,Esrub,Exha,Haqsho,Hiavheesh,Hitha,Hok'thi,Hossa,Iacid,Iciever,Illuq,Isnir,Keezut,Kheellavas,Kheizoh,Khiachod,Khika,Khirzur,Khonrud,Khrakku,Khraqshis,Khrethish'ti,Khriashus,Khrika,Khrirni,Klashirel,Kleil'sha,Klishuth,Krarnit,Kras'tex,Krotieqas,Lais'tid,Laizuh,Lasnoth,Len'qeer,Leqanches,Lezad,Lhilir,Lhivhath,Lhok'thu,Lialliesed,Liaraq,Liceva,Lichorro,Lilla,Lokieqib,Nakur,Neerhaca,Neet'er,Neezoh,Nenchiled,Nerhalneth,Nir'ih,Nizus,Noreeqo,On'qix,Qalitho,Qas'tor,Qasol,Qavrud,Qavud,Qazar,Qazru,Qekno,Qeqravee,Qes'tor,Qhaik'sal,Qhak'sish,Qhazsakais,Qheliva,Qhenchaqes,Qherazal,Qhon'qos,Qhosh,Qish'tur,Qisih,Qorhoci,Qranchiq,Racith,Rak'zes,Ranchis,Rarhie,Rarzi,Rarzisiaq,Ras'tih,Ravosho,Recad,Rekid,Rernee,Rertachis,Rezhokketh,Reziel,Rhacish,Rhail'shel,Rhairhizse,Rhakivex,Rhaqeer,Rhartix,Rheciezsei,Rheevid,Rhel'shir,Rhevhie,Rhiavekot,Rhikkos,Rhiqese,Rhiqi,Rhiqracar,Rhisned,Rhousnateb,Riakeesnex,Rintachal,Rir'ul,Rourk'u,Rouzakri,Sailiqei,Sanchiqed,Saqshu,Sat'ier,Sazi,Seiqas,Shieth'i,Shiqsheh,Shizha,Shrachuvo,Shranqo,Shravhos,Shravuth,Shreerhod,Shrethuh,Shriantieth,Shronqash,Shrovarhir,Shrozih,Siacaqoh,Siezosh,Siq'sha,Sirro,Sornosi,Srachussi,Szaca,Szacih,Szaqova,Szasu,Szazhilos,Szeerrud,Szeezsad,Szeknur,Szesir,Szezhirros,Szilshith,Szon'qol,Szornuq,Xeekke,Yeek'su,Yeeq'zox,Yeqil,Yeqroq,Yeveed,Yevied,Yicaveeh,Yirresh,Yisie,Yithik'thaih,Yorhaqshes,Zacheek'sa,Zakkasa,Zelraq,Zeqo,Zharuncho,Zhath'arhish,Zhavirrit,Zhazilraq,Zhazsachiel,Zhek'tha,Zhequ,Zhias'ted,Zhicat,Zhicur,Zhirhacil,Zhizri,Zhochizses,Ziarih,Zirnib"}, - {name: "Serpents", i: 41, min: 5, max: 11, d: "slrk", m: 0, b: "Aj'ha,Aj'i,Aj'tiss,Ajakess,Aksas,Aksiss,Al'en,An'jeshe,Apjige,Arkkess,Athaz,Atus,Azras,Caji,Cakrasar,Cal'arrun,Capji,Cathras,Cej'han,Ces,Cez'jenta,Cij'te,Cinash,Cizran,Coth'jus,Cothrash,Culzanek,Cunaless,Ej'tesh,Elzazash,Ergek,Eshjuk,Ethris,Gan'jas,Gapja,Gar'thituph,Gopjeguss,Gor'thesh,Gragishaph,Grar'theness,Grath'ji,Gressinas,Grolzesh,Grorjar,Grozrash,Guj'ika,Harji,Hej'hez,Herkush,Horgarrez,Illuph,Ipjar,Ithashin,Kaj'ess,Kar'kash,Kepjusha,Ki'kintus,Kissere,Koph,Kopjess,Kra'kasher,Krak,Krapjez,Krashjuless,Kraz'ji,Krirrigis,Krussin,Ma'lush,Mage,Maj'tak,Mal'a,Mapja,Mar'kash,Mar'kis,Marjin,Mas,Mathan,Men'jas,Meth'jaresh,Mij'hegak,Min'jash,Mith'jas,Monassu,Moss,Naj'hass,Najugash,Nak,Napjiph,Nar'ka,Nar'thuss,Narrusha,Nash,Nashjekez,Nataph,Nij'ass,Nij'tessiph,Nishjiss,Norkkuss,Nus,Olluruss,Or'thi,Or'thuss,Paj'a,Parkka,Pas,Pathujen,Paz'jaz,Pepjerras,Pirkkanar,Pituk,Porjunek,Pu'ke,Ragen,Ran'jess,Rargush,Razjuph,Rilzan,Riss,Rithruz,Rorgiss,Rossez,Rraj'asesh,Rraj'tass,Rrar'kess,Rrar'thuph,Rras,Rrazresh,Rrej'hish,Rrigelash,Rris,Rris,Rroksurrush,Rukrussush,Rurri,Russa,Ruth'jes,Sa'kitesh,Sar'thass,Sarjas,Sazjuzush,Ser'thez,Sezrass,Shajas,Shas,Shashja,Shass,Shetesh,Shijek,Shun'jaler,Shurjarri,Skaler,Skalla,Skallentas,Skaph,Skar'kerriz,Skath'jeruk,Sker'kalas,Skor,Skoz'ji,Sku'lu,Skuph,Skur'thur,Slalli,Slalt'har,Slelziress,Slil'ar,Sloz'jisa,Sojesh,Solle,Sorge,Sral'e,Sran'ji,Srapjess,Srar'thazur,Srash,Srath'jess,Srathrarre,Srerkkash,Srus,Sruss'tugeph,Sun,Suss'tir,Uzrash,Vargush,Vek,Vess'tu,Viph,Vult'ha,Vupjer,Vushjesash,Xagez,Xassa,Xulzessu,Zaj'tiss,Zan'jer,Zarriss,Zassegus,Zirres,Zsor,Zurjass"}, - // additional by Avengium: - {name: "Levantine", i: 42, min: 4, max: 12, d: "ankprs", m: 0, b: "Adme,Adramet,Agadir,Akko,Akzib,Alimas,Alis-Ubbo,Alqosh,Amid,Ammon,Ampi,Amurru,Andarig,Anpa,Araden,Aram,Arwad,Ashkelon,Athar,Atiq,Aza,Azeka,Baalbek,Babel,Batrun,Beerot,Beersheba,Beit Shemesh,Berytus,Bet Agus,Bet Anya,Beth-Horon,Bethel,Bethlehem,Bethuel,Bet Nahrin,Bet Nohadra,Bet Zalin,Birmula,Biruta,Bit Agushi,Bitan,Bit Zamani,Cerne,Dammeseq,Darmsuq,Dor,Eddial,Eden Ekron,Elah,Emek,Emun,Ephratah,Eyn Ganim,Finike,Gades,Galatia,Gaza,Gebal,Gedera,Gerizzim,Gethsemane,Gibeon,Gilead,Gilgal,Golgotha,Goshen,Gytte,Hagalil,Haifa,Halab,Haqel Dma,Har Habayit,Har Nevo,Har Pisga,Havilah,Hazor,Hebron,Hormah,Iboshim,Iriho,Irinem,Irridu,Israel,Kadesh,Kanaan,Kapara,Karaly,Kart-Hadasht,Keret Chadeshet,Kernah,Kesed,Keysariya,Kfar,Kfar Nahum,Khalibon,Khalpe,Khamat,Kiryat,Kittim,Kurda,Lapethos,Larna,Lepqis,Lepriptza,Liksos,Lod,Luv,Malaka,Malet,Marat,Megido,Melitta,Merdin,Metsada,Mishmarot,Mitzrayim,Moab,Mopsos,Motye,Mukish,Nampigi,Nampigu,Natzrat,Nimrud,Nineveh,Nob,Nuhadra,Oea,Ofir,Oyat,Phineka,Phoenicus,Pleshet,Qart-Tubah Sarepta,Qatna,Rabat Amon,Rakkath,Ramat Aviv,Ramitha,Ramta,Rehovot,Reshef,Rushadir,Rushakad,Samrin,Sefarad,Sehyon,Sepat,Sexi,Sharon,Shechem,Shefelat,Shfanim,Shiloh,Shmaya,Shomron,Sidon,Sinay,Sis,Solki,Sur,Suria,Tabetu,Tadmur,Tarshish,Tartus,Teberya,Tefessedt,Tekoa,Teyman,Tinga,Tipasa,Tsabratan,Tur Abdin,Tzarfat,Tziyon,Tzor,Ugarit,Unubaal,Ureshlem,Urhay,Urushalim,Vaga,Yaffa,Yamhad,Yam hamelach,Yam Kineret,Yamutbal,Yathrib,Yaudi,Yavne,Yehuda,Yerushalayim,Yev,Yevus,Yizreel,Yurdnan,Zarefat,Zeboim,Zeurta,Zeytim,Zikhron,Zmurna"} - ]; - }; - - return { - getBase, - getCulture, - getCultureShort, - getBaseShort, - getState, - updateChain, - clearChains, - getNameBases, - getMapName, - calculateChain - }; -})(); diff --git a/src/index.html b/src/index.html index d14cea96..d0c66986 100644 --- a/src/index.html +++ b/src/index.html @@ -8494,7 +8494,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index 6867c05f..9db7aaef 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,7 +1,8 @@ import "./voronoi"; import "./heightmap-generator"; import "./features"; -import "./lakes"; +import "./names-generator"; import "./ocean-layers"; +import "./lakes"; import "./river-generator"; import "./biomes"; diff --git a/src/modules/names-generator.ts b/src/modules/names-generator.ts new file mode 100644 index 00000000..da60beca --- /dev/null +++ b/src/modules/names-generator.ts @@ -0,0 +1,721 @@ +import { capitalize, isVowel, last, P, ra, rand } from "../utils"; + +declare global { + var Names: NamesGenerator; +} + +export interface NameBase { + name: string; // name of the base + i: number; // index of the base + min: number; // minimum length of generated names + max: number; // maximum length of generated names + d: string; // letters allowed to duplicate + m: number; // multi-word name rate [deprecated] + b: string; // base string with names separated by comma +} + +// Markov chain lookup table: key is a letter (or empty string for word start), value is array of possible next syllables +// Note: Uses array with string keys (sparse array) to match original JS behavior +type MarkovChain = string[][] & Record; + +class NamesGenerator { + chains: (MarkovChain | null)[] = []; // Markov chains for namebases + + calculateChain(namesList: string): MarkovChain { + const chain: MarkovChain = [] as unknown as MarkovChain; + const availableNames = namesList.split(","); + + for (const n of availableNames) { + const name = n.trim().toLowerCase(); + const basic = !/[^\x20-\x7e]/.test(name); // basic printable ASCII chars and English rules can be applied + + // split word into pseudo-syllables + for ( + let i = -1, syllable = ""; + i < name.length; + i += syllable.length || 1, syllable = "" + ) { + const prev = name[i] || ""; // pre-onset letter + let v = 0; // 0 if no vowels in syllable + + for (let c = i + 1; name[c] && syllable.length < 5; c++) { + const that = name[c], + next = name[c + 1]; // next char + syllable += that; + if (syllable === " " || syllable === "-") break; // syllable starts with space or hyphen + if (!next || next === " " || next === "-") break; // no need to check + + if (isVowel(that)) v = 1; // check if letter is vowel + + // do not split some diphthongs + if (that === "y" && next === "e") continue; // 'ye' + if (basic) { + // English-like + if (that === "o" && next === "o") continue; // 'oo' + if (that === "e" && next === "e") continue; // 'ee' + if (that === "a" && next === "e") continue; // 'ae' + if (that === "c" && next === "h") continue; // 'ch' + } + + if (isVowel(that) === (next as unknown as boolean)) break; // two same vowels in a row (original quirky behavior) + if (v && isVowel(name[c + 2])) break; // syllable has vowel and additional vowel is expected soon + } + + if (!chain[prev]) chain[prev] = []; + chain[prev].push(syllable); + } + } + + return chain; + } + + updateChain(index: number): void { + this.chains[index] = nameBases[index]?.b + ? this.calculateChain(nameBases[index].b) + : null; + } + + clearChains(): void { + this.chains = []; + } + + // generate name using Markov's chain + getBase(base: number, min?: number, max?: number, dupl?: string): string { + if (base === undefined) { + ERROR && console.error("Please define a base"); + return "ERROR"; + } + + if (nameBases[base] === undefined) { + if (nameBases[0]) { + WARN && + console.warn( + `Namebase ${base} is not found. First available namebase will be used`, + ); + base = 0; + } else { + ERROR && console.error(`Namebase ${base} is not found`); + return "ERROR"; + } + } + + if (!this.chains[base]) this.updateChain(base); + + const data = this.chains[base]; + if (!data || data[""] === undefined) { + tip( + `Namesbase ${base} is incorrect. Please check in namesbase editor`, + false, + "error", + ); + ERROR && console.error(`Namebase ${base} is incorrect!`); + return "ERROR"; + } + + if (!min) min = nameBases[base].min; + if (!max) max = nameBases[base].max; + if (dupl !== "") dupl = nameBases[base].d; + + let v = data[""], + cur = ra(v), + w = ""; + for (let i = 0; i < 20; i++) { + if (cur === "") { + // end of word + if (w.length < min) { + cur = ""; + w = ""; + v = data[""]; + } else break; + } else { + if (w.length + cur.length > max) { + // word too long + if (w.length < min) w += cur; + break; + } else v = data[last(cur.split("")) as string] || data[""]; + } + + w += cur; + cur = ra(v); + } + + // parse word to get a final name + const l = last(w.split("")); // last letter + if (l === "'" || l === " " || l === "-") w = w.slice(0, -1); // not allow some characters at the end + + let name = [...w].reduce((r, c, i, d) => { + if (c === d[i + 1] && !dupl.includes(c)) return r; // duplication is not allowed + if (!r.length) return c.toUpperCase(); + if (r.slice(-1) === "-" && c === " ") return r; // remove space after hyphen + if (r.slice(-1) === " ") return r + c.toUpperCase(); // capitalize letter after space + if (r.slice(-1) === "-") return r + c.toUpperCase(); // capitalize letter after hyphen + if (c === "a" && d[i + 1] === "e") return r; // "ae" => "e" + if (i + 2 < d.length && c === d[i + 1] && c === d[i + 2]) return r; // remove three same letters in a row + return r + c; + }, ""); + + // join the word if any part has only 1 letter + if (name.split(" ").some((part) => part.length < 2)) + name = name + .split(" ") + .map((p, i) => (i ? p.toLowerCase() : p)) + .join(""); + + if (name.length < 2) { + ERROR && console.error("Name is too short! Random name will be selected"); + name = ra(nameBases[base].b.split(",")); + } + + return name; + } + + // generate name for culture + getCulture( + culture: number, + min?: number, + max?: number, + dupl?: string, + ): string { + if (culture === undefined) { + ERROR && console.error("Please define a culture"); + return "ERROR"; + } + const base = pack.cultures[culture].base; + return this.getBase(base, min, max, dupl); + } + + // generate short name for culture + getCultureShort(culture: number): string { + if (culture === undefined) { + ERROR && console.error("Please define a culture"); + return "ERROR"; + } + return this.getBaseShort(pack.cultures[culture].base); + } + + // generate short name for base + getBaseShort(base: number): string { + const min = nameBases[base] ? nameBases[base].min - 1 : undefined; + const max = min ? Math.max(nameBases[base].max - 2, min) : undefined; + return this.getBase(base, min, max, ""); + } + + private validateSuffix(name: string, suffix: string): string { + if (name.slice(-1 * suffix.length) === suffix) return name; // no suffix if name already ends with it + const s1 = suffix.charAt(0); + if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter + if ( + isVowel(s1) === isVowel(name.slice(-1)) && + isVowel(s1) === isVowel(name.slice(-2, -1)) + ) + name = name.slice(0, -1); // remove name last char if 2 last chars are the same type as suffix's 1st + if (name.slice(-1) === s1) name = name.slice(0, -1); // remove name last letter if it's a suffix first letter + return name + suffix; + } + + private addSuffix(name: string): string { + const suffix = P(0.8) ? "ia" : "land"; + if (suffix === "ia" && name.length > 6) + name = name.slice(0, -(name.length - 3)); + else if (suffix === "land" && name.length > 6) + name = name.slice(0, -(name.length - 5)); + return this.validateSuffix(name, suffix); + } + + // generate state name based on capital or random name and culture-specific suffix + getState(name: string, culture: number, base: number): string { + if (name === undefined) { + ERROR && console.error("Please define a base name"); + return "ERROR"; + } + if (culture === undefined && base === undefined) { + ERROR && console.error("Please define a culture"); + return "ERROR"; + } + if (base === undefined) base = pack.cultures[culture].base; + + // exclude endings inappropriate for states name + if (name.includes(" ")) + name = capitalize(name.replace(/ /g, "").toLowerCase()); // don't allow multiword state names + if (name.length > 6 && name.slice(-4) === "berg") name = name.slice(0, -4); // remove -berg for any + if (name.length > 5 && name.slice(-3) === "ton") name = name.slice(0, -3); // remove -ton for any + + if (base === 5 && ["sk", "ev", "ov"].includes(name.slice(-2))) + name = name.slice(0, -2); + // remove -sk/-ev/-ov for Ruthenian + else if (base === 12) return isVowel(name.slice(-1)) ? name : `${name}u`; + // Japanese ends on any vowel or -u + else if (base === 18 && P(0.4)) + name = isVowel(name.slice(0, 1).toLowerCase()) + ? `Al${name.toLowerCase()}` + : `Al ${name}`; // Arabic starts with -Al + + // no suffix for fantasy bases + if (base > 32 && base < 42) return name; + + // define if suffix should be used + if (name.length > 3 && isVowel(name.slice(-1))) { + if (isVowel(name.slice(-2, -1)) && P(0.85)) name = name.slice(0, -2); + // 85% for vv + else if (P(0.7)) name = name.slice(0, -1); + // ~60% for cv + else return name; + } else if (P(0.4)) return name; // 60% for cc and vc + + // define suffix + let suffix = "ia"; // standard suffix + + const rnd = Math.random(), + l = name.length; + if (base === 3 && rnd < 0.03 && l < 7) suffix = "terra"; + // Italian + else if (base === 4 && rnd < 0.03 && l < 7) suffix = "terra"; + // Spanish + else if (base === 13 && rnd < 0.03 && l < 7) suffix = "terra"; + // Portuguese + else if (base === 2 && rnd < 0.03 && l < 7) suffix = "terre"; + // French + else if (base === 0 && rnd < 0.5 && l < 7) suffix = "land"; + // German + else if (base === 1 && rnd < 0.4 && l < 7) suffix = "land"; + // English + else if (base === 6 && rnd < 0.3 && l < 7) suffix = "land"; + // Nordic + else if (base === 32 && rnd < 0.1 && l < 7) suffix = "land"; + // generic Human + else if (base === 7 && rnd < 0.1) suffix = "eia"; + // Greek + else if (base === 9 && rnd < 0.35) suffix = "maa"; + // Finnic + else if (base === 15 && rnd < 0.4 && l < 6) suffix = "orszag"; + // Hungarian + else if (base === 16) suffix = rnd < 0.6 ? "yurt" : "eli"; + // Turkish + else if (base === 10) suffix = "guk"; + // Korean + else if (base === 11) suffix = " Guo"; + // Chinese + else if (base === 14) suffix = rnd < 0.5 && l < 6 ? "tlan" : "co"; + // Nahuatl + else if (base === 17 && rnd < 0.8) suffix = "a"; + // Berber + else if (base === 18 && rnd < 0.8) suffix = "a"; // Arabic + + return this.validateSuffix(name, suffix); + } + + // generato name for the map + getMapName(force: boolean) { + if (!force && locked("mapName")) return; + if (force && locked("mapName")) unlock("mapName"); + const base = P(0.7) ? 2 : P(0.5) ? rand(0, 6) : rand(0, 31); + if (!nameBases[base]) { + tip("Namebase is not found", false, "error"); + return ""; + } + const min = nameBases[base].min - 1; + const max = Math.max(nameBases[base].max - 3, min); + const baseName = this.getBase(base, min, max, "") as string; + const name = P(0.7) ? this.addSuffix(baseName) : baseName; + mapName.value = name; + } + + getNameBases(): NameBase[] { + // name, min length, max length, letters to allow duplication, multi-word name rate [deprecated] + // prettier-ignore + return [ + // real-world bases by Azgaar: + { + name: "German", + i: 0, + min: 5, + max: 12, + d: "lt", + m: 0, + b: "Achern,Aichhalden,Aitern,Albbruck,Alpirsbach,Altensteig,Althengstett,Appenweier,Auggen,Badenen,Badenweiler,Baiersbronn,Ballrechten,Bellingen,Berghaupten,Bernau,Biberach,Biederbach,Binzen,Birkendorf,Birkenfeld,Bischweier,Blumberg,Bollen,Bollschweil,Bonndorf,Bosingen,Braunlingen,Breisach,Breisgau,Breitnau,Brigachtal,Buchenbach,Buggingen,Buhl,Buhlertal,Calw,Dachsberg,Dobel,Donaueschingen,Dornhan,Dornstetten,Dottingen,Dunningen,Durbach,Durrheim,Ebhausen,Ebringen,Efringen,Egenhausen,Ehrenkirchen,Ehrsberg,Eimeldingen,Eisenbach,Elzach,Elztal,Emmendingen,Endingen,Engelsbrand,Enz,Enzklosterle,Eschbronn,Ettenheim,Ettlingen,Feldberg,Fischerbach,Fischingen,Fluorn,Forbach,Freiamt,Freiburg,Freudenstadt,Friedenweiler,Friesenheim,Frohnd,Furtwangen,Gaggenau,Geisingen,Gengenbach,Gernsbach,Glatt,Glatten,Glottertal,Gorwihl,Gottenheim,Grafenhausen,Grenzach,Griesbach,Gutach,Gutenbach,Hag,Haiterbach,Hardt,Harmersbach,Hasel,Haslach,Hausach,Hausen,Hausern,Heitersheim,Herbolzheim,Herrenalb,Herrischried,Hinterzarten,Hochenschwand,Hofen,Hofstetten,Hohberg,Horb,Horben,Hornberg,Hufingen,Ibach,Ihringen,Inzlingen,Kandern,Kappel,Kappelrodeck,Karlsbad,Karlsruhe,Kehl,Keltern,Kippenheim,Kirchzarten,Konigsfeld,Krozingen,Kuppenheim,Kussaberg,Lahr,Lauchringen,Lauf,Laufenburg,Lautenbach,Lauterbach,Lenzkirch,Liebenzell,Loffenau,Loffingen,Lorrach,Lossburg,Mahlberg,Malsburg,Malsch,March,Marxzell,Marzell,Maulburg,Monchweiler,Muhlenbach,Mullheim,Munstertal,Murg,Nagold,Neubulach,Neuenburg,Neuhausen,Neuried,Neuweiler,Niedereschach,Nordrach,Oberharmersbach,Oberkirch,Oberndorf,Oberbach,Oberried,Oberwolfach,Offenburg,Ohlsbach,Oppenau,Ortenberg,otigheim,Ottenhofen,Ottersweier,Peterstal,Pfaffenweiler,Pfalzgrafenweiler,Pforzheim,Rastatt,Renchen,Rheinau,Rheinfelden,Rheinmunster,Rickenbach,Rippoldsau,Rohrdorf,Rottweil,Rummingen,Rust,Sackingen,Sasbach,Sasbachwalden,Schallbach,Schallstadt,Schapbach,Schenkenzell,Schiltach,Schliengen,Schluchsee,Schomberg,Schonach,Schonau,Schonenberg,Schonwald,Schopfheim,Schopfloch,Schramberg,Schuttertal,Schwenningen,Schworstadt,Seebach,Seelbach,Seewald,Sexau,Simmersfeld,Simonswald,Sinzheim,Solden,Staufen,Stegen,Steinach,Steinen,Steinmauern,Straubenhardt,Stuhlingen,Sulz,Sulzburg,Teinach,Tiefenbronn,Tiengen,Titisee,Todtmoos,Todtnau,Todtnauberg,Triberg,Tunau,Tuningen,uhlingen,Unterkirnach,Reichenbach,Utzenfeld,Villingen,Villingendorf,Vogtsburg,Vohrenbach,Waldachtal,Waldbronn,Waldkirch,Waldshut,Wehr,Weil,Weilheim,Weisenbach,Wembach,Wieden,Wiesental,Wildbad,Wildberg,Winzeln,Wittlingen,Wittnau,Wolfach,Wutach,Wutoschingen,Wyhlen,Zavelstein", + }, + { + name: "English", + i: 1, + min: 6, + max: 11, + d: "", + m: 0.1, + b: "Abingdon,Albrighton,Alcester,Almondbury,Altrincham,Amersham,Andover,Appleby,Ashboume,Atherstone,Aveton,Axbridge,Aylesbury,Baldock,Bamburgh,Barton,Basingstoke,Berden,Bere,Berkeley,Berwick,Betley,Bideford,Bingley,Birmingham,Blandford,Blechingley,Bodmin,Bolton,Bootham,Boroughbridge,Boscastle,Bossinney,Bramber,Brampton,Brasted,Bretford,Bridgetown,Bridlington,Bromyard,Bruton,Buckingham,Bungay,Burton,Calne,Cambridge,Canterbury,Carlisle,Castleton,Caus,Charmouth,Chawleigh,Chichester,Chillington,Chinnor,Chipping,Chisbury,Cleobury,Clifford,Clifton,Clitheroe,Cockermouth,Coleshill,Combe,Congleton,Crafthole,Crediton,Cuddenbeck,Dalton,Darlington,Dodbrooke,Drax,Dudley,Dunstable,Dunster,Dunwich,Durham,Dymock,Exeter,Exning,Faringdon,Felton,Fenny,Finedon,Flookburgh,Fowey,Frampton,Gateshead,Gatton,Godmanchester,Grampound,Grantham,Guildford,Halesowen,Halton,Harbottle,Harlow,Hatfield,Hatherleigh,Haydon,Helston,Henley,Hertford,Heytesbury,Hinckley,Hitchin,Holme,Hornby,Horsham,Kendal,Kenilworth,Kilkhampton,Kineton,Kington,Kinver,Kirby,Knaresborough,Knutsford,Launceston,Leighton,Lewes,Linton,Louth,Luton,Lyme,Lympstone,Macclesfield,Madeley,Malborough,Maldon,Manchester,Manningtree,Marazion,Marlborough,Marshfield,Mere,Merryfield,Middlewich,Midhurst,Milborne,Mitford,Modbury,Montacute,Mousehole,Newbiggin,Newborough,Newbury,Newenden,Newent,Norham,Northleach,Noss,Oakham,Olney,Orford,Ormskirk,Oswestry,Padstow,Paignton,Penkneth,Penrith,Penzance,Pershore,Petersfield,Pevensey,Pickering,Pilton,Pontefract,Portsmouth,Preston,Quatford,Reading,Redcliff,Retford,Rockingham,Romney,Rothbury,Rothwell,Salisbury,Saltash,Seaford,Seasalter,Sherston,Shifnal,Shoreham,Sidmouth,Skipsea,Skipton,Solihull,Somerton,Southam,Southwark,Standon,Stansted,Stapleton,Stottesdon,Sudbury,Swavesey,Tamerton,Tarporley,Tetbury,Thatcham,Thaxted,Thetford,Thornbury,Tintagel,Tiverton,Torksey,Totnes,Towcester,Tregoney,Trematon,Tutbury,Uxbridge,Wallingford,Wareham,Warenmouth,Wargrave,Warton,Watchet,Watford,Wendover,Westbury,Westcheap,Weymouth,Whitford,Wickwar,Wigan,Wigmore,Winchelsea,Winkleigh,Wiscombe,Witham,Witheridge,Wiveliscombe,Woodbury,Yeovil", + }, + { + name: "French", + i: 2, + min: 5, + max: 13, + d: "nlrs", + m: 0.1, + b: "Adon,Aillant,Amilly,Andonville,Ardon,Artenay,Ascheres,Ascoux,Attray,Aubin,Audeville,Aulnay,Autruy,Auvilliers,Auxy,Aveyron,Baccon,Bardon,Barville,Batilly,Baule,Bazoches,Beauchamps,Beaugency,Beaulieu,Beaune,Bellegarde,Boesses,Boigny,Boiscommun,Boismorand,Boisseaux,Bondaroy,Bonnee,Bonny,Bordes,Bou,Bougy,Bouilly,Boulay,Bouzonville,Bouzy,Boynes,Bray,Breteau,Briare,Briarres,Bricy,Bromeilles,Bucy,Cepoy,Cercottes,Cerdon,Cernoy,Cesarville,Chailly,Chaingy,Chalette,Chambon,Champoulet,Chanteau,Chantecoq,Chapell,Charme,Charmont,Charsonville,Chateau,Chateauneuf,Chatel,Chatenoy,Chatillon,Chaussy,Checy,Chevannes,Chevillon,Chevilly,Chevry,Chilleurs,Choux,Chuelles,Clery,Coinces,Coligny,Combleux,Combreux,Conflans,Corbeilles,Corquilleroy,Cortrat,Coudroy,Coullons,Coulmiers,Courcelles,Courcy,Courtemaux,Courtempierre,Courtenay,Cravant,Crottes,Dadonville,Dammarie,Dampierre,Darvoy,Desmonts,Dimancheville,Donnery,Dordives,Dossainville,Douchy,Dry,Echilleuses,Egry,Engenville,Epieds,Erceville,Ervauville,Escrennes,Escrignelles,Estouy,Faverelles,Fay,Feins,Ferolles,Ferrieres,Fleury,Fontenay,Foret,Foucherolles,Freville,Gatinais,Gaubertin,Gemigny,Germigny,Gidy,Gien,Girolles,Givraines,Gondreville,Grangermont,Greneville,Griselles,Guigneville,Guilly,Gyleslonains,Huetre,Huisseau,Ingrannes,Ingre,Intville,Isdes,Ivre,Jargeau,Jouy,Juranville,Bussiere,Laas,Ladon,Lailly,Langesse,Leouville,Ligny,Lombreuil,Lorcy,Lorris,Loury,Louzouer,Malesherbois,Marcilly,Mardie,Mareau,Marigny,Marsainvilliers,Melleroy,Menestreau,Merinville,Messas,Meung,Mezieres,Migneres,Mignerette,Mirabeau,Montargis,Montbarrois,Montbouy,Montcresson,Montereau,Montigny,Montliard,Mormant,Morville,Moulinet,Moulon,Nancray,Nargis,Nesploy,Neuville,Neuvy,Nevoy,Nibelle,Nogent,Noyers,Ocre,Oison,Olivet,Ondreville,Onzerain,Orleans,Ormes,Orville,Oussoy,Outarville,Ouzouer,Pannecieres,Pannes,Patay,Paucourt,Pers,Pierrefitte,Pithiverais,Pithiviers,Poilly,Potier,Prefontaines,Presnoy,Pressigny,Puiseaux,Quiers,Ramoulu,Rebrechien,Rouvray,Rozieres,Rozoy,Ruan,Sandillon,Santeau,Saran,Sceaux,Seichebrieres,Semoy,Sennely,Sermaises,Sigloy,Solterre,Sougy,Sully,Sury,Tavers,Thignonville,Thimory,Thorailles,Thou,Tigy,Tivernon,Tournoisis,Trainou,Treilles,Trigueres,Trinay,Vannes,Varennes,Vennecy,Vieilles,Vienne,Viglain,Vignes,Villamblain,Villemandeur,Villemoutiers,Villemurlin,Villeneuve,Villereau,Villevoques,Villorceau,Vimory,Vitry,Vrigny", + }, + { + name: "Italian", + i: 3, + min: 5, + max: 12, + d: "cltr", + m: 0.1, + b: "Accumoli,Acquafondata,Acquapendente,Acuto,Affile,Agosta,Alatri,Albano,Allumiere,Alvito,Amaseno,Amatrice,Anagni,Anguillara,Anticoli,Antrodoco,Anzio,Aprilia,Aquino,Arcinazzo,Ariccia,Arpino,Arsoli,Ausonia,Bagnoregio,Bassiano,Bellegra,Belmonte,Bolsena,Bomarzo,Borgorose,Boville,Bracciano,Broccostella,Calcata,Camerata,Campagnano,Campoli,Canale,Canino,Cantalice,Cantalupo,Capranica,Caprarola,Carbognano,Casalattico,Casalvieri,Castelforte,Castelnuovo,Castiglione,Castro,Castrocielo,Ceccano,Celleno,Cellere,Cerreto,Cervara,Cerveteri,Ciampino,Ciciliano,Cittaducale,Cittareale,Civita,Civitella,Colfelice,Colleferro,Collepardo,Colonna,Concerviano,Configni,Contigliano,Cori,Cottanello,Esperia,Faleria,Farnese,Ferentino,Fiamignano,Filacciano,Fiuggi,Fiumicino,Fondi,Fontana,Fonte,Fontechiari,Formia,Frascati,Frasso,Frosinone,Fumone,Gaeta,Gallese,Gavignano,Genazzano,Giuliano,Gorga,Gradoli,Grottaferrata,Grotte,Guarcino,Guidonia,Ischia,Isola,Labico,Labro,Ladispoli,Latera,Lenola,Leonessa,Licenza,Longone,Lubriano,Maenza,Magliano,Marano,Marcellina,Marcetelli,Marino,Mazzano,Mentana,Micigliano,Minturno,Montalto,Montasola,Montebuono,Monteflavio,Montelanico,Monteleone,Montenero,Monterosi,Moricone,Morlupo,Nazzano,Nemi,Nerola,Nespolo,Nettuno,Norma,Olevano,Onano,Oriolo,Orte,Orvinio,Paganico,Paliano,Palombara,Patrica,Pescorocchiano,Petrella,Piansano,Picinisco,Pico,Piedimonte,Piglio,Pignataro,Poggio,Poli,Pomezia,Pontecorvo,Pontinia,Ponzano,Posta,Pozzaglia,Priverno,Proceno,Rignano,Riofreddo,Ripi,Rivodutri,Rocca,Roccagorga,Roccantica,Roccasecca,Roiate,Ronciglione,Roviano,Salisano,Sambuci,Santa,Santini,Scandriglia,Segni,Selci,Sermoneta,Serrone,Settefrati,Sezze,Sgurgola,Sonnino,Sora,Soriano,Sperlonga,Spigno,Subiaco,Supino,Sutri,Tarano,Tarquinia,Terelle,Terracina,Tivoli,Toffia,Tolfa,Torrice,Torricella,Trevi,Trevignano,Trivigliano,Turania,Tuscania,Valentano,Vallecorsa,Vallemaio,Vallepietra,Vallerano,Vasanello,Vejano,Velletri,Ventotene,Veroli,Vetralla,Vicalvi,Vico,Vicovaro,Vignanello,Viterbo,Viticuso,Vitorchiano,Vivaro,Zagarolo", + }, + { + name: "Castillian", + i: 4, + min: 5, + max: 11, + d: "lr", + m: 0, + b: "Ajofrin,Alameda,Alaminos,Albares,Albarreal,Albendiego,Alcanizo,Alcaudete,Alcolea,Aldea,Aldeanueva,Algar,Algora,Alhondiga,Almadrones,Almendral,Alovera,Anguita,Arbancon,Argecilla,Arges,Arroyo,Atanzon,Atienza,Azuqueca,Baides,Banos,Bargas,Barriopedro,Belvis,Berninches,Brihuega,Buenaventura,Burgos,Burguillos,Bustares,Cabanillas,Calzada,Camarena,Campillo,Cantalojas,Cardiel,Carmena,Casas,Castejon,Castellar,Castilforte,Castillo,Castilnuevo,Cazalegas,Centenera,Cervera,Checa,Chozas,Chueca,Cifuentes,Cincovillas,Ciruelas,Cogollor,Cogolludo,Consuegra,Copernal,Corral,Cuerva,Domingo,Dosbarrios,Driebes,Duron,Escalona,Escalonilla,Escamilla,Escopete,Espinosa,Esplegares,Esquivias,Estables,Estriegana,Fontanar,Fuembellida,Fuensalida,Fuentelsaz,Gajanejos,Galvez,Gascuena,Gerindote,Guadamur,Heras,Herreria,Herreruela,Hinojosa,Hita,Hombrados,Hontanar,Hormigos,Huecas,Huerta,Humanes,Illana,Illescas,Iniestola,Irueste,Jadraque,Jirueque,Lagartera,Ledanca,Lillo,Lominchar,Loranca,Lucillos,Luzaga,Luzon,Madrid,Magan,Malaga,Malpica,Manzanar,Maqueda,Masegoso,Matillas,Medranda,Megina,Mejorada,Millana,Milmarcos,Mirabueno,Miralrio,Mocejon,Mochales,Molina,Mondejar,Montarron,Mora,Moratilla,Morenilla,Navas,Negredo,Noblejas,Numancia,Nuno,Ocana,Ocentejo,Olias,Olmeda,Ontigola,Orea,Orgaz,Oropesa,Otero,Palma,Pardos,Paredes,Penalver,Pepino,Peralejos,Pinilla,Pioz,Piqueras,Portillo,Poveda,Pozo,Pradena,Prados,Puebla,Puerto,Quero,Quintanar,Rebollosa,Retamoso,Riba,Riofrio,Robledo,Romanillos,Romanones,Rueda,Salmeron,Santiuste,Santo,Sauca,Segura,Selas,Semillas,Sesena,Setiles,Sevilla,Siguenza,Solanillos,Somolinos,Sonseca,Sotillo,Talavera,Taravilla,Tembleque,Tendilla,Tierzo,Torralba,Torre,Torrejon,Torrijos,Tortola,Tortuera,Totanes,Trillo,Uceda,Ugena,Urda,Utande,Valdesotos,Valhermoso,Valtablado,Valverde,Velada,Viana,Yebra,Yuncos,Yunquera,Zaorejas,Zarzuela,Zorita", + }, + { + name: "Ruthenian", + i: 5, + min: 5, + max: 10, + d: "", + m: 0, + b: "Belgorod,Beloberezhye,Belyi,Belz,Berestiy,Berezhets,Berezovets,Berezutsk,Bobruisk,Bolonets,Borisov,Borovsk,Bozhesk,Bratslav,Bryansk,Brynsk,Buryn,Byhov,Chechersk,Chemesov,Cheremosh,Cherlen,Chern,Chernigov,Chernitsa,Chernobyl,Chernogorod,Chertoryesk,Chetvertnia,Demyansk,Derevesk,Devyagoresk,Dichin,Dmitrov,Dorogobuch,Dorogobuzh,Drestvin,Drokov,Drutsk,Dubechin,Dubichi,Dubki,Dubkov,Dveren,Galich,Glebovo,Glinsk,Goloty,Gomiy,Gorodets,Gorodische,Gorodno,Gorohovets,Goroshin,Gorval,Goryshon,Holm,Horobor,Hoten,Hotin,Hotmyzhsk,Ilovech,Ivan,Izborsk,Izheslavl,Kamenets,Kanev,Karachev,Karna,Kavarna,Klechesk,Klyapech,Kolomyya,Kolyvan,Kopyl,Korec,Kornik,Korochunov,Korshev,Korsun,Koshkin,Kotelno,Kovyla,Kozelsk,Kozelsk,Kremenets,Krichev,Krylatsk,Ksniatin,Kulatsk,Kursk,Kursk,Lebedev,Lida,Logosko,Lomihvost,Loshesk,Loshichi,Lubech,Lubno,Lubutsk,Lutsk,Luchin,Luki,Lukoml,Luzha,Lvov,Mtsensk,Mdin,Medniki,Melecha,Merech,Meretsk,Mescherskoe,Meshkovsk,Metlitsk,Mezetsk,Mglin,Mihailov,Mikitin,Mikulino,Miloslavichi,Mogilev,Mologa,Moreva,Mosalsk,Moschiny,Mozyr,Mstislav,Mstislavets,Muravin,Nemech,Nemiza,Nerinsk,Nichan,Novgorod,Novogorodok,Obolichi,Obolensk,Obolensk,Oleshsk,Olgov,Omelnik,Opoka,Opoki,Oreshek,Orlets,Osechen,Oster,Ostrog,Ostrov,Perelai,Peremil,Peremyshl,Pererov,Peresechen,Perevitsk,Pereyaslav,Pinsk,Ples,Polotsk,Pronsk,Proposhesk,Punia,Putivl,Rechitsa,Rodno,Rogachev,Romanov,Romny,Roslavl,Rostislavl,Rostovets,Rsha,Ruza,Rybchesk,Rylsk,Rzhavesk,Rzhev,Rzhischev,Sambor,Serensk,Serensk,Serpeysk,Shilov,Shuya,Sinech,Sizhka,Skala,Slovensk,Slutsk,Smedin,Sneporod,Snitin,Snovsk,Sochevo,Sokolec,Starica,Starodub,Stepan,Sterzh,Streshin,Sutesk,Svinetsk,Svisloch,Terebovl,Ternov,Teshilov,Teterin,Tiversk,Torchevsk,Toropets,Torzhok,Tripolye,Trubchevsk,Tur,Turov,Usvyaty,Uteshkov,Vasilkov,Velil,Velye,Venev,Venicha,Verderev,Vereya,Veveresk,Viazma,Vidbesk,Vidychev,Voino,Volodimer,Volok,Volyn,Vorobesk,Voronich,Voronok,Vorotynsk,Vrev,Vruchiy,Vselug,Vyatichsk,Vyatka,Vyshegorod,Vyshgorod,Vysokoe,Yagniatin,Yaropolch,Yasenets,Yuryev,Yuryevets,Zaraysk,Zhitomel,Zholvazh,Zizhech,Zubkov,Zudechev,Zvenigorod", + }, + { + name: "Nordic", + i: 6, + min: 6, + max: 10, + d: "kln", + m: 0.1, + b: "Akureyri,Aldra,Alftanes,Andenes,Austbo,Auvog,Bakkafjordur,Ballangen,Bardal,Beisfjord,Bifrost,Bildudalur,Bjerka,Bjerkvik,Bjorkosen,Bliksvaer,Blokken,Blonduos,Bolga,Bolungarvik,Borg,Borgarnes,Bosmoen,Bostad,Bostrand,Botsvika,Brautarholt,Breiddalsvik,Bringsli,Brunahlid,Budardalur,Byggdakjarni,Dalvik,Djupivogur,Donnes,Drageid,Drangsnes,Egilsstadir,Eiteroga,Elvenes,Engavogen,Ertenvog,Eskifjordur,Evenes,Eyrarbakki,Fagernes,Fallmoen,Fellabaer,Fenes,Finnoya,Fjaer,Fjelldal,Flakstad,Flateyri,Flostrand,Fludir,Gardaber,Gardur,Gimstad,Givaer,Gjeroy,Gladstad,Godoya,Godoynes,Granmoen,Gravdal,Grenivik,Grimsey,Grindavik,Grytting,Hafnir,Halsa,Hauganes,Haugland,Hauknes,Hella,Helland,Hellissandur,Hestad,Higrav,Hnifsdalur,Hofn,Hofsos,Holand,Holar,Holen,Holkestad,Holmavik,Hopen,Hovden,Hrafnagil,Hrisey,Husavik,Husvik,Hvammstangi,Hvanneyri,Hveragerdi,Hvolsvollur,Igeroy,Indre,Inndyr,Innhavet,Innes,Isafjordur,Jarklaustur,Jarnsreykir,Junkerdal,Kaldvog,Kanstad,Karlsoy,Kavosen,Keflavik,Kjelde,Kjerstad,Klakk,Kopasker,Kopavogur,Korgen,Kristnes,Krutoga,Krystad,Kvina,Lande,Laugar,Laugaras,Laugarbakki,Laugarvatn,Laupstad,Leines,Leira,Leiren,Leland,Lenvika,Loding,Lodingen,Lonsbakki,Lopsmarka,Lovund,Luroy,Maela,Melahverfi,Meloy,Mevik,Misvaer,Mornes,Mosfellsber,Moskenes,Myken,Naurstad,Nesberg,Nesjahverfi,Nesset,Nevernes,Obygda,Ofoten,Ogskardet,Okervika,Oknes,Olafsfjordur,Oldervika,Olstad,Onstad,Oppeid,Oresvika,Orsnes,Orsvog,Osmyra,Overdal,Prestoya,Raudalaekur,Raufarhofn,Reipo,Reykholar,Reykholt,Reykjahlid,Rif,Rinoya,Rodoy,Rognan,Rosvika,Rovika,Salhus,Sanden,Sandgerdi,Sandoker,Sandset,Sandvika,Saudarkrokur,Selfoss,Selsoya,Sennesvik,Setso,Siglufjordur,Silvalen,Skagastrond,Skjerstad,Skonland,Skorvogen,Skrova,Sleneset,Snubba,Softing,Solheim,Solheimar,Sorarnoy,Sorfugloy,Sorland,Sormela,Sorvaer,Sovika,Stamsund,Stamsvika,Stave,Stokka,Stokkseyri,Storjord,Storo,Storvika,Strand,Straumen,Strendene,Sudavik,Sudureyri,Sundoya,Sydalen,Thingeyri,Thorlakshofn,Thorshofn,Tjarnabyggd,Tjotta,Tosbotn,Traelnes,Trofors,Trones,Tverro,Ulvsvog,Unnstad,Utskor,Valla,Vandved,Varmahlid,Vassos,Vevelstad,Vidrek,Vik,Vikholmen,Vogar,Vogehamn,Vopnafjordur", + }, + { + name: "Greek", + i: 7, + min: 5, + max: 11, + d: "s", + m: 0.1, + b: "Abdera,Acharnae,Aegae,Aegina,Agrinion,Aigosthena,Akragas,Akroinon,Akrotiri,Alalia,Alexandria,Amarynthos,Amaseia,Amphicaea,Amphigeneia,Amphipolis,Antipatrea,Antiochia,Apamea,Aphidna,Apollonia,Argos,Artemita,Argyropolis,Asklepios,Athenai,Athmonia,Bhrytos,Borysthenes,Brauron,Byblos,Byzantion,Bythinion,Calydon,Chamaizi,Chalcis,Chios,Cleona,Corcyra,Croton,Cyrene,Cythera,Decelea,Delos,Delphi,Dicaearchia,Didyma,Dion,Dioscurias,Dodona,Dorylaion,Elateia,Eleusis,Eleutherna,Emporion,Ephesos,Epidamnos,Epidauros,Epizephyrian,Erythrae,Eubea,Golgi,Gonnos,Gorgippia,Gournia,Gortyn,Gytion,Hagios,Halicarnassos,Heliopolis,Hellespontos,Heloros,Heraclea,Hierapolis,Himera,Histria,Hubla,Hyele,Ialysos,Iasos,Idalion,Imbros,Iolcos,Itanos,Ithaca,Juktas,Kallipolis,Kameiros,Karistos,Kasmenai,Kepoi,Kimmerikon,Knossos,Korinthos,Kos,Kourion,Kydonia,Kyrenia,Lamia,Lampsacos,Laodicea,Lapithos,Larissa,Lebena,Lefkada,Lekhaion,Leibethra,Leontinoi,Lilaea,Lindos,Lissos,Magnesia,Mantineia,Marathon,Marmara,Massalia,Megalopolis,Megara,Metapontion,Methumna,Miletos,Morgantina,Mulai,Mukenai,Myonia,Myra,Myrmekion,Myos,Nauplios,Naucratis,Naupaktos,Naxos,Neapolis,Nemea,Nicaea,Nicopolis,Nymphaion,Nysa,Odessos,Olbia,Olympia,Olynthos,Opos,Orchomenos,Oricos,Orestias,Oreos,Onchesmos,Pagasae,Palaikastro,Pandosia,Panticapaion,Paphos,Pargamon,Paros,Pegai,Pelion,Peiraies,Phaistos,Phaleron,Pharos,Pithekussa,Philippopolis,Phocaea,Pinara,Pisa,Pitane,Plataea,Poseidonia,Potidaea,Pseira,Psychro,Pteleos,Pydna,Pylos,Pyrgos,Rhamnos,Rhithymna,Rhypae,Rizinia,Rodos,Salamis,Samos,Skyllaion,Seleucia,Semasos,Sestos,Scidros,Sicyon,,Sinope,Siris,Smyrna,Sozopolis,Sparta,Stagiros,Stratos,Stymphalos,Sybaris,Surakousai,Taras,Tanagra,Tanais,Tauromenion,Tegea,Temnos,Teos,Thapsos,Thassos,Thebai,Theodosia,Therma,Thespian,Thronion,Thoricos,Thurii,Thyreum,Thyria,Tithoraea,Tomis,Tragurion,Tripolis,Troliton,Troy,Tylissos,Tyros,Vathypetros,Zakynthos,Zakros", + }, + { + name: "Roman", + i: 8, + min: 6, + max: 11, + d: "ln", + m: 0.1, + b: "Abila,Adflexum,Adnicrem,Aelia,Aelius,Aeminium,Aequum,Agrippina,Agrippinae,Ala,Albanianis,Aleria,Ambianum,Andautonia,Apulum,Aquae,Aquaegranni,Aquensis,Aquileia,Aquincum,Arae,Argentoratum,Ariminum,Ascrivium,Asturica,Atrebatum,Atuatuca,Augusta,Aurelia,Aurelianorum,Batavar,Batavorum,Belum,Biriciana,Blestium,Bonames,Bonna,Bononia,Borbetomagus,Bovium,Bracara,Brigantium,Burgodunum,Caesaraugusta,Caesarea,Caesaromagus,Calleva,Camulodunum,Cannstatt,Cantiacorum,Capitolina,Caralis,Castellum,Castra,Castrum,Cibalae,Clausentum,Colonia,Concangis,Condate,Confluentes,Conimbriga,Corduba,Coria,Corieltauvorum,Corinium,Coriovallum,Cornoviorum,Danum,Deva,Dianium,Divodurum,Dobunnorum,Drusi,Dubris,Dumnoniorum,Durnovaria,Durocobrivis,Durocornovium,Duroliponte,Durovernum,Durovigutum,Eboracum,Ebusus,Edetanorum,Emerita,Emona,Emporiae,Euracini,Faventia,Flaviae,Florentia,Forum,Gerulata,Gerunda,Gesoscribate,Glevensium,Hadriani,Herculanea,Isca,Italica,Iulia,Iuliobrigensium,Iuvavum,Lactodurum,Lagentium,Lapurdum,Lauri,Legionis,Lemanis,Lentia,Lepidi,Letocetum,Lindinis,Lindum,Lixus,Londinium,Lopodunum,Lousonna,Lucus,Lugdunum,Luguvalium,Lutetia,Mancunium,Marsonia,Martius,Massa,Massilia,Matilo,Mattiacorum,Mediolanum,Mod,Mogontiacum,Moridunum,Mursa,Naissus,Nervia,Nida,Nigrum,Novaesium,Noviomagus,Olicana,Olisippo,Ovilava,Parisiorum,Partiscum,Paterna,Pistoria,Placentia,Pollentia,Pomaria,Pompeii,Pons,Portus,Praetoria,Praetorium,Pullum,Ragusium,Ratae,Raurica,Ravenna,Regina,Regium,Regulbium,Rigomagus,Roma,Romula,Rutupiae,Salassorum,Salernum,Salona,Scalabis,Segovia,Silurum,Sirmium,Siscia,Sorviodurum,Sumelocenna,Tarraco,Taurinorum,Theranda,Traiectum,Treverorum,Tungrorum,Turicum,Ulpia,Valentia,Venetiae,Venta,Verulamium,Vesontio,Vetera,Victoriae,Victrix,Villa,Viminacium,Vindelicorum,Vindobona,Vinovia,Viroconium", + }, + { + name: "Finnic", + i: 9, + min: 5, + max: 11, + d: "akiut", + m: 0, + b: "Aanekoski,Ahlainen,Aholanvaara,Ahtari,Aijala,Akaa,Alajarvi,Antsla,Aspo,Bennas,Bjorkoby,Elva,Emasalo,Espoo,Esse,Evitskog,Forssa,Haapamaki,Haapavesi,Haapsalu,Hameenlinna,Hanko,Harjavalta,Hattuvaara,Hautajarvi,Havumaki,Heinola,Hetta,Hinkabole,Hirmula,Hossa,Huittinen,Husula,Hyryla,Hyvinkaa,Ikaalinen,Iskmo,Itakoski,Jamsa,Jarvenpaa,Jeppo,Jioesuu,Jiogeva,Joensuu,Jokikyla,Jungsund,Jyvaskyla,Kaamasmukka,Kajaani,Kalajoki,Kallaste,Kankaanpaa,Karkku,Karpankyla,Kaskinen,Kasnas,Kauhajoki,Kauhava,Kauniainen,Kauvatsa,Kehra,Kellokoski,Kelottijarvi,Kemi,Kemijarvi,Kerava,Keuruu,Kiljava,Kiuruvesi,Kivesjarvi,Kiviioli,Kivisuo,Klaukkala,Klovskog,Kohtlajarve,Kokemaki,Kokkola,Kolho,Koskue,Kotka,Kouva,Kaupunki,Kuhmo,Kunda,Kuopio,Kuressaare,Kurikka,Kuusamo,Kylmalankyla,Lahti,Laitila,Lankipohja,Lansikyla,Lapua,Laurila,Lautiosaari,Lempaala,Lepsama,Liedakkala,Lieksa,Littoinen,Lohja,Loimaa,Loksa,Loviisa,Malmi,Mantta,Matasvaara,Maula,Miiluranta,Mioisakula,Munapirtti,Mustvee,Muurahainen,Naantali,Nappa,Narpio,Niinimaa,Niinisalo,Nikkila,Nilsia,Nivala,Nokia,Nummela,Nuorgam,Nuvvus,Obbnas,Oitti,Ojakkala,Onninen,Orimattila,Orivesi,Otanmaki,Otava,Otepaa,Oulainen,Oulu,Paavola,Paide,Paimio,Pakankyla,Paldiski,Parainen,Parkumaki,Parola,Perttula,Pieksamaki,Pioltsamaa,Piolva,Pohjavaara,Porhola,Porrasa,Porvoo,Pudasjarvi,Purmo,Pyhajarvi,Raahe,Raasepori,Raisio,Rajamaki,Rakvere,Rapina,Rapla,Rauma,Rautio,Reposaari,Riihimaki,Rovaniemi,Roykka,Ruonala,Ruottala,Rutalahti,Saarijarvi,Salo,Sastamala,Saue,Savonlinna,Seinajoki,Sillamae,Siuntio,Sompujarvi,Suonenjoki,Suurejaani,Syrjantaka,Tamsalu,Tapa,Temmes,Tiorva,Tormasenvaara,Tornio,Tottijarvi,Tulppio,Turenki,Turi,Tuukkala,Tuurala,Tuuri,Tuuski,Tuusniemi,Ulvila,Unari,Upinniemi,Utti,Uusikaupunki,Vaaksy,Vaalimaa,Vaarinmaja,Vaasa,Vainikkala,Valga,Valkeakoski,Vantaa,Varkaus,Vehkapera,Vehmasmaki,Vieki,Vierumaki,Viitasaari,Viljandi,Vilppula,Viohma,Vioru,Virrat,Ylike,Ylivieska,Ylojarvi", + }, + { + name: "Korean", + i: 10, + min: 5, + max: 11, + d: "", + m: 0, + b: "Anjung,Ansan,Anseong,Anyang,Aphae,Apo,Baekseok,Baeksu,Beolgyo,Boeun,Boseong,Busan,Buyeo,Changnyeong,Changwon,Cheonan,Cheongdo,Cheongjin,Cheongsong,Cheongyang,Cheorwon,Chirwon,Chuncheon,Chungju,Daedeok,Daegaya,Daejeon,Damyang,Dangjin,Dasa,Donghae,Dongsong,Doyang,Eonyang,Gaeseong,Ganggyeong,Ganghwa,Gangneung,Ganseong,Gaun,Geochang,Geoje,Geoncheon,Geumho,Geumil,Geumwang,Gijang,Gimcheon,Gimhwa,Gimje,Goa,Gochang,Gohan,Gongdo,Gongju,Goseong,Goyang,Gumi,Gunpo,Gunsan,Guri,Gurye,Gwangju,Gwangyang,Gwansan,Gyeongseong,Hadong,Hamchang,Hampyeong,Hamyeol,Hanam,Hapcheon,Hayang,Heungnam,Hongnong,Hongseong,Hwacheon,Hwando,Hwaseong,Hwasun,Hwawon,Hyangnam,Incheon,Inje,Iri,Janghang,Jangheung,Jangseong,Jangseungpo,Jangsu,Jecheon,Jeju,Jeomchon,Jeongeup,Jeonggwan,Jeongju,Jeongok,Jeongseon,Jeonju,Jido,Jiksan,Jinan,Jincheon,Jindo,Jingeon,Jinjeop,Jinnampo,Jinyeong,Jocheon,Jochiwon,Jori,Maepo,Mangyeong,Mokpo,Muju,Munsan,Naesu,Naju,Namhae,Namwon,Namyang,Namyangju,Nongong,Nonsan,Ocheon,Okcheon,Okgu,Onam,Onsan,Onyang,Opo,Paengseong,Pogok,Poseung,Pungsan,Pyeongchang,Pyeonghae,Pyeongyang,Sabi,Sacheon,Samcheok,Samho,Samrye,Sancheong,Sangdong,Sangju,Sapgyo,Sariwon,Sejong,Seocheon,Seogwipo,Seonghwan,Seongjin,Seongju,Seongnam,Seongsan,Seosan,Seungju,Siheung,Sindong,Sintaein,Soheul,Sokcho,Songak,Songjeong,Songnim,Songtan,Suncheon,Taean,Taebaek,Tongjin,Uijeongbu,Uiryeong,Uiwang,Uljin,Ulleung,Unbong,Ungcheon,Ungjin,Waegwan,Wando,Wayang,Wiryeseong,Wondeok,Yangju,Yangsan,Yangyang,Yecheon,Yeomchi,Yeoncheon,Yeongam,Yeongcheon,Yeongdeok,Yeongdong,Yeonggwang,Yeongju,Yeongwol,Yeongyang,Yeonil,Yongin,Yongjin,Yugu", + }, + { + name: "Chinese", + i: 11, + min: 5, + max: 10, + d: "", + m: 0, + b: "Anding,Anlu,Anqing,Anshun,Baixing,Banyang,Baoqing,Binzhou,Caozhou,Changbai,Changchun,Changde,Changling,Changsha,Changzhou,Chengdu,Chenzhou,Chizhou,Chongqing,Chuxiong,Chuzhou,Dading,Daming,Datong,Daxing,Dengzhou,Deqing,Dihua,Dingli,Dongan,Dongchang,Dongchuan,Dongping,Duyun,Fengtian,Fengxiang,Fengyang,Fenzhou,Funing,Fuzhou,Ganzhou,Gaoyao,Gaozhou,Gongchang,Guangnan,Guangning,Guangping,Guangxin,Guangzhou,Guiyang,Hailong,Hangzhou,Hanyang,Hanzhong,Heihe,Hejian,Henan,Hengzhou,Hezhong,Huaian,Huaiqing,Huanglong,Huangzhou,Huining,Hulan,Huzhou,Jiading,Jian,Jianchang,Jiangning,Jiankang,Jiaxing,Jiayang,Jilin,Jinan,Jingjiang,Jingzhao,Jinhua,Jinzhou,Jiujiang,Kaifeng,Kaihua,Kangding,Kuizhou,Laizhou,Lianzhou,Liaoyang,Lijiang,Linan,Linhuang,Lintao,Liping,Liuzhou,Longan,Longjiang,Longxing,Luan,Lubin,Luzhou,Mishan,Nanan,Nanchang,Nandian,Nankang,Nanyang,Nenjiang,Ningbo,Ningguo,Ningwu,Ningxia,Ningyuan,Pingjiang,Pingliang,Pingyang,Puer,Puzhou,Qianzhou,Qingyang,Qingyuan,Qingzhou,Qujing,Quzhou,Raozhou,Rende,Ruian,Ruizhou,Shafeng,Shajing,Shaoqing,Shaowu,Shaoxing,Shaozhou,Shinan,Shiqian,Shouchun,Shuangcheng,Shulei,Shunde,Shuntian,Shuoping,Sicheng,Sinan,Sizhou,Songjiang,Suiding,Suihua,Suining,Suzhou,Taian,Taibei,Taiping,Taiwan,Taiyuan,Taizhou,Taonan,Tengchong,Tingzhou,Tongchuan,Tongqing,Tongzhou,Weihui,Wensu,Wenzhou,Wuchang,Wuding,Wuzhou,Xian,Xianchun,Xianping,Xijin,Xiliang,Xincheng,Xingan,Xingde,Xinghua,Xingjing,Xingyi,Xingyuan,Xingzhong,Xining,Xinmen,Xiping,Xuanhua,Xunzhou,Xuzhou,Yanan,Yangzhou,Yanji,Yanping,Yanzhou,Yazhou,Yichang,Yidu,Yilan,Yili,Yingchang,Yingde,Yingtian,Yingzhou,Yongchang,Yongping,Yongshun,Yuanzhou,Yuezhou,Yulin,Yunnan,Yunyang,Zezhou,Zhang,Zhangzhou,Zhaoqing,Zhaotong,Zhenan,Zhending,Zhenhai,Zhenjiang,Zhenxi,Zhenyun,Zhongshan,Zunyi", + }, + { + name: "Japanese", + i: 12, + min: 4, + max: 10, + d: "", + m: 0, + b: "Abira,Aga,Aikawa,Aizumisato,Ajigasawa,Akkeshi,Amagi,Ami,Ando,Asakawa,Ashikita,Bandai,Biratori,Chonan,Esashi,Fuchu,Fujimi,Funagata,Genkai,Godo,Goka,Gonohe,Gyokuto,Haboro,Hamatonbetsu,Harima,Hashikami,Hayashima,Heguri,Hidaka,Higashiura,Hiranai,Hirogawa,Hiroo,Hodatsushimizu,Hoki,Hokuei,Hokuryu,Horokanai,Ibigawa,Ichikai,Ichikawa,Ichinohe,Iijima,Iizuna,Ikawa,Inagawa,Itakura,Iwaizumi,Iwate,Kaisei,Kamifurano,Kamiita,Kamijima,Kamikawa,Kamishihoro,Kamiyama,Kanda,Kanna,Kasagi,Kasuya,Katsuura,Kawabe,Kawamoto,Kawanehon,Kawanishi,Kawara,Kawasaki,Kawatana,Kawazu,Kihoku,Kikonai,Kin,Kiso,Kitagata,Kitajima,Kiyama,Kiyosato,Kofu,Koge,Kohoku,Kokonoe,Kora,Kosa,Kotohira,Kudoyama,Kumejima,Kumenan,Kumiyama,Kunitomi,Kurate,Kushimoto,Kutchan,Kyonan,Kyotamba,Mashike,Matsumae,Mifune,Mihama,Minabe,Minami,Minamiechizen,Minamitane,Misaki,Misasa,Misato,Miyashiro,Miyoshi,Mori,Moseushi,Mutsuzawa,Nagaizumi,Nagatoro,Nagayo,Nagomi,Nakadomari,Nakanojo,Nakashibetsu,Namegawa,Nanbu,Nanporo,Naoshima,Nasu,Niseko,Nishihara,Nishiizu,Nishikatsura,Nishikawa,Nishinoshima,Nishiwaga,Nogi,Noto,Nyuzen,Oarai,Obuse,Odai,Ogawara,Oharu,Oirase,Oishida,Oiso,Oizumi,Oji,Okagaki,Okutama,Omu,Ono,Osaka,Otobe,Otsuki,Owani,Reihoku,Rifu,Rikubetsu,Rishiri,Rokunohe,Ryuo,Saka,Sakuho,Samani,Satsuma,Sayo,Saza,Setana,Shakotan,Shibayama,Shikama,Shimamoto,Shimizu,Shintomi,Shirakawa,Shisui,Shitara,Sobetsu,Sue,Sumita,Suooshima,Suttsu,Tabuse,Tachiarai,Tadami,Tadaoka,Taiji,Taiki,Takachiho,Takahama,Taketoyo,Taragi,Tateshina,Tatsugo,Tawaramoto,Teshikaga,Tobe,Tokigawa,Toma,Tomioka,Tonosho,Tosa,Toyokoro,Toyotomi,Toyoyama,Tsubata,Tsubetsu,Tsukigata,Tsuno,Tsuwano,Umi,Wakasa,Yamamoto,Yamanobe,Yamatsuri,Yanaizu,Yasuda,Yoichi,Yonaguni,Yoro,Yoshino,Yubetsu,Yugawara,Yuni,Yusuhara,Yuza", + }, + { + name: "Portuguese", + i: 13, + min: 5, + max: 11, + d: "", + m: 0.1, + b: "Abrigada,Afonsoeiro,Agueda,Aguilada,Alagoas,Alagoinhas,Albufeira,Alcanhoes,Alcobaca,Alcoutim,Aldoar,Alenquer,Alfeizerao,Algarve,Almada,Almagreira,Almeirim,Alpalhao,Alpedrinha,Alvorada,Amieira,Anapolis,Apelacao,Aranhas,Arganil,Armacao,Assenceira,Aveiro,Avelar,Balsas,Barcarena,Barreiras,Barretos,Batalha,Beira,Benavente,Betim,Braga,Braganca,Brasilia,Brejo,Cabeceiras,Cabedelo,Cachoeiras,Cadafais,Calhandriz,Calheta,Caminha,Campinas,Canidelo,Canoas,Capinha,Carmoes,Cartaxo,Carvalhal,Carvoeiro,Cascavel,Castanhal,Caxias,Chapadinha,Chaves,Cocais,Coentral,Coimbra,Comporta,Conde,Coqueirinho,Coruche,Damaia,Dourados,Enxames,Ericeira,Ervidel,Escalhao,Esmoriz,Espinhal,Estela,Estoril,Eunapolis,Evora,Famalicao,Fanhoes,Faro,Fatima,Felgueiras,Ferreira,Figueira,Flecheiras,Florianopolis,Fornalhas,Fortaleza,Freiria,Freixeira,Fronteira,Fundao,Gracas,Gradil,Grainho,Gralheira,Guimaraes,Horta,Ilhavo,Ilheus,Lages,Lagos,Laranjeiras,Lavacolhos,Leiria,Limoeiro,Linhares,Lisboa,Lomba,Lorvao,Lourical,Lourinha,Luziania,Macedo,Machava,Malveira,Marinhais,Maxial,Mealhada,Milharado,Mira,Mirandela,Mogadouro,Montalegre,Mourao,Nespereira,Nilopolis,Obidos,Odemira,Odivelas,Oeiras,Oleiros,Olhalvo,Olinda,Olival,Oliveira,Oliveirinha,Palheiros,Palmeira,Palmital,Pampilhosa,Pantanal,Paradinha,Parelheiros,Pedrosinho,Pegoes,Penafiel,Peniche,Pinhao,Pinheiro,Pombal,Pontal,Pontinha,Portel,Portimao,Quarteira,Queluz,Ramalhal,Reboleira,Recife,Redinha,Ribadouro,Ribeira,Ribeirao,Rosais,Sabugal,Sacavem,Sagres,Sandim,Sangalhos,Santarem,Santos,Sarilhos,Seixas,Seixezelo,Seixo,Silvares,Silveira,Sinhaem,Sintra,Sobral,Sobralinho,Tabuaco,Tabuleiro,Taveiro,Teixoso,Telhado,Telheiro,Tomar,Torreira,Trancoso,Troviscal,Vagos,Varzea,Velas,Viamao,Viana,Vidigal,Vidigueira,Vidual,Vilamar,Vimeiro,Vinhais,Vitoria", + }, + { + name: "Nahuatl", + i: 14, + min: 6, + max: 13, + d: "l", + m: 0, + b: "Acapulco,Acatepec,Acatlan,Acaxochitlan,Acolman,Actopan,Acuamanala,Ahuacatlan,Almoloya,Amacuzac,Amanalco,Amaxac,Apaxco,Apetatitlan,Apizaco,Atenco,Atizapan,Atlacomulco,Atlapexco,Atotonilco,Axapusco,Axochiapan,Axocomanitla,Axutla,Azcapotzalco,Aztahuacan,Calimaya,Calnali,Calpulalpan,Camotlan,Capulhuac,Chalco,Chapulhuacan,Chapultepec,Chiapan,Chiautempan,Chiconautla,Chihuahua,Chilcuautla,Chimalhuacan,Cholollan,Cihuatlan,Coahuila,Coatepec,Coatetelco,Coatlan,Coatlinchan,Coatzacoalcos,Cocotitlan,Cohetzala,Colima,Colotlan,Coyoacan,Coyohuacan,Cuapiaxtla,Cuauhnahuac,Cuauhtemoc,Cuauhtitlan,Cuautepec,Cuautla,Cuaxomulco,Culhuacan,Ecatepec,Eloxochitlan,Epatlan,Epazoyucan,Huamantla,Huascazaloya,Huatlatlauca,Huautla,Huehuetlan,Huehuetoca,Huexotla,Hueyapan,Hueyotlipan,Hueypoxtla,Huichapan,Huimilpan,Huitzilac,Ixtapallocan,Iztacalco,Iztaccihuatl,Iztapalapa,Lolotla,Malinalco,Mapachtlan,Mazatepec,Mazatlan,Metepec,Metztitlan,Mexico,Miacatlan,Michoacan,Minatitlan,Mixcoac,Mixtla,Molcaxac,Nanacamilpa,Naucalpan,Naupan,Nextlalpan,Nezahualcoyotl,Nopalucan,Oaxaca,Ocotepec,Ocotitlan,Ocotlan,Ocoyoacac,Ocuilan,Ocuituco,Omitlan,Otompan,Otzoloapan,Pacula,Pahuatlan,Panotla,Papalotla,Patlachican,Piaztla,Popocatepetl,Sultepec,Tecamac,Tecolotlan,Tecozautla,Temamatla,Temascalapa,Temixco,Temoac,Temoaya,Tenayuca,Tenochtitlan,Teocuitlatlan,Teotihuacan,Teotlalco,Tepeacac,Tepeapulco,Tepehuacan,Tepetitlan,Tepeyanco,Tepotzotlan,Tepoztlan,Tetecala,Tetlatlahuca,Texcalyacac,Texcoco,Tezontepec,Tezoyuca,Timilpan,Tizapan,Tizayuca,Tlacopan,Tlacotenco,Tlahuac,Tlahuelilpan,Tlahuiltepa,Tlalmanalco,Tlalnepantla,Tlalpan,Tlanchinol,Tlatelolco,Tlaxcala,Tlaxcoapan,Tlayacapan,Tocatlan,Tolcayuca,Toluca,Tonanitla,Tonantzintla,Tonatico,Totolac,Totolapan,Tototlan,Tuchtlan,Tulantepec,Tultepec,Tzompantepec,Xalatlaco,Xaloztoc,Xaltocan,Xiloxoxtla,Xochiatipan,Xochicoatlan,Xochimilco,Xochitepec,Xolotlan,Xonacatlan,Yahualica,Yautepec,Yecapixtla,Yehaultepec,Zacatecas,Zacazonapan,Zacoalco,Zacualpan,Zacualtipan,Zapotlan,Zimapan,Zinacantepec,Zoyaltepec,Zumpahuacan", + }, + { + name: "Hungarian", + i: 15, + min: 6, + max: 13, + d: "", + m: 0.1, + b: "Aba,Abadszalok,Adony,Ajak,Albertirsa,Alsozsolca,Aszod,Babolna,Bacsalmas,Baktaloranthaza,Balassagyarmat,Balatonalmadi,Balatonboglar,Balkany,Balmazujvaros,Barcs,Bataszek,Batonyterenye,Battonya,Bekes,Berettyoujfalu,Berhida,Biatorbagy,Bicske,Biharkeresztes,Bodajk,Boly,Bonyhad,Budakalasz,Budakeszi,Celldomolk,Csakvar,Csenger,Csongrad,Csorna,Csorvas,Csurgo,Dabas,Demecser,Derecske,Devavanya,Devecser,Dombovar,Dombrad,Dunafoldvar,Dunaharaszti,Dunavarsany,Dunavecse,Edeleny,Elek,Emod,Encs,Enying,Ercsi,Fegyvernek,Fehergyarmat,Felsozsolca,Fertoszentmiklos,Fonyod,Fot,Fuzesabony,Fuzesgyarmat,Gardony,God,Gyal,Gyomaendrod,Gyomro,Hajdudorog,Hajduhadhaz,Hajdusamson,Hajduszoboszlo,Halasztelek,Harkany,Hatvan,Heves,Heviz,Ibrany,Isaszeg,Izsak,Janoshalma,Janossomorja,Jaszapati,Jaszarokszallas,Jaszfenyszaru,Jaszkiser,Kaba,Kalocsa,Kapuvar,Karcag,Kecel,Kemecse,Kenderes,Kerekegyhaza,Keszthely,Kisber,Kiskunmajsa,Kistarcsa,Kistelek,Kisujszallas,Kisvarda,Komadi,Komarom,Komlo,Kormend,Korosladany,Koszeg,Kozarmisleny,Kunhegyes,Kunszentmarton,Kunszentmiklos,Labatlan,Lajosmizse,Lenti,Letavertes,Letenye,Lorinci,Maglod,Mako,Mandok,Marcali,Martonvasar,Mateszalka,Melykut,Mezobereny,Mezocsat,Mezohegyes,Mezokeresztes,Mezokovesd,Mezotur,Mindszent,Mohacs,Monor,Mor,Morahalom,Nadudvar,Nagyatad,Nagyecsed,Nagyhalasz,Nagykallo,Nagykoros,Nagymaros,Nyekladhaza,Nyergesujfalu,Nyirbator,Nyirmada,Nyirtelek,Ocsa,Orkeny,Oroszlany,Paks,Pannonhalma,Paszto,Pecel,Pecsvarad,Pilisvorosvar,Polgar,Polgardi,Pomaz,Puspokladany,Pusztaszabolcs,Putnok,Racalmas,Rackeve,Rakamaz,Rakoczifalva,Sajoszent,Sandorfalva,Sarbogard,Sarkad,Sarospatak,Sarvar,Satoraljaujhely,Siklos,Simontornya,Soltvadkert,Sumeg,Szabadszallas,Szarvas,Szazhalombatta,Szecseny,Szeghalom,Szentgotthard,Szentlorinc,Szerencs,Szigethalom,Szigetvar,Szikszo,Tab,Tamasi,Tapioszele,Tapolca,Teglas,Tet,Tiszafoldvar,Tiszafured,Tiszakecske,Tiszalok,Tiszaujvaros,Tiszavasvari,Tokaj,Tokol,Tompa,Torokbalint,Torokszentmiklos,Totkomlos,Tura,Turkeve,Ujkigyos,ujszasz,Vamospercs,Varpalota,Vasarosnameny,Vasvar,Vecses,Veresegyhaz,Verpelet,Veszto,Zahony,Zalaszentgrot,Zirc,Zsambek", + }, + { + name: "Turkish", + i: 16, + min: 4, + max: 10, + d: "", + m: 0, + b: "Yelkaya,Buyrukkaya,Erdemtepe,Alakesen,Baharbeyli,Bozbay,Karaoklu,Altunbey,Yalkale,Yalkut,Akardere,Altayburnu,Esentepe,Okbelen,Derinsu,Alaoba,Yamanbeyli,Aykor,Ekinova,Saztepe,Baharkale,Devrekdibi,Alpseki,Ormanseki,Erkale,Yalbelen,Aytay,Yamanyaka,Altaydelen,Esen,Yedieli,Alpkor,Demirkor,Yediyol,Erdemkaya,Yayburnu,Ganiler,Bayatyurt,Kopuzteke,Aytepe,Deniz,Ayan,Ayazdere,Tepe,Kayra,Ayyaka,Deren,Adatepe,Kalkaneli,Bozkale,Yedidelen,Kocayolu,Sazdere,Bozkesen,Oguzeli,Yayladibi,Uluyol,Altay,Ayvar,Alazyaka,Yaloba,Suyaka,Baltaberi,Poyrazdelen,Eymir,Yediyuva,Kurt,Yeltepe,Oktar,Kara Ok,Ekinberi,Er Yurdu,Eren,Erenler,Ser,Oguz,Asay,Bozokeli,Aykut,Ormanyol,Yazkaya,Kalkanova,Yazbeyli,Dokuz Teke,Bilge,Ertensuyu,Kopuzyuva,Buyrukkut,Akardiken,Aybaray,Aslanbeyli,Altun Kaynak,Atikobasi,Yayla Eli,Kor Tepe,Salureli,Kor Kaya,Aybarberi,Kemerev,Yanaray,Beydileli,Buyrukoba,Yolduman,Tengri Tepe,Dokuzsu,Uzunkor,Erdem Yurdu,Kemer,Korteke,Bozokev,Bozoba,Ormankale,Askale,Oguztoprak,Yolberi,Kumseki,Esenobasi,Turkbelen,Ayazseki,Cereneli,Taykut,Bayramdelen,Beydilyaka,Boztepe,Uluoba,Yelyaka,Ulgardiken,Esensu,Baykale,Cerenkor,Bozyol,Duranoba,Aladuman,Denizli,Bahar,Yarkesen,Dokuzer,Yamankaya,Kocatarla,Alayaka,Toprakeli,Sarptarla,Sarpkoy,Serkaynak,Adayaka,Ayazkaynak,Kopuz,Turk,Kart,Kum,Erten,Buyruk,Yel,Ada,Alazova,Ayvarduman,Buyrukok,Ayvartoprak,Uzuntepe,Binseki,Yedibey,Durankale,Alaztoprak,Sarp Ok,Yaparobasi,Yaytepe,Asberi,Kalkankor,Beydiltepe,Adaberi,Bilgeyolu,Ganiyurt,Alkanteke,Esenerler,Asbey,Erdemkale,Erenkaynak,Oguzkoyu,Ayazoba,Boynuztoprak,Okova,Yaloklu,Sivriberi,Yuladiken,Sazbey,Karakaynak,Kopuzkoyu,Buyrukay,Kocakaya,Tepeduman,Yanarseki,Atikyurt,Esenev,Akarbeyli,Yayteke,Devreksungur,Akseki,Baykut,Kalkandere,Ulgarova,Devrekev,Yulabey,Bayatev,Yazsu,Vuraleli,Sivribeyli,Alaova,Alpobasi,Yalyurt,Elmatoprak,Alazkaynak,Esenay,Ertenev,Salurkor,Ekinok,Yalbey,Yeldere,Ganibay,Altaykut,Baltaboy,Ereli,Ayvarsu,Uzunsaz,Bayeli,Erenyol,Kocabay,Derintay,Ayazyol,Aslanoba,Esenkaynak,Ekinlik,Alpyolu,Alayunt,Bozeski,Erkil,Duransuyu,Yulak,Kut,Dodurga,Kutlubey,Kutluyurt,Boynuz,Alayol,Aybar,Aslaneli,Kemerseki,Baltasuyu,Akarer,Ayvarburnu,Boynuzbeyli,Adasungur,Esenkor,Yamanoba,Toprakkor,Uzunyurt,Sungur,Bozok,Kemerli,Alaz,Demirci,Kartepe", + }, + { + name: "Berber", + i: 17, + min: 4, + max: 10, + d: "s", + m: 0.2, + b: "Abkhouch,Adrar,Aeraysh,Afrag,Agadir,Agelmam,Aghmat,Agrakal,Agulmam,Ahaggar,Ait Baha,Ajdir,Akka,Almou,Amegdul,Amizmiz,Amknas,Amlil,Amurakush,Anfa,Annaba,Aousja,Arbat,Arfud,Argoub,Arif,Asfi,Asfru,Ashawen,Assamer,Assif,Awlluz,Ayt Melel,Azaghar,Azila,Azilal,Azmour,Azro,Azrou,Beccar,Beja,Bennour,Benslimane,Berkane,Berrechid,Bizerte,Bjaed,Bouayach,Boudenib,Boufrah,Bouskoura,Boutferda,Darallouch,Dar Bouazza,Darchaabane,Dcheira,Demnat,Denden,Djebel,Djedeida,Drargua,Elhusima,Essaouira,Ezzahra,Fas,Fnideq,Ghezeze,Goubellat,Grisaffen,Guelmim,Guercif,Hammamet,Harrouda,Hdifa,Hoceima,Houara,Idhan,Idurar,Ifendassen,Ifoghas,Ifrane,Ighoud,Ikbir,Imilchil,Imzuren,Inezgane,Irherm,Izoughar,Jendouba,Kacem,Kelibia,Kenitra,Kerrando,Khalidia,Khemisset,Khenifra,Khouribga,Khourigba,Kidal,Korba,Korbous,Lahraouyine,Larache,Leyun,Lqliaa,Manouba,Martil,Mazagan,Mcherga,Mdiq,Megrine,Mellal,Melloul,Midelt,Misur,Mohammedia,Mornag,Mrirt,Nabeul,Nadhour,Nador,Nawaksut,Nefza,Ouarzazate,Ouazzane,Oued Zem,Oujda,Ouladteima,Qsentina,Rades,Rafraf,Safi,Sefrou,Sejnane,Settat,Sijilmassa,Skhirat,Slimane,Somaa,Sraghna,Susa,Tabarka,Tadrart,Taferka,Tafilalt,Tafrawt,Tafza,Tagbalut,Tagerdayt,Taghzut,Takelsa,Taliouine,Tanja,Tantan,Taourirt,Targuist,Taroudant,Tarudant,Tasfelalayt,Tassort,Tata,Tattiwin,Tawnat,Taza,Tazagurt,Tazerka,Tazizawt,Taznakht,Tebourba,Teboursouk,Temara,Testour,Tetouan,Tibeskert,Tifelt,Tijdit,Tinariwen,Tinduf,Tinja,Tittawan,Tiznit,Toubkal,Trables,Tubqal,Tunes,Ultasila,Urup,Wagguten,Wararni,Warzazat,Watlas,Wehran,Wejda,Xamida,Yedder,Youssoufia,Zaghouan,Zahret,Zemmour,Zriba", + }, + { + name: "Arabic", + i: 18, + min: 4, + max: 9, + d: "ae", + m: 0.2, + b: "Abha,Ajman,Alabar,Alarjam,Alashraf,Alawali,Albawadi,Albirk,Aldhabiyah,Alduwaid,Alfareeq,Algayed,Alhazim,Alhrateem,Alhudaydah,Alhuwaya,Aljahra,Aljubail,Alkhafah,Alkhalas,Alkhawaneej,Alkhen,Alkhobar,Alkhuznah,Allisafah,Almshaykh,Almurjan,Almuwayh,Almuzaylif,Alnaheem,Alnashifah,Alqah,Alqouz,Alqurayyat,Alradha,Alraqmiah,Alsadyah,Alsafa,Alshagab,Alshuqaiq,Alsilaa,Althafeer,Alwasqah,Amaq,Amran,Annaseem,Aqbiyah,Arafat,Arar,Ardah,Asfan,Ashayrah,Askar,Ayaar,Aziziyah,Baesh,Bahrah,Balhaf,Banizayd,Bidiyah,Bisha,Biyatah,Buqhayq,Burayda,Dafiyat,Damad,Dammam,Dariyah,Dhafar,Dhahran,Dhalkut,Dhurma,Dibab,Doha,Dukhan,Duwaibah,Enaker,Fadhla,Fahaheel,Fanateer,Farasan,Fardah,Fujairah,Ghalilah,Ghar,Ghizlan,Ghomgyah,Ghran,Hadiyah,Haffah,Hajanbah,Hajrah,Haqqaq,Haradh,Hasar,Hawiyah,Hebaa,Hefar,Hijal,Husnah,Huwailat,Huwaitah,Irqah,Isharah,Ithrah,Jamalah,Jarab,Jareef,Jazan,Jeddah,Jiblah,Jihanah,Jilah,Jizan,Joraibah,Juban,Jumeirah,Kamaran,Keyad,Khab,Khaiybar,Khasab,Khathirah,Khawarah,Khulais,Kumzar,Limah,Linah,Madrak,Mahab,Mahalah,Makhtar,Mashwar,Masirah,Masliyah,Mastabah,Mazhar,Medina,Meeqat,Mirbah,Mokhtara,Muharraq,Muladdah,Musaykah,Mushayrif,Musrah,Mussafah,Nafhan,Najran,Nakhab,Nizwa,Oman,Qadah,Qalhat,Qamrah,Qasam,Qosmah,Qurain,Quriyat,Qurwa,Radaa,Rafha,Rahlah,Rakamah,Rasheedah,Rasmadrakah,Risabah,Rustaq,Ryadh,Sabtaljarah,Sadah,Safinah,Saham,Saihat,Salalah,Salmiya,Shabwah,Shalim,Shaqra,Sharjah,Sharurah,Shatifiyah,Shidah,Shihar,Shoqra,Shuwaq,Sibah,Sihmah,Sinaw,Sirwah,Sohar,Suhailah,Sulaibiya,Sunbah,Tabuk,Taif,Taqah,Tarif,Tharban,Thuqbah,Thuwal,Tubarjal,Turaif,Turbah,Tuwaiq,Ubar,Umaljerem,Urayarah,Urwah,Wabrah,Warbah,Yabreen,Yadamah,Yafur,Yarim,Yemen,Yiyallah,Zabid,Zahwah,Zallaq,Zinjibar,Zulumah", + }, + { + name: "Inuit", + i: 19, + min: 5, + max: 15, + d: "alutsn", + m: 0, + b: "Aaluik,Aappilattoq,Aasiaat,Agissat,Agssaussat,Akuliarutsip,Akunnaaq,Alluitsup,Alluttoq,Amitsorsuaq,Ammassalik,Anarusuk,Anguniartarfik,Annertussoq,Annikitsoq,Apparsuit,Apusiaajik,Arsivik,Arsuk,Atammik,Ateqanaq,Atilissuaq,Attu,Augpalugtoq,Aukarnersuaq,Aumat,Auvilkikavsaup,Avadtlek,Avallersuaq,Bjornesk,Blabaerdalen,Blomsterdalen,Brattalhid,Bredebrae,Brededal,Claushavn,Edderfulegoer,Egger,Eqalugalinnguit,Eqalugarssuit,Eqaluit,Eqqua,Etah,Graah,Hakluyt,Haredalen,Hareoen,Hundeo,Igaliku,Igdlorssuit,Igdluluarssuk,Iginniafik,Ikamiut,Ikarissat,Ikateq,Ikermiut,Ikermoissuaq,Ikorfarssuit,Ilimanaq,Illorsuit,Illunnguit,Iluileq,Ilulissat,Imaarsivik,Imartunarssuk,Immikkoortukajik,Innaarsuit,Inneruulalik,Inussullissuaq,Iperaq,Ippik,Iqek,Isortok,Isungartussoq,Itileq,Itissaalik,Itivdleq,Ittit,Ittoqqortoormiit,Ivingmiut,Ivittuut,Kanajoorartuut,Kangaamiut,Kangeq,Kangerluk,Kangerlussuaq,Kanglinnguit,Kapisillit,Kekertamiut,Kiatak,Kiataussaq,Kigatak,Kinaussak,Kingittorsuaq,Kitak,Kitsissuarsuit,Kitsissut,Klenczner,Kook,Kraulshavn,Kujalleq,Kullorsuaq,Kulusuk,Kuurmiit,Kuusuaq,Laksedalen,Maniitsoq,Marrakajik,Mattaangassut,Mernoq,Mittivakkat,Moriusaq,Myggbukta,Naajaat,Nangissat,Nanuuseq,Nappassoq,Narsarmijt,Narsarsuaq,Narssaq,Nasiffik,Natsiarsiorfik,Naujanguit,Niaqornaarsuk,Niaqornat,Nordfjordspasset,Nugatsiaq,Nunarssit,Nunarsuaq,Nunataaq,Nunatakavsaup,Nutaarmiut,Nuugaatsiaq,Nuuk,Nuukullak,Olonkinbyen,Oodaaq,Oqaatsut,Oqaitsunguit,Oqonermiut,Paagussat,Paamiut,Paatuut,Palungataq,Pamialluk,Perserajoq,Pituffik,Puugutaa,Puulkuip,Qaanaq,Qaasuitsup,Qaersut,Qajartalik,Qallunaat,Qaneq,Qaqortok,Qasigiannguit,Qassimiut,Qeertartivaq,Qeqertaq,Qeqertasussuk,Qeqqata,Qernertoq,Qernertunnguit,Qianarreq,Qingagssat,Qoornuup,Qorlortorsuaq,Qullikorsuit,Qunnerit,Qutdleq,Ravnedalen,Ritenbenk,Rypedalen,Saarloq,Saatorsuaq,Saattut,Salliaruseq,Sammeqqat,Sammisoq,Sanningassoq,Saqqaq,Saqqarlersuaq,Saqqarliit,Sarfannguit,Sattiaatteq,Savissivik,Serfanguaq,Sermersooq,Sermiligaaq,Sermilik,Sermitsiaq,Simitakaja,Simiutaq,Singamaq,Siorapaluk,Sisimiut,Sisuarsuit,Sullorsuaq,Suunikajik,Sverdrup,Taartoq,Takiseeq,Tasirliaq,Tasiusak,Tiilerilaaq,Timilersua,Timmiarmiut,Tukingassoq,Tussaaq,Tuttulissuup,Tuujuk,Uiivaq,Uilortussoq,Ujuaakajiip,Ukkusissat,Upernavik,Uttorsiutit,Uumannaq,Uunartoq,Uvkusigssat,Ymer", + }, + { + name: "Basque", + i: 20, + min: 4, + max: 11, + d: "r", + m: 0.1, + b: "Agurain,Aia,Aiara,Albiztur,Alkiza,Altzaga,Amorebieta,Amurrio,Andoain,Anoeta,Antzuola,Arakaldo,Arantzazu,Arbatzegi,Areatza,Arratzua,Arrieta,Artea,Artziniega,Asteasu,Astigarraga,Ataun,Atxondo,Aulesti,Azkoitia,Azpeitia,Bakio,Baliarrain,Barakaldo,Barrika,Barrundia,Basauri,Beasain,Bedia,Beizama,Belauntza,Berastegi,Bergara,Bermeo,Bernedo,Berriatua,Berriz,Bidania,Bilar,Bilbao,Busturia,Deba,Derio,Donostia,Dulantzi,Durango,Ea,Eibar,Elantxobe,Elduain,Elgeta,Elgoibar,Elorrio,Erandio,Ergoitia,Ermua,Errenteria,Errezil,Eskoriatza,Eskuernaga,Etxebarri,Etxebarria,Ezkio,Forua,Gabiria,Gaintza,Galdakao,Gamiz,Garai,Gasteiz,Gatzaga,Gaubea,Gautegiz,Gaztelu,Gernika,Gerrikaitz,Getaria,Getxo,Gizaburuaga,Goiatz,Gorliz,Gorriaga,Harana,Hernani,Hondarribia,Ibarra,Ibarrangelu,Idiazabal,Iekora,Igorre,Ikaztegieta,Irun,Irura,Iruraiz,Itsaso,Itsasondo,Iurreta,Izurtza,Jatabe,Kanpezu,Karrantza,Kortezubi,Kripan,Kuartango,Lanestosa,Lantziego,Larrabetzu,Lasarte,Laukiz,Lazkao,Leaburu,Legazpi,Legorreta,Legutio,Leintz,Leioa,Lekeitio,Lemoa,Lemoiz,Leza,Lezama,Lezo,Lizartza,Maeztu,Mallabia,Manaria,Markina,Maruri,Menaka,Mendaro,Mendata,Mendexa,Morga,Mundaka,Mungia,Munitibar,Murueta,Muskiz,Mutiloa,Mutriku,Nabarniz,Oiartzun,Oion,Okondo,Olaberria,Onati,Ondarroa,Ordizia,Orendain,Orexa,Oria,Orio,Ormaiztegi,Orozko,Ortuella,Otegi,Otxandio,Pasaia,Plentzia,Santurtzi,Sestao,Sondika,Soraluze,Sukarrieta,Tolosa,Trapagaran,Turtzioz,Ubarrundia,Ubide,Ugao,Urdua,Urduliz,Urizaharra,Urkabustaiz,Urnieta,Urretxu,Usurbil,Xemein,Zabaleta,Zaia,Zaldibar,Zambrana,Zamudio,Zaratamo,Zarautz,Zeberio,Zegama,Zerain,Zestoa,Zierbena,Zigoitia,Ziortza,Zuia,Zumaia,Zumarraga", + }, + { + name: "Nigerian", + i: 21, + min: 4, + max: 10, + d: "", + m: 0.3, + b: "Abadogo,Abafon,Adealesu,Adeto,Adyongo,Afaga,Afamju,Agigbigi,Agogoke,Ahute,Aiyelaboro,Ajebe,Ajola,Akarekwu,Akunuba,Alawode,Alkaijji,Amangam,Amgbaye,Amtasa,Amunigun,Animahun,Anyoko,Arapagi,Asande,Awgbagba,Awhum,Awodu,Babateduwa,Bandakwai,Bangdi,Bilikani,Birnindodo,Braidu,Bulakawa,Buriburi,Cainnan,Chakum,Chondugh,Dagwarga,Darpi,Dokatofa,Dozere,Ebelibri,Efem,Ekoku,Ekpe,Ewhoeviri,Galea,Gamen,Ganjin,Gantetudu,Gargar,Garinbode,Gbure,Gerti,Gidan,Gitabaremu,Giyagiri,Giyawa,Gmawa,Golakochi,Golumba,Gunji,Gwambula,Gwodoti,Hayinlere,Hayinmaialewa,Hirishi,Hombo,Ibefum,Iberekodo,Icharge,Idofin,Idofinoka,Igbogo,Ijoko,Ijuwa,Ikawga,Ikhin,Ikpakidout,Ikpeoniong,Imuogo,Ipawo,Ipinlerere,Isicha,Itakpa,Jangi,Jare,Jataudakum,Jaurogomki,Jepel,Kafinmalama,Katab,Katanga,Katinda,Katirije,Kaurakimba,Keffinshanu,Kellumiri,Kiagbodor,Kirbutu,Kita,Kogogo,Kopje,Korokorosei,Kotoku,Kuata,Kujum,Kukau,Kunboon,Kuonubogbene,Kurawe,Kushinahu,Kwaramakeri,Ladimeji,Lafiaro,Lahaga,Laindebajanle,Laindegoro,Lakati,Litenswa,Maba,Madarzai,Maianita,Malikansaa,Mata,Megoyo,Meku,Miama,Modi,Mshi,Msugh,Muduvu,Murnachehu,Namnai,Ndamanma,Ndiwulunbe,Ndonutim,Ngbande,Nguengu,Ntoekpe,Nyajo,Nyior,Odajie,Ogbaga,Ogultu,Ogunbunmi,Ojopode,Okehin,Olugunna,Omotunde,Onipede,Onma,Orhere,Orya,Otukwang,Otunade,Rampa,Rimi,Rugan,Rumbukawa,Sabiu,Sangabama,Sarabe,Seboregetore,Shafar,Shagwa,Shata,Shengu,Sokoron,Sunnayu,Tafoki,Takula,Talontan,Tarhemba,Tayu,Ter,Timtim,Timyam,Tindirke,Tokunbo,Torlwam,Tseakaadza,Tseanongo,Tsebeeve,Tsepaegh,Tuba,Tumbo,Tungalombo,Tunganyakwe,Uhkirhi,Umoru,Umuabai,Umuajuju,Unchida,Ungua,Unguwar,Unongo,Usha,Utongbo,Vembera,Wuro,Yanbashi,Yanmedi,Yoku,Zarunkwari,Zilumo,Zulika", + }, + { + name: "Celtic", + i: 22, + min: 4, + max: 12, + d: "nld", + m: 0, + b: "Aberaman,Aberangell,Aberarth,Aberavon,Aberbanc,Aberbargoed,Aberbeeg,Abercanaid,Abercarn,Abercastle,Abercegir,Abercraf,Abercregan,Abercych,Abercynon,Aberdare,Aberdaron,Aberdaugleddau,Aberdeen,Aberdulais,Aberdyfi,Aberedw,Abereiddy,Abererch,Abereron,Aberfan,Aberffraw,Aberffrwd,Abergavenny,Abergele,Aberglasslyn,Abergorlech,Abergwaun,Abergwesyn,Abergwili,Abergwynfi,Abergwyngregyn,Abergynolwyn,Aberhafesp,Aberhonddu,Aberkenfig,Aberllefenni,Abermain,Abermaw,Abermorddu,Abermule,Abernant,Aberpennar,Aberporth,Aberriw,Abersoch,Abersychan,Abertawe,Aberteifi,Aberthin,Abertillery,Abertridwr,Aberystwyth,Achininver,Afonhafren,Alisaha,Anfosadh,Antinbhearmor,Ardenna,Attacon,Banwen,Beira,Bhrura,Bleddfa,Boioduro,Bona,Boskyny,Boslowenpolbrogh,Boudobriga,Bravon,Brigant,Briganta,Briva,Brosnach,Caersws,Cambodunum,Cambra,Caracta,Catumagos,Centobriga,Ceredigion,Chalain,Chearbhallain,Chlasaigh,Chormaic,Cuileannach,Dinn,Diwa,Dubingen,Duibhidighe,Duro,Ebora,Ebruac,Eburodunum,Eccles,Egloskuri,Eighe,Eireann,Elerghi,Ferkunos,Fhlaithnin,Gallbhuaile,Genua,Ghrainnse,Gwyles,Heartsease,Hebron,Hordh,Inbhear,Inbhir,Inbhirair,Innerleithen,Innerleven,Innerwick,Inver,Inveraldie,Inverallan,Inveralmond,Inveramsay,Inveran,Inveraray,Inverarnan,Inverbervie,Inverclyde,Inverell,Inveresk,Inverfarigaig,Invergarry,Invergordon,Invergowrie,Inverhaddon,Inverkeilor,Inverkeithing,Inverkeithney,Inverkip,Inverleigh,Inverleith,Inverloch,Inverlochlarig,Inverlochy,Invermay,Invermoriston,Inverness,Inveroran,Invershin,Inversnaid,Invertrossachs,Inverugie,Inveruglas,Inverurie,Iubhrach,Karardhek,Kilninver,Kirkcaldy,Kirkintilloch,Krake,Lanngorrow,Latense,Leming,Lindomagos,Llanaber,Llandidiwg,Llandyrnog,Llanfarthyn,Llangadwaldr,Llansanwyr,Lochinver,Lugduno,Magoduro,Mheara,Monmouthshire,Nanshiryarth,Narann,Novioduno,Nowijonago,Octoduron,Penning,Pheofharain,Ponsmeur,Raithin,Ricomago,Rossinver,Salodurum,Seguia,Sentica,Theorsa,Tobargeal,Trealaw,Trefesgob,Trewedhenek,Trewythelan,Tuaisceart,Uige,Vitodurum,Windobona", + }, + { + name: "Mesopotamian", + i: 23, + min: 4, + max: 9, + d: "srpl", + m: 0.1, + b: "Adab,Adamndun,Adma,Admatum,Agrab,Akkad,Akshak,Amnanum,Andarig,Anshan,Apiru,Apum,Arantu,Arbid,Arpachiyah,Arpad,Arrapha,Ashlakka,Assur,Awan,Babilim,Bad-Tibira,Balawat,Barsip,Birtu,Bit-Bunakki,Borsippa,Chuera,Dashrah,Der,Dilbat,Diniktum,Doura,Dur-Kurigalzu,Dur-Sharrukin,Dur-Untash,Dûr-gurgurri,Ebla,Ekallatum,Ekalte,Emar,Erbil,Eresh,Eridu,Eshnunn,Eshnunna,Gargamish,Gasur,Gawra,Gibil,Girsu,Gizza,Habirun,Habur,Hadatu,Hakkulan,Halab,Halabit,Hamazi,Hamoukar,Haradum,Harbidum,Harran,Harranu,Hassuna,Hatarikka,Hatra,Hissar,Hiyawa,Hormirzad,Ida-Maras,Idamaraz,Idu,Imerishu,Imgur-Enlil,Irisagrig,Irnina,Irridu,Isin,Issinnitum,Iturungal,Izubitum,Jarmo,Jemdet,Kabnak,Kadesh,Kahat,Kalhu,Kar-Shulmanu-Asharedu,Kar-Tukulti-Ninurta,Kar-shulmanu-asharedu,Karana,Karatepe,Kartukulti,Kazallu,Kesh,Kidsha,Kinza,Kish,Kisiga,Kisurra,Kuara,Kurda,Kurruhanni,Kutha,Lagaba,Lagash,Larak,Larsa,Leilan,Malgium,Marad,Mardaman,Mari,Marlik,Mashkan,Mashkan-shapir,Matutem,Me-Turan,Meliddu,Mumbaqat,Nabada,Nagar,Nanagugal,Nerebtum,Nigin,Nimrud,Nina,Nineveh,Ninua,Nippur,Niru,Niya,Nuhashe,Nuhasse,Nuzi,Puzrish-Dagan,Qalatjarmo,Qatara,Qatna,Qattunan,Qidshu,Rapiqum,Rawda,Sagaz,Shaduppum,Shaggaratum,Shalbatu,Shanidar,Sharrukin,Shawwan,Shehna,Shekhna,Shemshara,Shibaniba,Shubat-Enlil,Shurkutir,Shuruppak,Shusharra,Shushin,Sikan,Sippar,Sippar-Amnanum,Sippar-sha-Annunitum,Subatum,Susuka,Tadmor,Tarbisu,Telul,Terqa,Tirazish,Tisbon,Tuba,Tushhan,Tuttul,Tutub,Ubaid,Umma,Ur,Urah,Urbilum,Urkesh,Ursa'um,Uruk,Urum,Uzarlulu,Warka,Washukanni,Zabalam,Zarri-Amnan", + }, + { + name: "Iranian", + i: 24, + min: 5, + max: 11, + d: "", + m: 0.1, + b: "Abali,Abrisham,Absard,Abuzeydabad,Afus,Alavicheh,Alikosh,Amol,Anarak,Anbar,Andisheh,Anshan,Aran,Ardabil,Arderica,Ardestan,Arjomand,Asgaran,Asgharabad,Ashian,Awan,Babajan,Badrud,Bafran,Baghestan,Baghshad,Bahadoran,Baharan Shahr,Baharestan,Bakun,Bam,Baqershahr,Barzok,Bastam,Behistun,Bitistar,Bumahen,Bushehr,Chadegan,Chahardangeh,Chamgardan,Chermahin,Choghabonut,Chugan,Damaneh,Damavand,Darabgard,Daran,Dastgerd,Dehaq,Dehaqan,Dezful,Dizicheh,Dorcheh,Dowlatabad,Duruntash,Ecbatana,Eslamshahr,Estakhr,Ezhiyeh,Falavarjan,Farrokhi,Fasham,Ferdowsieh,Fereydunshahr,Ferunabad,Firuzkuh,Fuladshahr,Ganjdareh,Ganzak,Gaz,Geoy,Godin,Goldasht,Golestan,Golpayegan,Golshahr,Golshan,Gorgab,Guged,Habibabad,Hafshejan,Hajjifiruz,Hana,Harand,Hasanabad,Hasanlu,Hashtgerd,Hecatompylos,Hormirzad,Imanshahr,Isfahan,Jandaq,Javadabad,Jiroft,Jowsheqan ,Jowzdan,Kabnak,Kahrizak,Kahriz Sang,Kangavar,Karaj,Karkevand,Kashan,Kelishad,Kermanshah,Khaledabad,Khansar,Khorramabad,Khur,Khvorzuq,Kilan,Komeh,Komeshcheh,Konar,Kuhpayeh,Kul,Kushk,Lavasan,Laybid,Liyan,Lyan,Mahabad,Mahallat,Majlesi,Malard,Manzariyeh,Marlik,Meshkat,Meymeh,Miandasht,Mish,Mobarakeh,Nahavand,Nain,Najafabad,Naqshe,Narezzash,Nasimshahr,Nasirshahr,Nasrabad,Natanz,Neyasar,Nikabad,Nimvar,Nushabad,Pakdasht,Parand,Pardis,Parsa,Pasargadai,Patigrabana,Pir Bakran,Pishva,Qahderijan,Qahjaverestan,Qamsar,Qarchak,Qods,Rabat,Ray-shahr,Rezvanshahr,Rhages,Robat Karim,Rozveh,Rudehen,Sabashahr,Safadasht,Sagzi,Salehieh,Sandal,Sarvestan,Sedeh,Sefidshahr,Semirom,Semnan,Shadpurabad,Shah,Shahdad,Shahedshahr,Shahin,Shahpour,Shahr,Shahreza,Shahriar,Sharifabad,Shemshak,Shiraz,Shushan,Shushtar,Sialk,Sin,Sukhteh,Tabas,Tabriz,Takhte,Talkhuncheh,Talli,Tarq,Temukan,Tepe,Tiran,Tudeshk,Tureng,Urmia,Vahidieh,Vahrkana,Vanak,Varamin,Varnamkhast,Varzaneh,Vazvan,Yahya,Yarim,Yasuj,Zarrin Shahr,Zavareh,Zayandeh,Zazeran,Ziar,Zibashahr,Zranka", + }, + { + name: "Hawaiian", + i: 25, + min: 5, + max: 10, + d: "auo", + m: 1, + b: "Aapueo,Ahoa,Ahuakaio,Ahupau,Alaakua,Alae,Alaeloa,Alamihi,Aleamai,Alena,Alio,Aupokopoko,Halakaa,Haleu,Haliimaile,Hamoa,Hanakaoo,Hanaulu,Hanawana,Hanehoi,Haou,Hikiaupea,Hokuula,Honohina,Honokahua,Honokeana,Honokohau,Honolulu,Honomaele,Hononana,Honopou,Hoolawa,Huelo,Kaalaea,Kaapahu,Kaeo,Kahalehili,Kahana,Kahuai,Kailua,Kainehe,Kakalahale,Kakanoni,Kalenanui,Kaleoaihe,Kalialinui,Kalihi,Kalimaohe,Kaloi,Kamani,Kamehame,Kanahena,Kaniaula,Kaonoulu,Kapaloa,Kapohue,Kapuaikini,Kapunakea,Kauau,Kaulalo,Kaulanamoa,Kauluohana,Kaumakani,Kaumanu,Kaunauhane,Kaupakulua,Kawaloa,Keaa,Keaaula,Keahua,Keahuapono,Kealahou,Keanae,Keauhou,Kelawea,Keokea,Keopuka,Kikoo,Kipapa,Koakupuna,Koali,Kolokolo,Kopili,Kou,Kualapa,Kuhiwa,Kuholilea,Kuhua,Kuia,Kuikui,Kukoae,Kukohia,Kukuiaeo,Kukuipuka,Kukuiula,Kulahuhu,Lapakea,Lapueo,Launiupoko,Lole,Maalo,Mahinahina,Mailepai,Makaakini,Makaalae,Makaehu,Makaiwa,Makaliua,Makapipi,Makapuu,Maluaka,Manawainui,Mehamenui,Moalii,Moanui,Mohopili,Mokae,Mokuia,Mokupapa,Mooiki,Mooloa,Moomuku,Muolea,Nakaaha,Nakalepo,Nakaohu,Nakapehu,Nakula,Napili,Niniau,Nuu,Oloewa,Olowalu,Omaopio,Onau,Onouli,Opaeula,Opana,Opikoula,Paakea,Paeahu,Paehala,Paeohi,Pahoa,Paia,Pakakia,Palauea,Palemo,Paniau,Papaaea,Papaanui,Papaauhau,Papaka,Papauluana,Pauku,Paunau,Pauwalu,Pauwela,Pohakanele,Polaiki,Polanui,Polapola,Poopoo,Poponui,Poupouwela,Puahoowali,Puakea,Puako,Pualaea,Puehuehu,Pueokauiki,Pukaauhuhu,Pukuilua,Pulehu,Puolua,Puou,Puuhaehae,Puuiki,Puuki,Puulani,Puunau,Puuomaile,Uaoa,Uhao,Ukumehame,Ulaino,Ulumalu,Wahikuli,Waianae,Waianu,Waiawa,Waiehu,Waieli,Waikapu,Wailamoa,Wailaulau,Wainee,Waiohole,Waiohonu,Waiohuli,Waiokama,Waiokila,Waiopai,Waiopua,Waipao,Waipionui,Waipouli", + }, + { + name: "Karnataka", + i: 26, + min: 5, + max: 11, + d: "tnl", + m: 0, + b: "Adityapatna,Adyar,Afzalpur,Aland,Alnavar,Alur,Ambikanagara,Anekal,Ankola,Annigeri,Arkalgud,Arsikere,Athni,Aurad,Badami,Bagalkot,Bagepalli,Bail,Bajpe,Bangalore,Bangarapet,Bankapura,Bannur,Bantval,Basavakalyan,Basavana,Belgaum,Beltangadi,Belur,Bhadravati,Bhalki,Bhatkal,Bhimarayanagudi,Bidar,Bijapur,Bilgi,Birur,Bommasandra,Byadgi,Challakere,Chamarajanagar,Channagiri,Channapatna,Channarayapatna,Chik,Chikmagalur,Chiknayakanhalli,Chikodi,Chincholi,Chintamani,Chitapur,Chitgoppa,Chitradurga,Dandeli,Dargajogihalli,Devadurga,Devanahalli,Dod,Donimalai,Gadag,Gajendragarh,Gangawati,Gauribidanur,Gokak,Gonikoppal,Gubbi,Gudibanda,Gulbarga,Guledgudda,Gundlupet,Gurmatkal,Haliyal,Hangal,Harapanahalli,Harihar,Hassan,Hatti,Haveri,Hebbagodi,Heggadadevankote,Hirekerur,Holalkere,Hole,Homnabad,Honavar,Honnali,Hoovina,Hosakote,Hosanagara,Hosdurga,Hospet,Hubli,Hukeri,Hungund,Hunsur,Ilkal,Indi,Jagalur,Jamkhandi,Jevargi,Jog,Kadigenahalli,Kadur,Kalghatgi,Kamalapuram,Kampli,Kanakapura,Karkal,Karwar,Khanapur,Kodiyal,Kolar,Kollegal,Konnur,Koppa,Koppal,Koratagere,Kotturu,Krishnarajanagara,Krishnarajasagara,Krishnarajpet,Kudchi,Kudligi,Kudremukh,Kumta,Kundapura,Kundgol,Kunigal,Kurgunta,Kushalnagar,Kushtagi,Lakshmeshwar,Lingsugur,Londa,Maddur,Madhugiri,Madikeri,Mahalingpur,Malavalli,Mallar,Malur,Mandya,Mangalore,Manvi,Molakalmuru,Mudalgi,Mudbidri,Muddebihal,Mudgal,Mudhol,Mudigere,Mulbagal,Mulgund,Mulki,Mulur,Mundargi,Mundgod,Munirabad,Mysore,Nagamangala,Nanjangud,Narasimharajapura,Naregal,Nargund,Navalgund,Nipani,Pandavapura,Pavagada,Piriyapatna,Pudu,Puttur,Rabkavi,Raichur,Ramanagaram,Ramdurg,Ranibennur,Raybag,Robertson,Ron,Sadalgi,Sagar,Sakleshpur,Saligram,Sandur,Sankeshwar,Saundatti,Savanur,Sedam,Shahabad,Shahpur,Shaktinagar,Shiggaon,Shikarpur,Shirhatti,Shorapur,Shrirangapattana,Siddapur,Sidlaghatta,Sindgi,Sindhnur,Sira,Siralkoppa,Sirsi,Siruguppa,Somvarpet,Sorab,Sringeri,Srinivaspur,Sulya,Talikota,Tarikere,Tekkalakote,Terdal,Thumbe,Tiptur,Tirthahalli,Tirumakudal,Tumkur,Turuvekere,Udupi,Vijayapura,Wadi,Yadgir,Yelandur,Yelbarga,Yellapur,Yenagudde", + }, + { + name: "Quechua", + i: 27, + min: 6, + max: 12, + d: "l", + m: 0, + b: "Alpahuaycco,Anchihuay,Anqea,Apurimac,Arequipa,Atahuallpa,Atawalpa,Atico,Ayacucho,Ayahuanco,Ayllu,Cajamarca,Canayre,Canchacancha,Carapo,Carhuac,Carhuacatac,Cashan,Caullaraju,Caxamalca,Cayesh,Ccahuasno,Ccarhuacc,Ccopayoc,Chacchapunta,Chacraraju,Challhuamayo,Champara,Chanchan,Chekiacraju,Chillihua,Chinchey,Chontah,Chopicalqui,Chucuito,Chuito,Chullo,Chumpi,Chuncho,Chupahuacho,Chuquiapo,Chuquisaca,Churup,Cocapata,Cochabamba,Cojup,Collota,Conococha,Corihuayrachina,Cuchoquesera,Cusichaca,Haika,Hanpiq,Hatun,Haywarisqa,Huaca,Huachinga,Hualcan,Hualchancca,Huamanga,Huamashraju,Huancarhuas,Huandoy,Huantsan,Huanupampa,Huarmihuanusca,Huascaran,Huaylas,Huayllabamba,Huayrana,Huaytara,Huichajanca,Huinayhuayna,Huinche,Huinioch,Illiasca,Intipunku,Iquicha,Ishinca,Jahuacocha,Jirishanca,Juli,Jurau,Kakananpunta,Kamasqa,Karpay,Kausay,Khuya,Kuelap,Lanccochayocc,Llaca,Llactapata,Llanganuco,Llaqta,Lloqllasca,Llupachayoc,Luricocha,Machu,Mallku,Matarraju,Mechecc,Mikhuy,Milluacocha,Morochuco,Munay,Ocshapalca,Ollantaytambo,Oroccahua,Oronccoy,Oyolo,Pacamayo,Pacaycasa,Paccharaju,Pachacamac,Pachakamaq,Pachakuteq,Pachakuti,Pachamama,Paititi,Pajaten,Palcaraju,Pallccas,Pampa,Panaka,Paqarina,Paqo,Parap,Paria,Patahuasi,Patallacta,Patibamba,Pisac,Pisco,Pongos,Pucacolpa,Pucahirca,Pucaranra,Pumatambo,Puscanturpa,Putaca,Puyupatamarca,Qawaq,Qayqa,Qochamoqo,Qollana,Qorihuayrachina,Qorimoqo,Qotupuquio,Quenuaracra,Queshque,Quillcayhuanca,Quillya,Quitaracsa,Quitaraju,Qusqu,Rajucolta,Rajutakanan,Rajutuna,Ranrahirca,Ranrapalca,Raria,Rasac,Rimarima,Riobamba,Runkuracay,Rurec,Sacsa,Sacsamarca,Saiwa,Sarapo,Sayacmarca,Sayripata,Sinakara,Sonccopa,Taripaypacha,Taulliraju,Tawantinsuyu,Taytanchis,Tiwanaku,Tocllaraju,Tsacra,Tuco,Tucubamba,Tullparaju,Tumbes,Uchuraccay,Uchuraqay,Ulta,Urihuana,Uruashraju,Vallunaraju,Vilcabamba,Wacho,Wankawillka,Wayra,Yachay,Yahuarraju,Yanamarey,Yanaqucha,Yanesha,Yerupaja", + }, + { + name: "Swahili", + i: 28, + min: 4, + max: 9, + d: "", + m: 0, + b: "Abim,Adjumani,Alebtong,Amolatar,Amuru,Apac,Arua,Arusha,Babati,Baragoi,Bombo,Budaka,Bugembe,Bugiri,Buikwe,Bukedea,Bukoba,Bukomansimbi,Bukungu,Buliisa,Bundibugyo,Bungoma,Busembatya,Bushenyi,Busia,Busolwe,Butaleja,Butambala,Butere,Buwenge,Buyende,Dadaab,Dodoma,Dokolo,Eldoret,Elegu,Emali,Embu,Entebbe,Garissa,Gede,Gulu,Handeni,Hima,Hoima,Hola,Ibanda,Iganga,Iringa,Isingiro,Isiolo,Jinja,Kaabong,Kabuyanda,Kabwohe,Kagadi,Kajiado,Kakinga,Kakiri,Kakuma,Kalangala,Kaliro,Kalongo,Kalungu,Kampala,Kamwenge,Kanungu,Kapchorwa,Kasese,Kasulu,Katakwi,Kayunga,Keroka,Kiambu,Kibaale,Kibaha,Kibingo,Kibwezi,Kigoma,Kihiihi,Kilifi,Kiruhura,Kiryandongo,Kisii,Kisoro,Kisumu,Kitale,Kitgum,Kitui,Koboko,Korogwe,Kotido,Kumi,Kyazanga,Kyegegwa,Kyenjojo,Kyotera,Lamu,Langata,Lindi,Lodwar,Lokichoggio,Londiani,Loyangalani,Lugazi,Lukaya,Luweero,Lwakhakha,Lwengo,Lyantonde,Machakos,Mafinga,Makambako,Makindu,Malaba,Malindi,Manafwa,Mandera,Marsabit,Masaka,Masindi,Masulita,Matugga,Mayuge,Mbale,Mbarara,Mbeya,Meru,Mitooma,Mityana,Mombasa,Morogoro,Moroto,Moyale,Moyo,Mpanda,Mpigi,Mpondwe,Mtwara,Mubende,Mukono,Muranga,Musoma,Mutomo,Mutukula,Mwanza,Nagongera,Nairobi,Naivasha,Nakapiripirit,Nakaseke,Nakasongola,Nakuru,Namanga,Namayingo,Namutumba,Nansana,Nanyuki,Narok,Naromoru,Nebbi,Ngora,Njeru,Njombe,Nkokonjeru,Ntungamo,Nyahururu,Nyeri,Oyam,Pader,Paidha,Pakwach,Pallisa,Rakai,Ruiru,Rukungiri,Rwimi,Sanga,Sembabule,Shimoni,Shinyanga,Singida,Sironko,Songea,Soroti,Ssabagabo,Sumbawanga,Tabora,Takaungu,Tanga,Thika,Tororo,Tunduma,Vihiga,Voi,Wajir,Wakiso,Watamu,Webuye,Wobulenzi,Wote,Wundanyi,Yumbe,Zanzibar", + }, + { + name: "Vietnamese", + i: 29, + min: 3, + max: 12, + d: "", + m: 1, + b: "An Giang,Anh Son,An Khe,An Nhon,Ayun Pa,Bac Giang,Bac Kan,Bac Lieu,Bac Ninh,Ba Don,Bao Loc,Ba Ria,Ba Ria-Vung Tau,Ba Thuoc,Ben Cat,Ben Tre,Bien Hoa,Bim Son,Binh Dinh,Binh Duong,Binh Long,Binh Minh,Binh Phuoc,Binh Thuan,Buon Ho,Buon Ma Thuot,Cai Lay,Ca Mau,Cam Khe,Cam Pha,Cam Ranh,Cam Thuy,Can Tho,Cao Bang,Cao Lanh,Cao Phong,Chau Doc,Chi Linh,Con Cuong,Cua Lo,Da Bac,Dak Lak,Da Lat,Da Nang,Di An,Dien Ban,Dien Bien,Dien Bien Phu,Dien Chau,Do Luong,Dong Ha,Dong Hoi,Dong Trieu,Duc Pho,Duyen Hai,Duy Tien,Gia Lai,Gia Nghia,Gia Rai,Go Cong,Ha Giang,Ha Hoa,Hai Duong,Hai Phong,Ha Long,Ha Nam,Ha Noi,Ha Tinh,Ha Trung,Hau Giang,Hoa Binh,Hoang Mai,Hoa Thanh,Ho Chi Minh,Hoi An,Hong Linh,Hong Ngu,Hue,Hung Nguyen,Hung Yen,Huong Thuy,Huong Tra,Khanh Hoa,Kien Tuong,Kim Boi,Kinh Mon,Kon Tum,Ky Anh,Ky Son,Lac Son,Lac Thuy,La Gi,Lai Chau,Lam Thao,Lang Chanh,Lang Son,Lao Cai,Long An,Long Khanh,Long My,Long Xuyen,Luong Son,Mai Chau,Mong Cai,Muong Lat,Muong Lay,My Hao,My Tho,Nam Dan,Nam Dinh,Nga Bay,Nga Nam,Nga Son,Nghe An,Nghia Dan,Nghia Lo,Nghi Loc,Nghi Son,Ngoc Lac,Nha Trang,Nhu Thanh,Nhu Xuan,Ninh Binh,Ninh Hoa,Nong Cong,Phan Rang Thap Cham,Phan Thiet,Pho Yen,Phu Ly,Phu My,Phu Ninh,Phuoc Long,Phu Tho,Phu Yen,Pleiku,Quang Binh,Quang Nam,Quang Ngai,Quang Ninh,Quang Tri,Quang Xuong,Quang Yen,Quan Hoa,Quan Son,Que Phong,Quy Chau,Quy Hop,Quynh Luu,Quy Nhon,Rach Gia,Sa Dec,Sai Gon,Sam Son,Sa Pa,Soc Trang,Song Cau,Song Cong,Son La,Son Tay,Tam Diep,Tam Ky,Tan An,Tan Chau,Tan Ky,Tan Lac,Tan Son,Tan Uyen,Tay Ninh,Thach Thanh,Thai Binh,Thai Hoa,Thai Nguyen,Thanh Chuong,Thanh Hoa,Thieu Hoa,Thuan An,Thua Thien-Hue,Thu Dau Mot,Thu Duc,Thuong Xuan,Tien Giang,Trang Bang,Tra Vinh,Trieu Son,Tu Son,Tuyen Quang,Tuy Hoa,Uong Bi,Viet Tri,Vinh,Vinh Chau,Vinh Loc,Vinh Long,Vinh Yen,Vi Thanh,Vung Tau,Yen Bai,Yen Dinh,Yen Thanh,Yen Thuy", + }, + { + name: "Cantonese", + i: 30, + min: 5, + max: 11, + d: "", + m: 0, + b: "Chaiwan,Chingchung,Chinghoi,Chingsen,Chingshing,Chiunam,Chiuon,Chiuyeung,Chiyuen,Choihung,Chuehoi,Chuiman,Chungfu,Chungsan,Chunguktsuen,Dakhing,Daopo,Daumun,Dingwu,Dinpak,Donggun,Dongyuen,Duenchau,Fachau,Fanling,Fatgong,Fatshan,Fotan,Fuktien,Fumun,Funggong,Funghoi,Fungshun,Fungtei,Gamtin,Gochau,Goming,Gonghoi,Gongshing,Goyiu,Hanghau,Hangmei,Hengon,Heungchau,Heunggong,Heungkiu,Hingning,Hohfuktong,Hoichue,Hoifung,Hoiping,Hokong,Hokshan,Hoyuen,Hunghom,Hungshuikiu,Jiuling,Kamsheung,Kamwan,Kaulongtong,Keilun,Kinon,Kinsang,Kityeung,Kongmun,Kukgong,Kwaifong,Kwaihing,Kwongchau,Kwongling,Kwongming,Kwuntong,Laichikok,Laiking,Laiwan,Lamtei,Lamtin,Leitung,Leungking,Limkong,Linping,Linshan,Loding,Lokcheong,Lokfu,Longchuen,Longgong,Longmun,Longping,Longwa,Longwu,Lowu,Luichau,Lukfung,Lukho,Lungmun,Macheung,Maliushui,Maonshan,Mauming,Maunam,Meifoo,Mingkum,Mogong,Mongkok,Muichau,Muigong,Muiyuen,Naiwai,Namcheong,Namhoi,Namhong,Namsha,Nganwai,Ngautaukok,Ngchuen,Ngwa,Onting,Pakwun,Paotoishan,Pingshan,Pingyuen,Poklo,Pongon,Poning,Potau,Puito,Punyue,Saiwanho,Saiyingpun,Samshing,Samshui,Samtsen,Samyuenlei,Sanfung,Sanhing,Sanhui,Sanwai,Seiwui,Shamshuipo,Shanmei,Shantau,Shauking,Shekmun,Shekpai,Sheungshui,Shingkui,Shiuhing,Shundak,Shunyi,Shupinwai,Simshing,Siuhei,Siuhong,Siukwan,Siulun,Suikai,Taihing,Taikoo,Taipo,Taishuihang,Taiwai,Taiwohau,Tinhau,Tinshuiwai,Tiukengleng,Toishan,Tongfong,Tonglowan,Tsakyoochung,Tsamgong,Tsangshing,Tseungkwano,Tsimshatsui,Tsinggong,Tsingshantsuen,Tsingwun,Tsingyi,Tsingyuen,Tsiuchau,Tsuenshekshan,Tsuenwan,Tuenmun,Tungchung,Waichap,Waichau,Waidong,Wailoi,Waishing,Waiyeung,Wanchai,Wanfau,Wanshing,Wingon,Wongpo,Wongtaisin,Woping,Wukaisha,Yano,Yaumatei,Yautong,Yenfa,Yeungchun,Yeungdong,Yeungsai,Yeungshan,Yimtin,Yingdak,Yiuping,Yongshing,Yongyuen,Yuenlong,Yuenshing,Yuetsau,Yuknam,Yunping", + }, + { + name: "Mongolian", + i: 31, + min: 5, + max: 12, + d: "aou", + m: 0.3, + b: "Adaatsag,Airag,Alag Erdene,Altai,Altanshiree,Altantsogts,Arbulag,Baatsagaan,Batnorov,Batshireet,Battsengel,Bayan Adarga,Bayan Agt,Bayanbulag,Bayandalai,Bayandun,Bayangovi,Bayanjargalan,Bayankhongor,Bayankhutag,Bayanlig,Bayanmonkh,Bayannur,Bayannuur,Bayan Ondor,Bayan Ovoo,Bayantal,Bayantsagaan,Bayantumen,Bayan Uul,Bayanzurkh,Berkh,Biger,Binder,Bogd,Bombogor,Bor Ondor,Bugat,Bugt,Bulgan,Buregkhangai,Burentogtokh,Buutsagaan,Buyant,Chandmani,Chandmani Ondor,Choibalsan,Chuluunkhoroot,Chuluut,Dadal,Dalanjargalan,Dalanzadgad,Darhan Muminggan,Darkhan,Darvi,Dashbalbar,Dashinchilen,Delger,Delgerekh,Delgerkhaan,Delgerkhangai,Delgertsogt,Deluun,Deren,Dorgon,Duut,Erdene,Erdenebulgan,Erdeneburen,Erdenedalai,Erdenemandal,Erdenetsogt,Galshar,Galt,Galuut,Govi Ugtaal,Gurvan,Gurvanbulag,Gurvansaikhan,Gurvanzagal,Hinggan,Hodong,Holingol,Hondlon,Horin Ger,Horqin,Hulunbuir,Hure,Ikhkhet,Ikh Tamir,Ikh Uul,Jargalan,Jargalant,Jargaltkhaan,Jarud,Jinst,Khairkhan,Khalhgol,Khaliun,Khanbogd,Khangai,Khangal,Khankh,Khankhongor,Khashaat,Khatanbulag,Khatgal,Kherlen,Khishig Ondor,Khokh,Kholonbuir,Khongor,Khotont,Khovd,Khovsgol,Khuld,Khureemaral,Khurmen,Khutag Ondor,Luus,Mandakh,Mandal Ovoo,Mankhan,Manlai,Matad,Mogod,Monkhkhairkhan,Moron,Most,Myangad,Nogoonnuur,Nomgon,Norovlin,Noyon,Ogii,Olgii,Olziit,Omnodelger,Ondorkhaan,Ondorshil,Ondor Ulaan,Ongniud,Ordos,Orgon,Orkhon,Rashaant,Renchinlkhumbe,Sagsai,Saikhan,Saikhandulaan,Saikhan Ovoo,Sainshand,Saintsagaan,Selenge,Sergelen,Sevrei,Sharga,Sharyngol,Shine Ider,Shinejinst,Shiveegovi,Sumber,Taishir,Tarialan,Tariat,Teshig,Togrog,Togtoh,Tolbo,Tomorbulag,Tonkhil,Tosontsengel,Tsagaandelger,Tsagaannuur,Tsagaan Ovoo,Tsagaan Uur,Tsakhir,Tseel,Tsengel,Tsenkher,Tsenkhermandal,Tsetseg,Tsetserleg,Tsogt,Tsogt Ovoo,Tsogttsetsii,Tumed,Tunel,Tuvshruulekh,Ulaanbadrakh,Ulaankhus,Ulaan Uul,Ulanhad,Ulanqab,Uyench,Yesonbulag,Zag,Zalainur,Zamyn Uud,Zereg", + }, + // fantasy bases by Dopu: + { + name: "Human Generic", + i: 32, + min: 6, + max: 11, + d: "peolst", + m: 0, + b: "Amberglen,Angelhand,Arrowden,Autumnband,Autumnkeep,Basinfrost,Basinmore,Bayfrost,Beargarde,Bearmire,Bellcairn,Bellport,Bellreach,Blackwatch,Bleakward,Bonemouth,Boulder,Bridgefalls,Bridgeforest,Brinepeak,Brittlehelm,Bronzegrasp,Castlecross,Castlefair,Cavemire,Claymond,Claymouth,Clearguard,Cliffgate,Cliffshear,Cliffshield,Cloudbay,Cloudcrest,Cloudwood,Coldholde,Cragbury,Crowgrove,Crowvault,Crystalrock,Crystalspire,Cursefield,Curseguard,Cursespell,Dawnforest,Dawnwater,Deadford,Deadkeep,Deepcairn,Deerchill,Demonfall,Dewglen,Dewmere,Diredale,Direden,Dirtshield,Dogcoast,Dogmeadow,Dragonbreak,Dragonhold,Dragonward,Dryhost,Dustcross,Dustwatch,Eaglevein,Earthfield,Earthgate,Earthpass,Ebonfront,Edgehaven,Eldergate,Eldermere,Embervault,Everchill,Evercoast,Falsevale,Faypond,Fayvale,Fayyard,Fearpeak,Flameguard,Flamewell,Freyshell,Ghostdale,Ghostpeak,Gloomburn,Goldbreach,Goldyard,Grassplains,Graypost,Greeneld,Grimegrove,Grimeshire,Heartfall,Heartford,Heartvault,Highbourne,Hillpass,Hollowstorm,Honeywater,Houndcall,Houndholde,Iceholde,Icelight,Irongrave,Ironhollow,Knightlight,Knighttide,Lagoonpass,Lakecross,Lastmere,Laststar,Lightvale,Limeband,Littlehall,Littlehold,Littlemire,Lostcairn,Lostshield,Loststar,Madfair,Madham,Midholde,Mightglen,Millstrand,Mistvault,Mondpass,Moonacre,Moongulf,Moonwell,Mosshand,Mosstide,Mosswind,Mudford,Mudwich,Mythgulch,Mythshear,Nevercrest,Neverfront,Newfalls,Nighthall,Oakenbell,Oakenrun,Oceanstar,Oldreach,Oldwall,Oldwatch,Oxbrook,Oxlight,Pearlhaven,Pinepond,Pondfalls,Pondtown,Pureshell,Quickbell,Quickpass,Ravenside,Roguehaven,Roseborn,Rosedale,Rosereach,Rustmore,Saltmouth,Sandhill,Scorchpost,Scorchstall,Shadeforest,Shademeadow,Shadeville,Shimmerrun,Shimmerwood,Shroudrock,Silentkeep,Silvercairn,Silvergulch,Smallmire,Smoothcliff,Smoothgrove,Smoothtown,Snakemere,Snowbay,Snowshield,Snowtown,Southbreak,Springmire,Springview,Stagport,Steammouth,Steamwall,Steepmoor,Stillhall,Stoneguard,Stonespell,Stormhand,Stormhorn,Sungulf,Sunhall,Swampmaw,Swangarde,Swanwall,Swiftwell,Thorncairn,Thornhelm,Thornyard,Timberside,Tradewick,Westmeadow,Westpoint,Whiteshore,Whitvalley,Wildeden,Wildwell,Wildyard,Winterhaven,Wolfpass", + }, + { + name: "Elven", + i: 33, + min: 6, + max: 12, + d: "lenmsrg", + m: 0, + b: "Adrindest,Aethel,Afranthemar,Aiqua,Alari,Allanar,Almalian,Alora,Alyanasari,Alyelona,Alyran,Ammar,Anyndell,Arasari,Aren,Ashmebel,Aymlume,Bel-Didhel,Brinorion,Caelora,Chaulssad,Chaundra,Cyhmel,Cyrang,Dolarith,Dolonde,Draethe,Dranzan,Draugaust,E'ana,Eahil,Edhil,Eebel,Efranluma,Eld-Sinnocrin,Elelthyr,Ellanalin,Ellena,Ellorthond,Eltaesi,Elunore,Emyranserine,Entheas,Eriargond,Esari,Esath,Eserius,Eshsalin,Eshthalas,Evraland,Faellenor,Famelenora,Filranlean,Filsaqua,Gafetheas,Gaf Serine,Geliene,Gondorwin,Guallu,Haeth,Hanluna,Haulssad,Heloriath,Himlarien,Himliene,Hinnead,Hlinas,Hloireenil,Hluihei,Hlurthei,Hlynead,Iaenarion,Iaron,Illanathaes,Illfanora,Imlarlon,Imyse,Imyvelian,Inferius,Inlurth,innsshe,Iralserin,Irethtalos,Irholona,Ishal,Ishlashara,Ithelion,Ithlin,Iulil,Jaal,Jamkadi,Kaalume,Kaansera,Karanthanil,Karnosea,Kasethyr,Keatheas,Kelsya,Keth Aiqua,Kmlon,Kyathlenor,Kyhasera,Lahetheas,Lefdorei,Lelhamelle,Lilean,Lindeenil,Lindoress,Litys,Llaughei,Lya,Lyfa,Lylharion,Lynathalas,Machei,Masenoris,Mathethil,Mathentheas,Meethalas,Menyamar,Mithlonde,Mytha,Mythsemelle,Mythsthas,Naahona,Nalore,Nandeedil,Nasad Ilaurth,Nasin,Nathemar,Neadar,Neilon,Nelalon,Nellean,Nelnetaesi,Nilenathyr,Nionande,Nylm,Nytenanas,Nythanlenor,O'anlenora,Obeth,Ofaenathyr,Ollmnaes,Ollsmel,Olwen,Olyaneas,Omanalon,Onelion,Onelond,Orlormel,Ormrion,Oshana,Oshvamel,Raethei,Rauguall,Reisera,Reslenora,Ryanasera,Rymaserin,Sahnor,Saselune,Sel-Zedraazin,Selananor,Sellerion,Selmaluma,Shaeras,Shemnas,Shemserin,Sheosari,Sileltalos,Siriande,Siriathil,Srannor,Sshanntyr,Sshaulu,Syholume,Sylharius,Sylranbel,Taesi,Thalor,Tharenlon,Thelethlune,Thelhohil,Themar,Thene,Thilfalean,Thilnaenor,Thvethalas,Thylathlond,Tiregul,Tlauven,Tlindhe,Ulal,Ullve,Ulmetheas,Ulssin,Umnalin,Umye,Umyheserine,Unanneas,Unarith,Undraeth,Unysarion,Vel-Shonidor,Venas,Vin Argor,Wasrion,Wlalean,Yaeluma,Yeelume,Yethrion,Ymserine,Yueghed,Yuerran,Yuethin", + }, + { + name: "Dark Elven", + i: 34, + min: 6, + max: 14, + d: "nrslamg", + m: 0.2, + b: "Abaethaggar,Abburth,Afranthemar,Aharasplit,Aidanat,Ald'ruhn,Ashamanu,Ashesari,Ashletheas,Baerario,Baereghel,Baethei,Bahashae,Balmora,Bel-Didhel,Borethanil,Buiyrandyn,Caellagith,Caellathala,Caergroth,Caldras,Chaggar,Chaggaust,Channtar,Charrvhel'raugaust,Chaulssin,Chaundra,ChedNasad,ChetarIthlin,ChethRrhinn,Chymaer,Clarkarond,Cloibbra,Commoragh,Cyrangroth,Cilben,D'eldarc,Daedhrog,Dalkyn,Do'Urden,Doladress,Dolarith,Dolonde,Draethe,Dranzan,Dranzithl,Draugaust,Dreghei,Drelhei,Dryndlu,Dusklyngh,DyonG'ennivalz,Edraithion,Eld-Sinnocrin,Ellorthond,Enhethyr,Entheas,ErrarIthinn,Eryndlyn,Faladhell,Faneadar,Fethalas,Filranlean,Formarion,Ferdor,Gafetheas,Ghrond,Gilranel,Glamordis,Gnaarmok,Gnisis,Golothaer,Gondorwin,Guallidurth,Guallu,Gulshin,Haeth,Haggraef,Harganeth,Harkaldra,Haulssad,Haundrauth,Heloriath,Hlammachar,Hlaughei,Hloireenil,Hluitar,Inferius,Innsshe,Ithilaughym,Iz'aiogith,Jaal,Jhachalkhyn,Kaerabrae,Karanthanil,Karondkar,Karsoluthiyl,Kellyth,Khuul,Lahetheas,Lidurth,Lindeenil,Lirillaquen,LithMy'athar,LlurthDreier,Lolth,Lothuial,Luihaulen'tar,Maeralyn,Maerimydra,Mathathlona,Mathethil,Mellodona,Menagith,Menegwen,Menerrendil,Menzithl,Menzoberranzan,Mila-Nipal,Mithryn,Molagmar,Mundor,Myvanas,Naggarond,Nandeedil,NasadIlaurth,Nauthor,Navethas,Neadar,Nurtaleewe,Nidiel,Noruiben,Olwen,O'lalona,Obeth,Ofaenathyr,Orlormel,Orlytlar,Pelagiad,Raethei,Raugaust,Rauguall,Rilauven,Rrharrvhei,Sadrith,Sel-Zedraazin,Seydaneen,Shaz'rir,Skaal,Sschindylryn,Shamath,Shamenz,Shanntur,Sshanntynlan,Sshanntyr,Shaulssin,SzithMorcane,Szithlin,Szobaeth,Sirdhemben,T'lindhet,Tebh'zhor,Telmere,Telnarquel,Tharlarast,Thylathlond,Tlaughe,Trizex,Tyrybblyn,Ugauth,Ughym,Uhaelben,Ullmatalos,Ulmetheas,Ulrenserine,Uluitur,Undraeth,Undraurth,Undrek'Thoz,Ungethal,UstNatha,Uthaessien,V'elddrinnsshar,Vaajha,Vel-Shonidor,Velddra,Velothi,Venead,Vhalth'vha,Vinargothr,Vojha,Waethe,Waethei,Xaalkis,Yakaridan,Yeelume,Yridhremben,Yuethin,Yuethindrynn,Zirnakaynin", + }, + { + name: "Dwarven", + i: 35, + min: 4, + max: 11, + d: "dk", + m: 0, + b: "Addundad,Ahagzad,Ahazil,Akil,Akzizad,Anumush,Araddush,Arar,Arbhur,Badushund,Baragzig,Baragzund,Barakinb,Barakzig,Barakzinb,Barakzir,Baramunz,Barazinb,Barazir,Bilgabar,Bilgatharb,Bilgathaz,Bilgila,Bilnaragz,Bilnulbar,Bilnulbun,Bizaddum,Bizaddush,Bizanarg,Bizaram,Bizinbiz,Biziram,Bunaram,Bundinar,Bundushol,Bundushund,Bundushur,Buzaram,Buzundab,Buzundush,Gabaragz,Gabaram,Gabilgab,Gabilgath,Gabizir,Gabunal,Gabunul,Gabuzan,Gatharam,Gatharbhur,Gathizdum,Gathuragz,Gathuraz,Gila,Giledzir,Gilukkhath,Gilukkhel,Gunala,Gunargath,Gunargil,Gundumunz,Gundusharb,Gundushizd,Kharbharbiln,Kharbhatharb,Kharbhela,Kharbilgab,Kharbuzadd,Khatharbar,Khathizdin,Khathundush,Khazanar,Khazinbund,Khaziragz,Khaziraz,Khizdabun,Khizdusharbh,Khizdushath,Khizdushel,Khizdushur,Kholedzar,Khundabiln,Khundabuz,Khundinarg,Khundushel,Khuragzig,Khuramunz,Kibarak,Kibilnal,Kibizar,Kibunarg,Kibundin,Kibuzan,Kinbadab,Kinbaragz,Kinbarakz,Kinbaram,Kinbizah,Kinbuzar,Nala,Naledzar,Naledzig,Naledzinb,Naragzah,Naragzar,Naragzig,Narakzah,Narakzar,Naramunz,Narazar,Nargabad,Nargabar,Nargatharb,Nargila,Nargundum,Nargundush,Nargunul,Narukthar,Narukthel,Nula,Nulbadush,Nulbaram,Nulbilnarg,Nulbunal,Nulbundab,Nulbundin,Nulbundum,Nulbuzah,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhund,Nulukkhur,Sharakinb,Sharakzar,Sharamunz,Sharbarukth,Shatharbhizd,Shatharbiz,Shathazah,Shathizdush,Shathola,Shaziragz,Shizdinar,Shizdushund,Sholukkharb,Shundinulb,Shundushund,Shurakzund,Shuramunz,Tumunzadd,Tumunzan,Tumunzar,Tumunzinb,Tumunzir,Ukthad,Ulbirad,Ulbirar,Ulunzar,Ulur,Umunzad,Undalar,Undukkhil,Undun,Undur,Unduzur,Unzar,Unzathun,Usharar,Zaddinarg,Zaddushur,Zaharbad,Zaharbhizd,Zarakib,Zarakzar,Zaramunz,Zarukthel,Zinbarukth,Zirakinb,Zirakzir,Ziramunz,Ziruktharbh,Zirukthur,Zundumunz", + }, + { + name: "Goblin", + i: 36, + min: 4, + max: 9, + d: "eag", + m: 0, + b: "Asinx,Bhiagielt,Biokvish,Blix,Blus,Bratliaq,Breshass,Bridvelb,Brybsil,Bugbig,Buyagh,Cel,Chalk,Chiafzia,Chox,Cielb,Cosvil,Crekork,Crild,Croibieq,Diervaq,Dobruing,Driord,Eebligz,Een,Enissee,Esz,Far,Felhob,Froihiofz,Fruict,Fygsee,Gagablin,Gigganqi,Givzieqee,Glamzofs,Glernaahx,Gneabs,Gnoklig,Gobbledak,gobbok,Gobbrin,Heszai,Hiszils,Hobgar,Honk,Iahzaarm,Ialsirt,Ilm,Ish,Jasheafta,Joimtoilm,Kass,Katmelt,Kleabtong,Kleardeek,Klilm,Kluirm,Kuipuinx,Moft,Mogg,Nilbog,Oimzoishai,Onq,Ozbiard,Paas,Phax,Phigheldai,Preang,Prolkeh,Pyreazzi,Qeerags,Qosx,Rekx,Shaxi,Sios,Slehzit,Slofboif,Slukex,Srefs,Srurd,Stiaggaltia,Stiolx,Stioskurt,Stroir,Strytzakt,Stuikvact,Styrzangai,Suirx,Swaxi,Taxai,Thelt,Thresxea,Thult,Traglila,Treaq,Ulb,Ulm,Utha,Utiarm,Veekz,Vohniots,Vreagaald,Watvielx,Wrogdilk,Wruilt,Xurx,Ziggek,Zriokots", + }, + { + name: "Orc", + i: 37, + min: 4, + max: 8, + d: "gzrcu", + m: 0, + b: "Adgoz,Adgril-Gha,Adog,Adzurd,Agkadh,Agzil-Ghal,Akh,Ariz-Dru,Arkugzo,Arrordri,Ashnedh,Azrurdrekh,Bagzildre,Bashnud,Bedgez-Graz,Bhakh,Bhegh,Bhiccozdur,Bhicrur,Bhirgoshbel,Bhog,Bhurkrukh,Bod-Rugniz,Bogzel,Bozdra,Bozgrun,Bozziz,Bral-Lazogh,Brazadh,Brogved,Brogzozir,Brolzug,Brordegeg,Brorkril-Zrog,Brugroz,Brukh-Zrabrul,Brur-Korre,Bulbredh,Bulgragh,Chaz-Charard,Chegan-Khed,Chugga,Chuzar,Dhalgron-Mog,Dhazon-Ner,Dhezza,Dhoddud,Dhodh-Brerdrodh,Dhodh-Ghigin,Dhoggun-Bhogh,Dhulbazzol,Digzagkigh,Dirdrurd,Dodkakh,Dorgri,Drizdedh,Drobagh,Drodh-Ashnugh,Drogvukh-Drodh,Drukh-Qodgoz,Drurkuz,Dududh,Dur-Khaddol,Egmod,Ekh-Beccon,Ekh-Krerdrugh,Ekh-Mezred,Gagh-Druzred,Gazdrakh-Vrard,Gegnod,Gerkradh,Ghagrocroz,Ghared-Krin,Ghedgrolbrol,Gheggor,Ghizgil,Gho-Ugnud,Gholgard,Gidh-Ucceg,Goccogmurd,Golkon,Graz-Khulgag,Gribrabrokh,Gridkog,Grigh-Kaggaz,Grirkrun-Qur,Grughokh,Grurro,Gugh-Zozgrod,Gur-Ghogkagh,Ibagh-Chol,Ibruzzed,Ibul-Brad,Iggulzaz,Ikh-Ugnan,Irdrelzug,Irmekh-Bhor,Kacruz,Kalbrugh,Karkor-Zrid,Kazzuz-Zrar,Kezul-Bruz,Kharkiz,Khebun,Khorbric,Khuldrerra,Khuzdraz,Kirgol,Koggodh,Korkrir-Grar,Kraghird,Krar-Zurmurd,Krigh-Bhurdin,Kroddadh,Krudh-Khogzokh,Kudgroccukh,Kudrukh,Kudzal,Kuzgrurd-Dedh,Larud,Legvicrodh,Lorgran,Lugekh,Lulkore,Mazgar,Merkraz,Mocculdrer,Modh-Odod,Morbraz,Mubror,Muccug-Ghuz,Mughakh-Chil,Murmad,Nazad-Ludh,Negvidh,Nelzor-Zroz,Nirdrukh,Nogvolkar,Nubud,Nuccag,Nudh-Kuldra,Nuzecro,Oddigh-Krodh,Okh-Uggekh,Ordol,Orkudh-Bhur,Orrad,Qashnagh,Qiccad-Chal,Qiddolzog,Qidzodkakh,Qirzodh,Rarurd,Reradgri,Rezegh,Rezgrugh,Rodrekh,Rogh-Chirzaz,Rordrushnokh,Rozzez,Ruddirgrad,Rurguz-Vig,Ruzgrin,Ugh-Vruron,Ughudadh,Uldrukh-Bhudh,Ulgor,Ulkin,Ummugh-Ekh,Uzaggor,Uzdriboz,Uzdroz,Uzord,Uzron,Vaddog,Vagord-Khod,Velgrudh,Verrugh,Vrazin,Vrobrun,Vrugh-Nardrer,Vrurgu,Vuccidh,Vun-Gaghukh,Zacrad,Zalbrez,Zigmorbredh,Zordrordud,Zorrudh,Zradgukh,Zragmukh,Zragrizgrakh,Zraldrozzuz,Zrard-Krodog,Zrazzuz-Vaz,Zrigud,Zrulbukh-Dekh,Zubod-Ur,Zulbriz,Zun-Bergrord", + }, + { + name: "Giant", + i: 38, + min: 5, + max: 10, + d: "kdtng", + m: 0, + b: "Addund,Aerora,Agane,Anumush,Arangrim,Bahourg,Baragzund,Barakinb,Barakzig,Barakzinb,Baramunz,Barazinb,Beornelde,Beratira,Borgbert,Botharic,Bremrol,Brerstin,Brildung,Brozu,Bundushund,Burthug,Chazruc,Chergun,Churtec,Dagdhor,Dankuc,Darnaric,Debuch,Dina,Dinez,Diru,Drard,Druguk,Dugfast,Duhal,Dulkun,Eldond,Enuz,Eraddam,Eradhelm,Froththorn,Fynwyn,Gabaragz,Gabaram,Gabizir,Gabuzan,Gagkake,Galfald,Galgrim,Gatal,Gazin,Geru,Gila,Giledzir,Girkun,Glumvat,Gluthmark,Gomruch,Gorkege,Gortho,Gostuz,Grimor,Grimtira,Guddud,Gudgiz,Gulwo,Gunargath,Gundusharb,Guril,Gurkale,Guruge,Guzi,Hargarth,Hartreo,Heimfara,Hildlaug,Idgurth,Inez,Inginy,Iora,Irkin,Jaldhor,Jarwar,Jornangar,Jornmoth,Kakkek,Kaltoch,Kegkez,Kengord,Kharbharbiln,Khatharbar,Khathizdin,Khazanar,Khaziragz,Khizdabun,Khizdushel,Khundinarg,Kibarak,Kibizar,Kigine,Kilfond,Kilkan,Kinbadab,Kinbuzar,Koril,Kostand,Kuzake,Lindira,Lingarth,Maerdis,Magald,Marbold,Marbrand,Memron,Minu,Mistoch,Morluch,Mornkin,Morntaric,Nagu,Naragzah,Naramunz,Narazar,Nargabar,Nargatharb,Nargundush,Nargunul,Natan,Natil,Neliz,Nelkun,Noluch,Norginny,Nulbaram,Nulbilnarg,Nuledzah,Nuledzig,Nulukkhaz,Nulukkhur,Nurkel,Oci,Olane,Oldstin,Orga,Ranava,Ranhera,Rannerg,Rirkan,Rizen,Rurki,Rurkoc,Sadgach,Sgandrol,Sharakzar,Shatharbiz,Shathizdush,Shathola,Shizdinar,Sholukkharb,Shundushund,Shurakzund,Sidga,Sigbeorn,Sigbi,Solfod,Somrud,Srokvan,Stighere,Sulduch,Talkale,Theoddan,Theodgrim,Throtrek,Tigkiz,Tolkeg,Toren,Tozage,Tulkug,Tumunzar,Umunzad,Undukkhil,Usharar,Valdhere,Varkud,Velfirth,Velhera,Vigkan,Vorkige,Vozig,Vylwed,Widhyrde,Wylaeya,Yili,Yotane,Yudgor,Yulkake,Zigez,Zugkan,Zugke", + }, + { + name: "Draconic", + i: 39, + min: 6, + max: 14, + d: "aliuszrox", + m: 0, + b: "Aaronarra,Adalon,Adamarondor,Aeglyl,Aerosclughpalar,Aghazstamn,Aglaraerose,Agoshyrvor,Alduin,Alhazmabad,Altagos,Ammaratha,Amrennathed,Anaglathos,Andrathanach,Araemra,Araugauthos,Arauthator,Arharzel,Arngalor,Arveiaturace,Athauglas,Augaurath,Auntyrlothtor,Azarvilandral,Azhaq,Balagos,Baratathlaer,Bleucorundum,BrazzPolis,Canthraxis,Capnolithyl,Charvekkanathor,Chellewis,Chelnadatilar,Cirrothamalan,Claugiyliamatar,Cragnortherma,Dargentum,Dendeirmerdammarar,Dheubpurcwenpyl,Domborcojh,Draconobalen,Dragansalor,Dupretiskava,Durnehviir,Eacoathildarandus,Eldrisithain,Enixtryx,Eormennoth,Esmerandanna,Evenaelorathos,Faenphaele,Felgolos,Felrivenser,Firkraag,Fll'Yissetat,Furlinastis,Galadaeros,Galglentor,Garnetallisar,Garthammus,Gaulauntyr,Ghaulantatra,Glouroth,Greshrukk,Guyanothaz,Haerinvureem,Haklashara,Halagaster,Halaglathgar,Havarlan,Heltipyre,Hethcypressarvil,Hoondarrh,Icehauptannarthanyx,Iiurrendeem,Ileuthra,Iltharagh,Ingeloakastimizilian,Irdrithkryn,Ishenalyr,Iymrith,Jaerlethket,Jalanvaloss,Jharakkan,Kasidikal,Kastrandrethilian,Khavalanoth,Khuralosothantar,Kisonraathiisar,Kissethkashaan,Kistarianth,Klauth,Klithalrundrar,Krashos,Kreston,Kriionfanthicus,Krosulhah,Krustalanos,Kruziikrel,Kuldrak,Lareth,Latovenomer,Lhammaruntosz,Llimark,Ma'fel'no'sei'kedeh'naar,MaelestorRex,Magarovallanthanz,Mahatnartorian,Mahrlee,Malaeragoth,Malagarthaul,Malazan,Maldraedior,Maldrithor,MalekSalerno,Maughrysear,Mejas,Meliordianix,Merah,Mikkaalgensis,Mirmulnir,Mistinarperadnacles,Miteach,Mithbarazak,Morueme,Moruharzel,Naaslaarum,Nahagliiv,Nalavarauthatoryl,Naxorlytaalsxar,Nevalarich,Nolalothcaragascint,Nurvureem,Nymmurh,Odahviing,Olothontor,Ormalagos,Otaaryliakkarnos,Paarthurnax,Pelath,Pelendralaar,Praelorisstan,Praxasalandos,Protanther,Qiminstiir,Quelindritar,Ralionate,Rathalylaug,Rathguul,Rauglothgor,Raumorthadar,Relonikiv,Ringreemeralxoth,Roraurim,Rynnarvyx,Sablaxaahl,Sahloknir,Sahrotaar,Samdralyrion,Saryndalaghlothtor,Sawaka,Shalamalauth,Shammagar,Sharndrel,Shianax,Skarlthoon,Skurge,Smergadas,Ssalangan,Sssurist,Sussethilasis,Sylvallitham,Tamarand,Tantlevgithus,Tarlacoal,Tenaarlaktor,Thalagyrt,Tharas'kalagram,Thauglorimorgorus,Thoklastees,Thyka,Tsenshivah,Ueurwen,Uinnessivar,Urnalithorgathla,Velcuthimmorhar,Velora,Vendrathdammarar,Venomindhar,Viinturuth,Voaraghamanthar,Voslaarum,Vr'tark,Vrondahorevos,Vuljotnaak,Vulthuryol,Wastirek,Worlathaugh,Xargithorvar,Xavarathimius,Yemere,Ylithargathril,Ylveraasahlisar,Za-Jikku,Zarlandris,Zellenesterex,Zilanthar,Zormapalearath,Zundaerazylym,Zz'Pzora", + }, + { + name: "Arachnid", + i: 40, + min: 4, + max: 10, + d: "erlsk", + m: 0, + b: "Aaqok'ser,Aiced,Aizachis,Allinqel,As'taq,Ashrash,Caaqtos,Ceek'sax,Ceezuq,Cek'sier,Cen'qi,Ceqzocer,Cezeed,Chachocaq,Charis,Chashilieth,Checib,Chernul,Chezi,Chiazu,Chishros,Chixhi,Chizhi,Chollash,Choq'sha,Cinchichail,Collul,Ecush'taid,Ekiqe,Eqas,Er'uria,Erikas,Es'tase,Esrub,Exha,Haqsho,Hiavheesh,Hitha,Hok'thi,Hossa,Iacid,Iciever,Illuq,Isnir,Keezut,Kheellavas,Kheizoh,Khiachod,Khika,Khirzur,Khonrud,Khrakku,Khraqshis,Khrethish'ti,Khriashus,Khrika,Khrirni,Klashirel,Kleil'sha,Klishuth,Krarnit,Kras'tex,Krotieqas,Lais'tid,Laizuh,Lasnoth,Len'qeer,Leqanches,Lezad,Lhilir,Lhivhath,Lhok'thu,Lialliesed,Liaraq,Liceva,Lichorro,Lilla,Lokieqib,Nakur,Neerhaca,Neet'er,Neezoh,Nenchiled,Nerhalneth,Nir'ih,Nizus,Noreeqo,On'qix,Qalitho,Qas'tor,Qasol,Qavrud,Qavud,Qazar,Qazru,Qekno,Qeqravee,Qes'tor,Qhaik'sal,Qhak'sish,Qhazsakais,Qheliva,Qhenchaqes,Qherazal,Qhon'qos,Qhosh,Qish'tur,Qisih,Qorhoci,Qranchiq,Racith,Rak'zes,Ranchis,Rarhie,Rarzi,Rarzisiaq,Ras'tih,Ravosho,Recad,Rekid,Rernee,Rertachis,Rezhokketh,Reziel,Rhacish,Rhail'shel,Rhairhizse,Rhakivex,Rhaqeer,Rhartix,Rheciezsei,Rheevid,Rhel'shir,Rhevhie,Rhiavekot,Rhikkos,Rhiqese,Rhiqi,Rhiqracar,Rhisned,Rhousnateb,Riakeesnex,Rintachal,Rir'ul,Rourk'u,Rouzakri,Sailiqei,Sanchiqed,Saqshu,Sat'ier,Sazi,Seiqas,Shieth'i,Shiqsheh,Shizha,Shrachuvo,Shranqo,Shravhos,Shravuth,Shreerhod,Shrethuh,Shriantieth,Shronqash,Shrovarhir,Shrozih,Siacaqoh,Siezosh,Siq'sha,Sirro,Sornosi,Srachussi,Szaca,Szacih,Szaqova,Szasu,Szazhilos,Szeerrud,Szeezsad,Szeknur,Szesir,Szezhirros,Szilshith,Szon'qol,Szornuq,Xeekke,Yeek'su,Yeeq'zox,Yeqil,Yeqroq,Yeveed,Yevied,Yicaveeh,Yirresh,Yisie,Yithik'thaih,Yorhaqshes,Zacheek'sa,Zakkasa,Zelraq,Zeqo,Zharuncho,Zhath'arhish,Zhavirrit,Zhazilraq,Zhazsachiel,Zhek'tha,Zhequ,Zhias'ted,Zhicat,Zhicur,Zhirhacil,Zhizri,Zhochizses,Ziarih,Zirnib", + }, + { + name: "Serpents", + i: 41, + min: 5, + max: 11, + d: "slrk", + m: 0, + b: "Aj'ha,Aj'i,Aj'tiss,Ajakess,Aksas,Aksiss,Al'en,An'jeshe,Apjige,Arkkess,Athaz,Atus,Azras,Caji,Cakrasar,Cal'arrun,Capji,Cathras,Cej'han,Ces,Cez'jenta,Cij'te,Cinash,Cizran,Coth'jus,Cothrash,Culzanek,Cunaless,Ej'tesh,Elzazash,Ergek,Eshjuk,Ethris,Gan'jas,Gapja,Gar'thituph,Gopjeguss,Gor'thesh,Gragishaph,Grar'theness,Grath'ji,Gressinas,Grolzesh,Grorjar,Grozrash,Guj'ika,Harji,Hej'hez,Herkush,Horgarrez,Illuph,Ipjar,Ithashin,Kaj'ess,Kar'kash,Kepjusha,Ki'kintus,Kissere,Koph,Kopjess,Kra'kasher,Krak,Krapjez,Krashjuless,Kraz'ji,Krirrigis,Krussin,Ma'lush,Mage,Maj'tak,Mal'a,Mapja,Mar'kash,Mar'kis,Marjin,Mas,Mathan,Men'jas,Meth'jaresh,Mij'hegak,Min'jash,Mith'jas,Monassu,Moss,Naj'hass,Najugash,Nak,Napjiph,Nar'ka,Nar'thuss,Narrusha,Nash,Nashjekez,Nataph,Nij'ass,Nij'tessiph,Nishjiss,Norkkuss,Nus,Olluruss,Or'thi,Or'thuss,Paj'a,Parkka,Pas,Pathujen,Paz'jaz,Pepjerras,Pirkkanar,Pituk,Porjunek,Pu'ke,Ragen,Ran'jess,Rargush,Razjuph,Rilzan,Riss,Rithruz,Rorgiss,Rossez,Rraj'asesh,Rraj'tass,Rrar'kess,Rrar'thuph,Rras,Rrazresh,Rrej'hish,Rrigelash,Rris,Rris,Rroksurrush,Rukrussush,Rurri,Russa,Ruth'jes,Sa'kitesh,Sar'thass,Sarjas,Sazjuzush,Ser'thez,Sezrass,Shajas,Shas,Shashja,Shass,Shetesh,Shijek,Shun'jaler,Shurjarri,Skaler,Skalla,Skallentas,Skaph,Skar'kerriz,Skath'jeruk,Sker'kalas,Skor,Skoz'ji,Sku'lu,Skuph,Skur'thur,Slalli,Slalt'har,Slelziress,Slil'ar,Sloz'jisa,Sojesh,Solle,Sorge,Sral'e,Sran'ji,Srapjess,Srar'thazur,Srash,Srath'jess,Srathrarre,Srerkkash,Srus,Sruss'tugeph,Sun,Suss'tir,Uzrash,Vargush,Vek,Vess'tu,Viph,Vult'ha,Vupjer,Vushjesash,Xagez,Xassa,Xulzessu,Zaj'tiss,Zan'jer,Zarriss,Zassegus,Zirres,Zsor,Zurjass", + }, + // additional by Avengium: + { + name: "Levantine", + i: 42, + min: 4, + max: 12, + d: "ankprs", + m: 0, + b: "Adme,Adramet,Agadir,Akko,Akzib,Alimas,Alis-Ubbo,Alqosh,Amid,Ammon,Ampi,Amurru,Andarig,Anpa,Araden,Aram,Arwad,Ashkelon,Athar,Atiq,Aza,Azeka,Baalbek,Babel,Batrun,Beerot,Beersheba,Beit Shemesh,Berytus,Bet Agus,Bet Anya,Beth-Horon,Bethel,Bethlehem,Bethuel,Bet Nahrin,Bet Nohadra,Bet Zalin,Birmula,Biruta,Bit Agushi,Bitan,Bit Zamani,Cerne,Dammeseq,Darmsuq,Dor,Eddial,Eden Ekron,Elah,Emek,Emun,Ephratah,Eyn Ganim,Finike,Gades,Galatia,Gaza,Gebal,Gedera,Gerizzim,Gethsemane,Gibeon,Gilead,Gilgal,Golgotha,Goshen,Gytte,Hagalil,Haifa,Halab,Haqel Dma,Har Habayit,Har Nevo,Har Pisga,Havilah,Hazor,Hebron,Hormah,Iboshim,Iriho,Irinem,Irridu,Israel,Kadesh,Kanaan,Kapara,Karaly,Kart-Hadasht,Keret Chadeshet,Kernah,Kesed,Keysariya,Kfar,Kfar Nahum,Khalibon,Khalpe,Khamat,Kiryat,Kittim,Kurda,Lapethos,Larna,Lepqis,Lepriptza,Liksos,Lod,Luv,Malaka,Malet,Marat,Megido,Melitta,Merdin,Metsada,Mishmarot,Mitzrayim,Moab,Mopsos,Motye,Mukish,Nampigi,Nampigu,Natzrat,Nimrud,Nineveh,Nob,Nuhadra,Oea,Ofir,Oyat,Phineka,Phoenicus,Pleshet,Qart-Tubah Sarepta,Qatna,Rabat Amon,Rakkath,Ramat Aviv,Ramitha,Ramta,Rehovot,Reshef,Rushadir,Rushakad,Samrin,Sefarad,Sehyon,Sepat,Sexi,Sharon,Shechem,Shefelat,Shfanim,Shiloh,Shmaya,Shomron,Sidon,Sinay,Sis,Solki,Sur,Suria,Tabetu,Tadmur,Tarshish,Tartus,Teberya,Tefessedt,Tekoa,Teyman,Tinga,Tipasa,Tsabratan,Tur Abdin,Tzarfat,Tziyon,Tzor,Ugarit,Unubaal,Ureshlem,Urhay,Urushalim,Vaga,Yaffa,Yamhad,Yam hamelach,Yam Kineret,Yamutbal,Yathrib,Yaudi,Yavne,Yehuda,Yerushalayim,Yev,Yevus,Yizreel,Yurdnan,Zarefat,Zeboim,Zeurta,Zeytim,Zikhron,Zmurna", + }, + ]; + } +} + +window.Names = new NamesGenerator(); diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 9aecef43..2cbec13a 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -39,4 +39,5 @@ export interface PackedGraph { }; rivers: River[]; features: PackedGraphFeature[]; + cultures: any[]; } diff --git a/src/types/global.ts b/src/types/global.ts index 43e2c1b0..fc0b8613 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -1,4 +1,5 @@ import type { Selection } from "d3"; +import type { NameBase } from "../modules/names-generator"; import type { PackedGraph } from "./PackedGraph"; declare global { @@ -12,9 +13,11 @@ declare global { var ERROR: boolean; var heightmapTemplates: any; - var Names: any; + var nameBases: NameBase[]; + var pointsInput: HTMLInputElement; var heightExponentInput: HTMLInputElement; + var mapName: HTMLInputElement; var rivers: Selection; var oceanLayers: Selection; @@ -28,4 +31,12 @@ declare global { icons: string[][]; cost: number[]; }; + + var tip: ( + message: string, + autoHide?: boolean, + type?: "info" | "warning" | "error", + ) => void; + var locked: (settingId: string) => boolean; + var unlock: (settingId: string) => void; } From 3807903cae21bea66b33433765156a19aa3ef9c1 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Tue, 27 Jan 2026 21:00:55 +0100 Subject: [PATCH 13/24] refactor: migrate cultures generator (#1287) * refactor: migrate cultures generator * Update src/modules/cultures-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore:lint * fix: wrong call structure --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/modules/cultures-generator.js | 618 ----------- src/index.html | 1 - src/modules/cultures-generator.ts | 1405 ++++++++++++++++++++++++++ src/modules/index.ts | 1 + src/types/PackedGraph.ts | 10 +- src/types/global.ts | 6 + 6 files changed, 1419 insertions(+), 622 deletions(-) delete mode 100644 public/modules/cultures-generator.js create mode 100644 src/modules/cultures-generator.ts diff --git a/public/modules/cultures-generator.js b/public/modules/cultures-generator.js deleted file mode 100644 index 34dc5edd..00000000 --- a/public/modules/cultures-generator.js +++ /dev/null @@ -1,618 +0,0 @@ -"use strict"; - -window.Cultures = (function () { - let cells; - - const generate = function () { - TIME && console.time("generateCultures"); - cells = pack.cells; - - const cultureIds = new Uint16Array(cells.i.length); // cell cultures - - const culturesInputNumber = +byId("culturesInput").value; - const culturesInSetNumber = +byId("culturesSet").selectedOptions[0].dataset.max; - let count = Math.min(culturesInputNumber, culturesInSetNumber); - - const populated = cells.i.filter(i => cells.s[i]); // populated cells - if (populated.length < count * 25) { - count = Math.floor(populated.length / 50); - if (!count) { - WARN && console.warn(`There are no populated cells. Cannot generate cultures`); - pack.cultures = [{name: "Wildlands", i: 0, base: 1, shield: "round"}]; - cells.culture = cultureIds; - - alertMessage.innerHTML = /* html */ `The climate is harsh and people cannot live in this world.
- No cultures, states and burgs will be created.
- Please consider changing climate settings in the World Configurator`; - - $("#alert").dialog({ - resizable: false, - title: "Extreme climate warning", - buttons: { - Ok: function () { - $(this).dialog("close"); - } - } - }); - return; - } else { - WARN && console.warn(`Not enough populated cells (${populated.length}). Will generate only ${count} cultures`); - alertMessage.innerHTML = /* html */ ` There are only ${populated.length} populated cells and it's insufficient livable area.
- Only ${count} out of ${culturesInput.value} requested cultures will be generated.
- Please consider changing climate settings in the World Configurator`; - $("#alert").dialog({ - resizable: false, - title: "Extreme climate warning", - buttons: { - Ok: function () { - $(this).dialog("close"); - } - } - }); - } - } - - const cultures = (pack.cultures = selectCultures(count)); - const centers = d3.quadtree(); - const colors = getColors(count); - const emblemShape = document.getElementById("emblemShape").value; - - const codes = []; - - cultures.forEach(function (c, i) { - const newId = i + 1; - - if (c.lock) { - codes.push(c.code); - centers.add(c.center); - - for (const i of cells.i) { - if (cells.culture[i] === c.i) cultureIds[i] = newId; - } - - c.i = newId; - return; - } - - const sortingFn = c.sort ? c.sort : i => cells.s[i]; - const center = placeCenter(sortingFn); - - centers.add(cells.p[center]); - c.center = center; - c.i = newId; - delete c.odd; - delete c.sort; - c.color = colors[i]; - c.type = defineCultureType(center); - c.expansionism = defineCultureExpansionism(c.type); - c.origins = [0]; - c.code = abbreviate(c.name, codes); - codes.push(c.code); - cultureIds[center] = newId; - if (emblemShape === "random") c.shield = getRandomShield(); - }); - - cells.culture = cultureIds; - - function placeCenter(sortingFn) { - let spacing = (graphWidth + graphHeight) / 2 / count; - const MAX_ATTEMPTS = 100; - - const sorted = [...populated].sort((a, b) => sortingFn(b) - sortingFn(a)); - const max = Math.floor(sorted.length / 2); - - let cellId = 0; - for (let i = 0; i < MAX_ATTEMPTS; i++) { - cellId = sorted[biased(0, max, 5)]; - spacing *= 0.9; - if (!cultureIds[cellId] && !centers.find(cells.p[cellId][0], cells.p[cellId][1], spacing)) break; - } - - return cellId; - } - - // the first culture with id 0 is for wildlands - cultures.unshift({name: "Wildlands", i: 0, base: 1, origins: [null], shield: "round"}); - - // make sure all bases exist in nameBases - if (!nameBases.length) { - ERROR && console.error("Name base is empty, default nameBases will be applied"); - nameBases = Names.getNameBases(); - } - - cultures.forEach(c => (c.base = c.base % nameBases.length)); - - function selectCultures(culturesNumber) { - let defaultCultures = getDefault(culturesNumber); - const cultures = []; - - pack.cultures?.forEach(function (culture) { - if (culture.lock && !culture.removed) cultures.push(culture); - }); - - if (!cultures.length) { - if (culturesNumber === defaultCultures.length) return defaultCultures; - if (defaultCultures.every(d => d.odd === 1)) return defaultCultures.splice(0, culturesNumber); - } - - for (let culture, rnd, i = 0; cultures.length < culturesNumber && defaultCultures.length > 0; ) { - do { - rnd = rand(defaultCultures.length - 1); - culture = defaultCultures[rnd]; - i++; - } while (i < 200 && !P(culture.odd)); - cultures.push(culture); - defaultCultures.splice(rnd, 1); - } - return cultures; - } - - // set culture type based on culture center position - function defineCultureType(i) { - if (cells.h[i] < 70 && [1, 2, 4].includes(cells.biome[i])) return "Nomadic"; // high penalty in forest biomes and near coastline - if (cells.h[i] > 50) return "Highland"; // no penalty for hills and moutains, high for other elevations - const f = pack.features[cells.f[cells.haven[i]]]; // opposite feature - if (f.type === "lake" && f.cells > 5) return "Lake"; // low water cross penalty and high for growth not along coastline - if ( - (cells.harbor[i] && f.type !== "lake" && P(0.1)) || - (cells.harbor[i] === 1 && P(0.6)) || - (pack.features[cells.f[i]].group === "isle" && P(0.4)) - ) - return "Naval"; // low water cross penalty and high for non-along-coastline growth - if (cells.r[i] && cells.fl[i] > 100) return "River"; // no River cross penalty, penalty for non-River growth - if (cells.t[i] > 2 && [3, 7, 8, 9, 10, 12].includes(cells.biome[i])) return "Hunting"; // high penalty in non-native biomes - return "Generic"; - } - - function defineCultureExpansionism(type) { - let base = 1; // Generic - if (type === "Lake") base = 0.8; - else if (type === "Naval") base = 1.5; - else if (type === "River") base = 0.9; - else if (type === "Nomadic") base = 1.5; - else if (type === "Hunting") base = 0.7; - else if (type === "Highland") base = 1.2; - return rn(((Math.random() * byId("sizeVariety").value) / 2 + 1) * base, 1); - } - - TIME && console.timeEnd("generateCultures"); - }; - - const add = function (center) { - const defaultCultures = getDefault(); - let culture, base, name; - - if (pack.cultures.length < defaultCultures.length) { - // add one of the default cultures - culture = pack.cultures.length; - base = defaultCultures[culture].base; - name = defaultCultures[culture].name; - } else { - // add random culture besed on one of the current ones - culture = rand(pack.cultures.length - 1); - name = Names.getCulture(culture, 5, 8, ""); - base = pack.cultures[culture].base; - } - - const code = abbreviate( - name, - pack.cultures.map(c => c.code) - ); - const i = pack.cultures.length; - const color = getRandomColor(); - - // define emblem shape - let shield = culture.shield; - const emblemShape = document.getElementById("emblemShape").value; - if (emblemShape === "random") shield = getRandomShield(); - - pack.cultures.push({ - name, - color, - base, - center, - i, - expansionism: 1, - type: "Generic", - cells: 0, - area: 0, - rural: 0, - urban: 0, - origins: [pack.cells.culture[center]], - code, - shield - }); - }; - - const getDefault = function (count) { - // generic sorting functions - const cells = pack.cells, - s = cells.s, - sMax = d3.max(s), - t = cells.t, - h = cells.h, - temp = grid.cells.temp; - const n = cell => Math.ceil((s[cell] / sMax) * 3); // normalized cell score - const td = (cell, goal) => { - const d = Math.abs(temp[cells.g[cell]] - goal); - return d ? d + 1 : 1; - }; // temperature difference fee - const bd = (cell, biomes, fee = 4) => (biomes.includes(cells.biome[cell]) ? 1 : fee); // biome difference fee - const sf = (cell, fee = 4) => - cells.haven[cell] && pack.features[cells.f[cells.haven[cell]]].type !== "lake" ? 1 : fee; // not on sea coast fee - - if (culturesSet.value === "european") { - return [ - {name: "Shwazen", base: 0, odd: 1, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "swiss"}, - {name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "wedged"}, - {name: "Luari", base: 2, odd: 1, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "french"}, - {name: "Tallian", base: 3, odd: 1, sort: i => n(i) / td(i, 15), shield: "horsehead"}, - {name: "Astellian", base: 4, odd: 1, sort: i => n(i) / td(i, 16), shield: "spanish"}, - {name: "Slovan", base: 5, odd: 1, sort: i => (n(i) / td(i, 6)) * t[i], shield: "polish"}, - {name: "Norse", base: 6, odd: 1, sort: i => n(i) / td(i, 5), shield: "heater"}, - {name: "Elladan", base: 7, odd: 1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "boeotian"}, - {name: "Romian", base: 8, odd: 0.2, sort: i => n(i) / td(i, 15) / t[i], shield: "roman"}, - {name: "Soumi", base: 9, odd: 1, sort: i => (n(i) / td(i, 5) / bd(i, [9])) * t[i], shield: "pavise"}, - {name: "Portuzian", base: 13, odd: 1, sort: i => n(i) / td(i, 17) / sf(i), shield: "renaissance"}, - {name: "Vengrian", base: 15, odd: 1, sort: i => (n(i) / td(i, 11) / bd(i, [4])) * t[i], shield: "horsehead2"}, - {name: "Turchian", base: 16, odd: 0.05, sort: i => n(i) / td(i, 14), shield: "round"}, - {name: "Euskati", base: 20, odd: 0.05, sort: i => (n(i) / td(i, 15)) * h[i], shield: "oldFrench"}, - {name: "Keltan", base: 22, odd: 0.05, sort: i => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], shield: "oval"} - ]; - } - - if (culturesSet.value === "oriental") { - return [ - {name: "Koryo", base: 10, odd: 1, sort: i => n(i) / td(i, 12) / t[i], shield: "round"}, - {name: "Hantzu", base: 11, odd: 1, sort: i => n(i) / td(i, 13), shield: "banner"}, - {name: "Yamoto", base: 12, odd: 1, sort: i => n(i) / td(i, 15) / t[i], shield: "round"}, - {name: "Turchian", base: 16, odd: 1, sort: i => n(i) / td(i, 12), shield: "round"}, - { - name: "Berberan", - base: 17, - odd: 0.2, - sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], - shield: "oval" - }, - {name: "Eurabic", base: 18, odd: 1, sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], shield: "oval"}, - {name: "Efratic", base: 23, odd: 0.1, sort: i => (n(i) / td(i, 22)) * t[i], shield: "round"}, - {name: "Tehrani", base: 24, odd: 1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "round"}, - {name: "Maui", base: 25, odd: 0.2, sort: i => n(i) / td(i, 24) / sf(i) / t[i], shield: "vesicaPiscis"}, - {name: "Carnatic", base: 26, odd: 0.5, sort: i => n(i) / td(i, 26), shield: "round"}, - {name: "Vietic", base: 29, odd: 0.8, sort: i => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], shield: "banner"}, - {name: "Guantzu", base: 30, odd: 0.5, sort: i => n(i) / td(i, 17), shield: "banner"}, - {name: "Ulus", base: 31, odd: 1, sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], shield: "banner"} - ]; - } - - if (culturesSet.value === "english") { - const getName = () => Names.getBase(1, 5, 9, "", 0); - return [ - {name: getName(), base: 1, odd: 1, shield: "heater"}, - {name: getName(), base: 1, odd: 1, shield: "wedged"}, - {name: getName(), base: 1, odd: 1, shield: "swiss"}, - {name: getName(), base: 1, odd: 1, shield: "oldFrench"}, - {name: getName(), base: 1, odd: 1, shield: "swiss"}, - {name: getName(), base: 1, odd: 1, shield: "spanish"}, - {name: getName(), base: 1, odd: 1, shield: "hessen"}, - {name: getName(), base: 1, odd: 1, shield: "fantasy5"}, - {name: getName(), base: 1, odd: 1, shield: "fantasy4"}, - {name: getName(), base: 1, odd: 1, shield: "fantasy1"} - ]; - } - - if (culturesSet.value === "antique") { - return [ - {name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 14) / t[i], shield: "roman"}, // Roman - {name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 15) / sf(i), shield: "roman"}, // Roman - {name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 16) / sf(i), shield: "roman"}, // Roman - {name: "Roman", base: 8, odd: 1, sort: i => n(i) / td(i, 17) / t[i], shield: "roman"}, // Roman - {name: "Hellenic", base: 7, odd: 1, sort: i => (n(i) / td(i, 18) / sf(i)) * h[i], shield: "boeotian"}, // Greek - {name: "Hellenic", base: 7, odd: 1, sort: i => (n(i) / td(i, 19) / sf(i)) * h[i], shield: "boeotian"}, // Greek - {name: "Macedonian", base: 7, odd: 0.5, sort: i => (n(i) / td(i, 12)) * h[i], shield: "round"}, // Greek - {name: "Celtic", base: 22, odd: 1, sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), shield: "round"}, - {name: "Germanic", base: 0, odd: 1, sort: i => n(i) / td(i, 10) ** 0.5 / bd(i, [6, 8]), shield: "round"}, - {name: "Persian", base: 24, odd: 0.8, sort: i => (n(i) / td(i, 18)) * h[i], shield: "oval"}, // Iranian - {name: "Scythian", base: 24, odd: 0.5, sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [4]), shield: "round"}, // Iranian - {name: "Cantabrian", base: 20, odd: 0.5, sort: i => (n(i) / td(i, 16)) * h[i], shield: "oval"}, // Basque - {name: "Estian", base: 9, odd: 0.2, sort: i => (n(i) / td(i, 5)) * t[i], shield: "pavise"}, // Finnic - {name: "Carthaginian", base: 42, odd: 0.3, sort: i => n(i) / td(i, 20) / sf(i), shield: "oval"}, // Levantine - {name: "Hebrew", base: 42, odd: 0.2, sort: i => (n(i) / td(i, 19)) * sf(i), shield: "oval"}, // Levantine - {name: "Mesopotamian", base: 23, odd: 0.2, sort: i => n(i) / td(i, 22) / bd(i, [1, 2, 3]), shield: "oval"} // Mesopotamian - ]; - } - - if (culturesSet.value === "highFantasy") { - return [ - // fantasy races - { - name: "Quenian (Elfish)", - base: 33, - odd: 1, - sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], - shield: "gondor" - }, // Elves - { - name: "Eldar (Elfish)", - base: 33, - odd: 1, - sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], - shield: "noldor" - }, // Elves - { - name: "Trow (Dark Elfish)", - base: 34, - odd: 0.9, - sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], - shield: "hessen" - }, // Dark Elves - { - name: "Lothian (Dark Elfish)", - base: 34, - odd: 0.3, - sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], - shield: "wedged" - }, // Dark Elves - {name: "Dunirr (Dwarven)", base: 35, odd: 1, sort: i => n(i) + h[i], shield: "ironHills"}, // Dwarfs - {name: "Khazadur (Dwarven)", base: 35, odd: 1, sort: i => n(i) + h[i], shield: "erebor"}, // Dwarfs - {name: "Kobold (Goblin)", base: 36, odd: 1, sort: i => t[i] - s[i], shield: "moriaOrc"}, // Goblin - {name: "Uruk (Orkish)", base: 37, odd: 1, sort: i => h[i] * t[i], shield: "urukHai"}, // Orc - { - name: "Ugluk (Orkish)", - base: 37, - odd: 0.5, - sort: i => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), - shield: "moriaOrc" - }, // Orc - {name: "Yotunn (Giants)", base: 38, odd: 0.7, sort: i => td(i, -10), shield: "pavise"}, // Giant - {name: "Rake (Drakonic)", base: 39, odd: 0.7, sort: i => -s[i], shield: "fantasy2"}, // Draconic - {name: "Arago (Arachnid)", base: 40, odd: 0.7, sort: i => t[i] - s[i], shield: "horsehead2"}, // Arachnid - {name: "Aj'Snaga (Serpents)", base: 41, odd: 0.7, sort: i => n(i) / bd(i, [12], 10), shield: "fantasy1"}, // Serpents - // fantasy human - {name: "Anor (Human)", base: 32, odd: 1, sort: i => n(i) / td(i, 10), shield: "fantasy5"}, - {name: "Dail (Human)", base: 32, odd: 1, sort: i => n(i) / td(i, 13), shield: "roman"}, - {name: "Rohand (Human)", base: 16, odd: 1, sort: i => n(i) / td(i, 16), shield: "round"}, - { - name: "Dulandir (Human)", - base: 31, - odd: 1, - sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], - shield: "easterling" - } - ]; - } - - if (culturesSet.value === "darkFantasy") { - return [ - // common real-world English - {name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "heater"}, - {name: "Enlandic", base: 1, odd: 1, sort: i => n(i) / td(i, 12), shield: "heater"}, - {name: "Westen", base: 1, odd: 1, sort: i => n(i) / td(i, 10), shield: "heater"}, - {name: "Nortumbic", base: 1, odd: 1, sort: i => n(i) / td(i, 7), shield: "heater"}, - {name: "Mercian", base: 1, odd: 1, sort: i => n(i) / td(i, 9), shield: "heater"}, - {name: "Kentian", base: 1, odd: 1, sort: i => n(i) / td(i, 12), shield: "heater"}, - // rare real-world western - {name: "Norse", base: 6, odd: 0.7, sort: i => n(i) / td(i, 5) / sf(i), shield: "oldFrench"}, - {name: "Schwarzen", base: 0, odd: 0.3, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "gonfalon"}, - {name: "Luarian", base: 2, odd: 0.3, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "oldFrench"}, - {name: "Hetallian", base: 3, odd: 0.3, sort: i => n(i) / td(i, 15), shield: "oval"}, - {name: "Astellian", base: 4, odd: 0.3, sort: i => n(i) / td(i, 16), shield: "spanish"}, - // rare real-world exotic - { - name: "Kiswaili", - base: 28, - odd: 0.05, - sort: i => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), - shield: "vesicaPiscis" - }, - {name: "Yoruba", base: 21, odd: 0.05, sort: i => n(i) / td(i, 15) / bd(i, [5, 7]), shield: "vesicaPiscis"}, - {name: "Koryo", base: 10, odd: 0.05, sort: i => n(i) / td(i, 12) / t[i], shield: "round"}, - {name: "Hantzu", base: 11, odd: 0.05, sort: i => n(i) / td(i, 13), shield: "banner"}, - {name: "Yamoto", base: 12, odd: 0.05, sort: i => n(i) / td(i, 15) / t[i], shield: "round"}, - {name: "Guantzu", base: 30, odd: 0.05, sort: i => n(i) / td(i, 17), shield: "banner"}, - { - name: "Ulus", - base: 31, - odd: 0.05, - sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], - shield: "banner" - }, - {name: "Turan", base: 16, odd: 0.05, sort: i => n(i) / td(i, 12), shield: "round"}, - { - name: "Berberan", - base: 17, - odd: 0.05, - sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], - shield: "round" - }, - { - name: "Eurabic", - base: 18, - odd: 0.05, - sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], - shield: "round" - }, - {name: "Slovan", base: 5, odd: 0.05, sort: i => (n(i) / td(i, 6)) * t[i], shield: "round"}, - { - name: "Keltan", - base: 22, - odd: 0.1, - sort: i => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), - shield: "vesicaPiscis" - }, - {name: "Elladan", base: 7, odd: 0.2, sort: i => (n(i) / td(i, 18) / sf(i)) * h[i], shield: "boeotian"}, - {name: "Romian", base: 8, odd: 0.2, sort: i => n(i) / td(i, 14) / t[i], shield: "roman"}, - // fantasy races - {name: "Eldar", base: 33, odd: 0.5, sort: i => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], shield: "fantasy5"}, // Elves - {name: "Trow", base: 34, odd: 0.8, sort: i => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], shield: "hessen"}, // Dark Elves - {name: "Durinn", base: 35, odd: 0.8, sort: i => n(i) + h[i], shield: "erebor"}, // Dwarven - {name: "Kobblin", base: 36, odd: 0.8, sort: i => t[i] - s[i], shield: "moriaOrc"}, // Goblin - {name: "Uruk", base: 37, odd: 0.8, sort: i => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), shield: "urukHai"}, // Orc - {name: "Yotunn", base: 38, odd: 0.8, sort: i => td(i, -10), shield: "pavise"}, // Giant - {name: "Drake", base: 39, odd: 0.9, sort: i => -s[i], shield: "fantasy2"}, // Draconic - {name: "Rakhnid", base: 40, odd: 0.9, sort: i => t[i] - s[i], shield: "horsehead2"}, // Arachnid - {name: "Aj'Snaga", base: 41, odd: 0.9, sort: i => n(i) / bd(i, [12], 10), shield: "fantasy1"} // Serpents - ]; - } - - if (culturesSet.value === "random") { - return d3.range(count).map(function () { - const rnd = rand(nameBases.length - 1); - const name = Names.getBaseShort(rnd); - return {name, base: rnd, odd: 1, shield: getRandomShield()}; - }); - } - - // all-world - return [ - {name: "Shwazen", base: 0, odd: 0.7, sort: i => n(i) / td(i, 10) / bd(i, [6, 8]), shield: "hessen"}, - {name: "Angshire", base: 1, odd: 1, sort: i => n(i) / td(i, 10) / sf(i), shield: "heater"}, - {name: "Luari", base: 2, odd: 0.6, sort: i => n(i) / td(i, 12) / bd(i, [6, 8]), shield: "oldFrench"}, - {name: "Tallian", base: 3, odd: 0.6, sort: i => n(i) / td(i, 15), shield: "horsehead2"}, - {name: "Astellian", base: 4, odd: 0.6, sort: i => n(i) / td(i, 16), shield: "spanish"}, - {name: "Slovan", base: 5, odd: 0.7, sort: i => (n(i) / td(i, 6)) * t[i], shield: "round"}, - {name: "Norse", base: 6, odd: 0.7, sort: i => n(i) / td(i, 5), shield: "heater"}, - {name: "Elladan", base: 7, odd: 0.7, sort: i => (n(i) / td(i, 18)) * h[i], shield: "boeotian"}, - {name: "Romian", base: 8, odd: 0.7, sort: i => n(i) / td(i, 15), shield: "roman"}, - {name: "Soumi", base: 9, odd: 0.3, sort: i => (n(i) / td(i, 5) / bd(i, [9])) * t[i], shield: "pavise"}, - {name: "Koryo", base: 10, odd: 0.1, sort: i => n(i) / td(i, 12) / t[i], shield: "round"}, - {name: "Hantzu", base: 11, odd: 0.1, sort: i => n(i) / td(i, 13), shield: "banner"}, - {name: "Yamoto", base: 12, odd: 0.1, sort: i => n(i) / td(i, 15) / t[i], shield: "round"}, - {name: "Portuzian", base: 13, odd: 0.4, sort: i => n(i) / td(i, 17) / sf(i), shield: "spanish"}, - {name: "Nawatli", base: 14, odd: 0.1, sort: i => h[i] / td(i, 18) / bd(i, [7]), shield: "square"}, - {name: "Vengrian", base: 15, odd: 0.2, sort: i => (n(i) / td(i, 11) / bd(i, [4])) * t[i], shield: "wedged"}, - {name: "Turchian", base: 16, odd: 0.2, sort: i => n(i) / td(i, 13), shield: "round"}, - { - name: "Berberan", - base: 17, - odd: 0.1, - sort: i => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], - shield: "round" - }, - {name: "Eurabic", base: 18, odd: 0.2, sort: i => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], shield: "round"}, - {name: "Inuk", base: 19, odd: 0.05, sort: i => td(i, -1) / bd(i, [10, 11]) / sf(i), shield: "square"}, - {name: "Euskati", base: 20, odd: 0.05, sort: i => (n(i) / td(i, 15)) * h[i], shield: "spanish"}, - {name: "Yoruba", base: 21, odd: 0.05, sort: i => n(i) / td(i, 15) / bd(i, [5, 7]), shield: "vesicaPiscis"}, - { - name: "Keltan", - base: 22, - odd: 0.05, - sort: i => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], - shield: "vesicaPiscis" - }, - {name: "Efratic", base: 23, odd: 0.05, sort: i => (n(i) / td(i, 22)) * t[i], shield: "diamond"}, - {name: "Tehrani", base: 24, odd: 0.1, sort: i => (n(i) / td(i, 18)) * h[i], shield: "round"}, - {name: "Maui", base: 25, odd: 0.05, sort: i => n(i) / td(i, 24) / sf(i) / t[i], shield: "round"}, - {name: "Carnatic", base: 26, odd: 0.05, sort: i => n(i) / td(i, 26), shield: "round"}, - {name: "Inqan", base: 27, odd: 0.05, sort: i => h[i] / td(i, 13), shield: "square"}, - {name: "Kiswaili", base: 28, odd: 0.1, sort: i => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), shield: "vesicaPiscis"}, - {name: "Vietic", base: 29, odd: 0.1, sort: i => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], shield: "banner"}, - {name: "Guantzu", base: 30, odd: 0.1, sort: i => n(i) / td(i, 17), shield: "banner"}, - {name: "Ulus", base: 31, odd: 0.1, sort: i => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], shield: "banner"}, - {name: "Hebrew", base: 42, odd: 0.2, sort: i => (n(i) / td(i, 18)) * sf(i), shield: "oval"} // Levantine - ]; - }; - - // expand cultures across the map (Dijkstra-like algorithm) - const expand = function () { - TIME && console.time("expandCultures"); - const {cells, cultures} = pack; - - const queue = new FlatQueue(); - const cost = []; - - const neutralRate = byId("neutralRate")?.valueAsNumber || 1; - const maxExpansionCost = cells.i.length * 0.6 * neutralRate; // limit cost for culture growth - - // remove culture from all cells except of locked - const hasLocked = cultures.some(c => !c.removed && c.lock); - if (hasLocked) { - for (const cellId of cells.i) { - const culture = cultures[cells.culture[cellId]]; - if (culture.lock) continue; - cells.culture[cellId] = 0; - } - } else { - cells.culture = new Uint16Array(cells.i.length); - } - - for (const culture of cultures) { - if (!culture.i || culture.removed || culture.lock) continue; - queue.push({cellId: culture.center, cultureId: culture.i, priority: 0}, 0); - } - - while (queue.length) { - const {cellId, priority, cultureId} = queue.pop(); - const {type, expansionism} = cultures[cultureId]; - - cells.c[cellId].forEach(neibCellId => { - if (hasLocked) { - const neibCultureId = cells.culture[neibCellId]; - if (neibCultureId && cultures[neibCultureId].lock) return; // do not overwrite cell of locked culture - } - - const biome = cells.biome[neibCellId]; - const biomeCost = getBiomeCost(cultureId, biome, type); - const biomeChangeCost = biome === cells.biome[neibCellId] ? 0 : 20; // penalty on biome change - const heightCost = getHeightCost(neibCellId, cells.h[neibCellId], type); - const riverCost = getRiverCost(cells.r[neibCellId], neibCellId, type); - const typeCost = getTypeCost(cells.t[neibCellId], type); - - const cellCost = (biomeCost + biomeChangeCost + heightCost + riverCost + typeCost) / expansionism; - const totalCost = priority + cellCost; - - if (totalCost > maxExpansionCost) return; - - if (!cost[neibCellId] || totalCost < cost[neibCellId]) { - if (cells.pop[neibCellId] > 0) cells.culture[neibCellId] = cultureId; // assign culture to populated cell - cost[neibCellId] = totalCost; - queue.push({cellId: neibCellId, cultureId, priority: totalCost}, totalCost); - } - }); - } - - function getBiomeCost(c, biome, type) { - if (cells.biome[cultures[c].center] === biome) return 10; // tiny penalty for native biome - if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters - if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 10; // forest biome penalty for nomads - return biomesData.cost[biome] * 2; // general non-native biome penalty - } - - function getHeightCost(i, h, type) { - const f = pack.features[cells.f[i]], - a = cells.area[i]; - if (type === "Lake" && f.type === "lake") return 10; // no lake crossing penalty for Lake cultures - if (type === "Naval" && h < 20) return a * 2; // low sea/lake crossing penalty for Naval cultures - if (type === "Nomadic" && h < 20) return a * 50; // giant sea/lake crossing penalty for Nomads - if (h < 20) return a * 6; // general sea/lake crossing penalty - if (type === "Highland" && h < 44) return 3000; // giant penalty for highlanders on lowlands - if (type === "Highland" && h < 62) return 200; // giant penalty for highlanders on lowhills - if (type === "Highland") return 0; // no penalty for highlanders on highlands - if (h >= 67) return 200; // general mountains crossing penalty - if (h >= 44) return 30; // general hills crossing penalty - return 0; - } - - function getRiverCost(riverId, cellId, type) { - if (type === "River") return riverId ? 0 : 100; // penalty for river cultures - if (!riverId) return 0; // no penalty for others if there is no river - return minmax(cells.fl[cellId] / 10, 20, 100); // river penalty from 20 to 100 based on flux - } - - function getTypeCost(t, type) { - if (t === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline - if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads - if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals - return 0; - } - - TIME && console.timeEnd("expandCultures"); - }; - - const getRandomShield = function () { - const type = rw(COA.shields.types); - return rw(COA.shields[type]); - }; - - return {generate, add, expand, getDefault, getRandomShield}; -})(); diff --git a/src/index.html b/src/index.html index d0c66986..e65ad0ad 100644 --- a/src/index.html +++ b/src/index.html @@ -8494,7 +8494,6 @@ - diff --git a/src/modules/cultures-generator.ts b/src/modules/cultures-generator.ts new file mode 100644 index 00000000..91f4690d --- /dev/null +++ b/src/modules/cultures-generator.ts @@ -0,0 +1,1405 @@ +import { max, quadtree, range } from "d3"; +import { + abbreviate, + biased, + byId, + getColors, + getRandomColor, + minmax, + P, + rand, + rn, + rw, +} from "../utils"; + +declare global { + var Cultures: CulturesModule; +} + +export interface Culture { + name: string; + i: number; + base: number; + shield: string; + lock?: boolean; + code?: string; + center?: number; + sort?: (i: number) => number; + odd?: number; + color?: string; + type?: string; + expansionism?: number; + origins?: (number | null)[]; + removed?: boolean; + cells?: number; + area?: number; + rural?: number; + urban?: number; +} + +class CulturesModule { + cells: any; + + getRandomShield() { + const type = rw(COA.shields.types); + return rw(COA.shields[type]); + } + + getDefault(count: number = 0): Omit[] { + // generic sorting functions + const cells = pack.cells, + s = cells.s, + sMax = max(s) as number, + t = cells.t, + h = cells.h, + temp = grid.cells.temp; + const n = (cell: number) => Math.ceil((s[cell] / sMax) * 3); // normalized cell score + const td = (cell: number, goal: number) => { + const d = Math.abs(temp[cells.g[cell]] - goal); + return d ? d + 1 : 1; + }; // temperature difference fee + const bd = (cell: number, biomes: number[], fee = 4) => + biomes.includes(cells.biome[cell]) ? 1 : fee; // biome difference fee + const sf = (cell: number, fee = 4) => + cells.haven[cell] && + pack.features[cells.f[cells.haven[cell]]].type !== "lake" + ? 1 + : fee; // not on sea coast fee + + if (culturesSet.value === "european") { + return [ + { + name: "Shwazen", + base: 0, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) / bd(i, [6, 8]), + shield: "swiss", + }, + { + name: "Angshire", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) / sf(i), + shield: "wedged", + }, + { + name: "Luari", + base: 2, + odd: 1, + sort: (i: number) => n(i) / td(i, 12) / bd(i, [6, 8]), + shield: "french", + }, + { + name: "Tallian", + base: 3, + odd: 1, + sort: (i: number) => n(i) / td(i, 15), + shield: "horsehead", + }, + { + name: "Astellian", + base: 4, + odd: 1, + sort: (i: number) => n(i) / td(i, 16), + shield: "spanish", + }, + { + name: "Slovan", + base: 5, + odd: 1, + sort: (i: number) => (n(i) / td(i, 6)) * t[i], + shield: "polish", + }, + { + name: "Norse", + base: 6, + odd: 1, + sort: (i: number) => n(i) / td(i, 5), + shield: "heater", + }, + { + name: "Elladan", + base: 7, + odd: 1, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "boeotian", + }, + { + name: "Romian", + base: 8, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 15) / t[i], + shield: "roman", + }, + { + name: "Soumi", + base: 9, + odd: 1, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [9])) * t[i], + shield: "pavise", + }, + { + name: "Portuzian", + base: 13, + odd: 1, + sort: (i: number) => n(i) / td(i, 17) / sf(i), + shield: "renaissance", + }, + { + name: "Vengrian", + base: 15, + odd: 1, + sort: (i: number) => (n(i) / td(i, 11) / bd(i, [4])) * t[i], + shield: "horsehead2", + }, + { + name: "Turchian", + base: 16, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 14), + shield: "round", + }, + { + name: "Euskati", + base: 20, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 15)) * h[i], + shield: "oldFrench", + }, + { + name: "Keltan", + base: 22, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], + shield: "oval", + }, + ]; + } + + if (culturesSet.value === "oriental") { + return [ + { + name: "Koryo", + base: 10, + odd: 1, + sort: (i: number) => n(i) / td(i, 12) / t[i], + shield: "round", + }, + { + name: "Hantzu", + base: 11, + odd: 1, + sort: (i: number) => n(i) / td(i, 13), + shield: "banner", + }, + { + name: "Yamoto", + base: 12, + odd: 1, + sort: (i: number) => n(i) / td(i, 15) / t[i], + shield: "round", + }, + { + name: "Turchian", + base: 16, + odd: 1, + sort: (i: number) => n(i) / td(i, 12), + shield: "round", + }, + { + name: "Berberan", + base: 17, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], + shield: "oval", + }, + { + name: "Eurabic", + base: 18, + odd: 1, + sort: (i: number) => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], + shield: "oval", + }, + { + name: "Efratic", + base: 23, + odd: 0.1, + sort: (i: number) => (n(i) / td(i, 22)) * t[i], + shield: "round", + }, + { + name: "Tehrani", + base: 24, + odd: 1, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "round", + }, + { + name: "Maui", + base: 25, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 24) / sf(i) / t[i], + shield: "vesicaPiscis", + }, + { + name: "Carnatic", + base: 26, + odd: 0.5, + sort: (i: number) => n(i) / td(i, 26), + shield: "round", + }, + { + name: "Vietic", + base: 29, + odd: 0.8, + sort: (i: number) => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], + shield: "banner", + }, + { + name: "Guantzu", + base: 30, + odd: 0.5, + sort: (i: number) => n(i) / td(i, 17), + shield: "banner", + }, + { + name: "Ulus", + base: 31, + odd: 1, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], + shield: "banner", + }, + ]; + } + + if (culturesSet.value === "english") { + const getName = () => Names.getBase(1, 5, 9, ""); + return [ + { name: getName(), base: 1, odd: 1, shield: "heater" }, + { name: getName(), base: 1, odd: 1, shield: "wedged" }, + { name: getName(), base: 1, odd: 1, shield: "swiss" }, + { name: getName(), base: 1, odd: 1, shield: "oldFrench" }, + { name: getName(), base: 1, odd: 1, shield: "swiss" }, + { name: getName(), base: 1, odd: 1, shield: "spanish" }, + { name: getName(), base: 1, odd: 1, shield: "hessen" }, + { name: getName(), base: 1, odd: 1, shield: "fantasy5" }, + { name: getName(), base: 1, odd: 1, shield: "fantasy4" }, + { name: getName(), base: 1, odd: 1, shield: "fantasy1" }, + ]; + } + + if (culturesSet.value === "antique") { + return [ + { + name: "Roman", + base: 8, + odd: 1, + sort: (i: number) => n(i) / td(i, 14) / t[i], + shield: "roman", + }, // Roman + { + name: "Roman", + base: 8, + odd: 1, + sort: (i: number) => n(i) / td(i, 15) / sf(i), + shield: "roman", + }, // Roman + { + name: "Roman", + base: 8, + odd: 1, + sort: (i: number) => n(i) / td(i, 16) / sf(i), + shield: "roman", + }, // Roman + { + name: "Roman", + base: 8, + odd: 1, + sort: (i: number) => n(i) / td(i, 17) / t[i], + shield: "roman", + }, // Roman + { + name: "Hellenic", + base: 7, + odd: 1, + sort: (i: number) => (n(i) / td(i, 18) / sf(i)) * h[i], + shield: "boeotian", + }, // Greek + { + name: "Hellenic", + base: 7, + odd: 1, + sort: (i: number) => (n(i) / td(i, 19) / sf(i)) * h[i], + shield: "boeotian", + }, // Greek + { + name: "Macedonian", + base: 7, + odd: 0.5, + sort: (i: number) => (n(i) / td(i, 12)) * h[i], + shield: "round", + }, // Greek + { + name: "Celtic", + base: 22, + odd: 1, + sort: (i: number) => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), + shield: "round", + }, + { + name: "Germanic", + base: 0, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) ** 0.5 / bd(i, [6, 8]), + shield: "round", + }, + { + name: "Persian", + base: 24, + odd: 0.8, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "oval", + }, // Iranian + { + name: "Scythian", + base: 24, + odd: 0.5, + sort: (i: number) => n(i) / td(i, 11) ** 0.5 / bd(i, [4]), + shield: "round", + }, // Iranian + { + name: "Cantabrian", + base: 20, + odd: 0.5, + sort: (i: number) => (n(i) / td(i, 16)) * h[i], + shield: "oval", + }, // Basque + { + name: "Estian", + base: 9, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 5)) * t[i], + shield: "pavise", + }, // Finnic + { + name: "Carthaginian", + base: 42, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 20) / sf(i), + shield: "oval", + }, // Levantine + { + name: "Hebrew", + base: 42, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 19)) * sf(i), + shield: "oval", + }, // Levantine + { + name: "Mesopotamian", + base: 23, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 22) / bd(i, [1, 2, 3]), + shield: "oval", + }, // Mesopotamian + ]; + } + + if (culturesSet.value === "highFantasy") { + return [ + // fantasy races + { + name: "Quenian (Elfish)", + base: 33, + odd: 1, + sort: (i: number) => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], + shield: "gondor", + }, // Elves + { + name: "Eldar (Elfish)", + base: 33, + odd: 1, + sort: (i: number) => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], + shield: "noldor", + }, // Elves + { + name: "Trow (Dark Elfish)", + base: 34, + odd: 0.9, + sort: (i: number) => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], + shield: "hessen", + }, // Dark Elves + { + name: "Lothian (Dark Elfish)", + base: 34, + odd: 0.3, + sort: (i: number) => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], + shield: "wedged", + }, // Dark Elves + { + name: "Dunirr (Dwarven)", + base: 35, + odd: 1, + sort: (i: number) => n(i) + h[i], + shield: "ironHills", + }, // Dwarfs + { + name: "Khazadur (Dwarven)", + base: 35, + odd: 1, + sort: (i: number) => n(i) + h[i], + shield: "erebor", + }, // Dwarfs + { + name: "Kobold (Goblin)", + base: 36, + odd: 1, + sort: (i: number) => t[i] - s[i], + shield: "moriaOrc", + }, // Goblin + { + name: "Uruk (Orkish)", + base: 37, + odd: 1, + sort: (i: number) => h[i] * t[i], + shield: "urukHai", + }, // Orc + { + name: "Ugluk (Orkish)", + base: 37, + odd: 0.5, + sort: (i: number) => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), + shield: "moriaOrc", + }, // Orc + { + name: "Yotunn (Giants)", + base: 38, + odd: 0.7, + sort: (i: number) => td(i, -10), + shield: "pavise", + }, // Giant + { + name: "Rake (Drakonic)", + base: 39, + odd: 0.7, + sort: (i: number) => -s[i], + shield: "fantasy2", + }, // Draconic + { + name: "Arago (Arachnid)", + base: 40, + odd: 0.7, + sort: (i: number) => t[i] - s[i], + shield: "horsehead2", + }, // Arachnid + { + name: "Aj'Snaga (Serpents)", + base: 41, + odd: 0.7, + sort: (i: number) => n(i) / bd(i, [12], 10), + shield: "fantasy1", + }, // Serpents + // fantasy human + { + name: "Anor (Human)", + base: 32, + odd: 1, + sort: (i: number) => n(i) / td(i, 10), + shield: "fantasy5", + }, + { + name: "Dail (Human)", + base: 32, + odd: 1, + sort: (i: number) => n(i) / td(i, 13), + shield: "roman", + }, + { + name: "Rohand (Human)", + base: 16, + odd: 1, + sort: (i: number) => n(i) / td(i, 16), + shield: "round", + }, + { + name: "Dulandir (Human)", + base: 31, + odd: 1, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], + shield: "easterling", + }, + ]; + } + + if (culturesSet.value === "darkFantasy") { + return [ + // common real-world English + { + name: "Angshire", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) / sf(i), + shield: "heater", + }, + { + name: "Enlandic", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 12), + shield: "heater", + }, + { + name: "Westen", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 10), + shield: "heater", + }, + { + name: "Nortumbic", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 7), + shield: "heater", + }, + { + name: "Mercian", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 9), + shield: "heater", + }, + { + name: "Kentian", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 12), + shield: "heater", + }, + // rare real-world western + { + name: "Norse", + base: 6, + odd: 0.7, + sort: (i: number) => n(i) / td(i, 5) / sf(i), + shield: "oldFrench", + }, + { + name: "Schwarzen", + base: 0, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 10) / bd(i, [6, 8]), + shield: "gonfalon", + }, + { + name: "Luarian", + base: 2, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 12) / bd(i, [6, 8]), + shield: "oldFrench", + }, + { + name: "Hetallian", + base: 3, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 15), + shield: "oval", + }, + { + name: "Astellian", + base: 4, + odd: 0.3, + sort: (i: number) => n(i) / td(i, 16), + shield: "spanish", + }, + // rare real-world exotic + { + name: "Kiswaili", + base: 28, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), + shield: "vesicaPiscis", + }, + { + name: "Yoruba", + base: 21, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 15) / bd(i, [5, 7]), + shield: "vesicaPiscis", + }, + { + name: "Koryo", + base: 10, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 12) / t[i], + shield: "round", + }, + { + name: "Hantzu", + base: 11, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 13), + shield: "banner", + }, + { + name: "Yamoto", + base: 12, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 15) / t[i], + shield: "round", + }, + { + name: "Guantzu", + base: 30, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 17), + shield: "banner", + }, + { + name: "Ulus", + base: 31, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], + shield: "banner", + }, + { + name: "Turan", + base: 16, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 12), + shield: "round", + }, + { + name: "Berberan", + base: 17, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], + shield: "round", + }, + { + name: "Eurabic", + base: 18, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], + shield: "round", + }, + { + name: "Slovan", + base: 5, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 6)) * t[i], + shield: "round", + }, + { + name: "Keltan", + base: 22, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 11) ** 0.5 / bd(i, [6, 8]), + shield: "vesicaPiscis", + }, + { + name: "Elladan", + base: 7, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 18) / sf(i)) * h[i], + shield: "boeotian", + }, + { + name: "Romian", + base: 8, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 14) / t[i], + shield: "roman", + }, + // fantasy races + { + name: "Eldar", + base: 33, + odd: 0.5, + sort: (i: number) => (n(i) / bd(i, [6, 7, 8, 9], 10)) * t[i], + shield: "fantasy5", + }, // Elves + { + name: "Trow", + base: 34, + odd: 0.8, + sort: (i: number) => (n(i) / bd(i, [7, 8, 9, 12], 10)) * t[i], + shield: "hessen", + }, // Dark Elves + { + name: "Durinn", + base: 35, + odd: 0.8, + sort: (i: number) => n(i) + h[i], + shield: "erebor", + }, // Dwarven + { + name: "Kobblin", + base: 36, + odd: 0.8, + sort: (i: number) => t[i] - s[i], + shield: "moriaOrc", + }, // Goblin + { + name: "Uruk", + base: 37, + odd: 0.8, + sort: (i: number) => (h[i] * t[i]) / bd(i, [1, 2, 10, 11]), + shield: "urukHai", + }, // Orc + { + name: "Yotunn", + base: 38, + odd: 0.8, + sort: (i: number) => td(i, -10), + shield: "pavise", + }, // Giant + { + name: "Drake", + base: 39, + odd: 0.9, + sort: (i: number) => -s[i], + shield: "fantasy2", + }, // Draconic + { + name: "Rakhnid", + base: 40, + odd: 0.9, + sort: (i: number) => t[i] - s[i], + shield: "horsehead2", + }, // Arachnid + { + name: "Aj'Snaga", + base: 41, + odd: 0.9, + sort: (i: number) => n(i) / bd(i, [12], 10), + shield: "fantasy1", + }, // Serpents + ]; + } + + if (culturesSet.value === "random") { + return range(count).map(() => { + const rnd = rand(nameBases.length - 1); + const name = Names.getBaseShort(rnd); + return { name, base: rnd, odd: 1, shield: this.getRandomShield() }; + }); + } + + // all-world + return [ + { + name: "Shwazen", + base: 0, + odd: 0.7, + sort: (i: number) => n(i) / td(i, 10) / bd(i, [6, 8]), + shield: "hessen", + }, + { + name: "Angshire", + base: 1, + odd: 1, + sort: (i: number) => n(i) / td(i, 10) / sf(i), + shield: "heater", + }, + { + name: "Luari", + base: 2, + odd: 0.6, + sort: (i: number) => n(i) / td(i, 12) / bd(i, [6, 8]), + shield: "oldFrench", + }, + { + name: "Tallian", + base: 3, + odd: 0.6, + sort: (i: number) => n(i) / td(i, 15), + shield: "horsehead2", + }, + { + name: "Astellian", + base: 4, + odd: 0.6, + sort: (i: number) => n(i) / td(i, 16), + shield: "spanish", + }, + { + name: "Slovan", + base: 5, + odd: 0.7, + sort: (i: number) => (n(i) / td(i, 6)) * t[i], + shield: "round", + }, + { + name: "Norse", + base: 6, + odd: 0.7, + sort: (i: number) => n(i) / td(i, 5), + shield: "heater", + }, + { + name: "Elladan", + base: 7, + odd: 0.7, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "boeotian", + }, + { + name: "Romian", + base: 8, + odd: 0.7, + sort: (i: number) => n(i) / td(i, 15), + shield: "roman", + }, + { + name: "Soumi", + base: 9, + odd: 0.3, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [9])) * t[i], + shield: "pavise", + }, + { + name: "Koryo", + base: 10, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 12) / t[i], + shield: "round", + }, + { + name: "Hantzu", + base: 11, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 13), + shield: "banner", + }, + { + name: "Yamoto", + base: 12, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 15) / t[i], + shield: "round", + }, + { + name: "Portuzian", + base: 13, + odd: 0.4, + sort: (i: number) => n(i) / td(i, 17) / sf(i), + shield: "spanish", + }, + { + name: "Nawatli", + base: 14, + odd: 0.1, + sort: (i: number) => h[i] / td(i, 18) / bd(i, [7]), + shield: "square", + }, + { + name: "Vengrian", + base: 15, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 11) / bd(i, [4])) * t[i], + shield: "wedged", + }, + { + name: "Turchian", + base: 16, + odd: 0.2, + sort: (i: number) => n(i) / td(i, 13), + shield: "round", + }, + { + name: "Berberan", + base: 17, + odd: 0.1, + sort: (i: number) => (n(i) / td(i, 19) / bd(i, [1, 2, 3], 7)) * t[i], + shield: "round", + }, + { + name: "Eurabic", + base: 18, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 26) / bd(i, [1, 2], 7)) * t[i], + shield: "round", + }, + { + name: "Inuk", + base: 19, + odd: 0.05, + sort: (i: number) => td(i, -1) / bd(i, [10, 11]) / sf(i), + shield: "square", + }, + { + name: "Euskati", + base: 20, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 15)) * h[i], + shield: "spanish", + }, + { + name: "Yoruba", + base: 21, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 15) / bd(i, [5, 7]), + shield: "vesicaPiscis", + }, + { + name: "Keltan", + base: 22, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 11) / bd(i, [6, 8])) * t[i], + shield: "vesicaPiscis", + }, + { + name: "Efratic", + base: 23, + odd: 0.05, + sort: (i: number) => (n(i) / td(i, 22)) * t[i], + shield: "diamond", + }, + { + name: "Tehrani", + base: 24, + odd: 0.1, + sort: (i: number) => (n(i) / td(i, 18)) * h[i], + shield: "round", + }, + { + name: "Maui", + base: 25, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 24) / sf(i) / t[i], + shield: "round", + }, + { + name: "Carnatic", + base: 26, + odd: 0.05, + sort: (i: number) => n(i) / td(i, 26), + shield: "round", + }, + { + name: "Inqan", + base: 27, + odd: 0.05, + sort: (i: number) => h[i] / td(i, 13), + shield: "square", + }, + { + name: "Kiswaili", + base: 28, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 29) / bd(i, [1, 3, 5, 7]), + shield: "vesicaPiscis", + }, + { + name: "Vietic", + base: 29, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 25) / bd(i, [7], 7) / t[i], + shield: "banner", + }, + { + name: "Guantzu", + base: 30, + odd: 0.1, + sort: (i: number) => n(i) / td(i, 17), + shield: "banner", + }, + { + name: "Ulus", + base: 31, + odd: 0.1, + sort: (i: number) => (n(i) / td(i, 5) / bd(i, [2, 4, 10], 7)) * t[i], + shield: "banner", + }, + { + name: "Hebrew", + base: 42, + odd: 0.2, + sort: (i: number) => (n(i) / td(i, 18)) * sf(i), + shield: "oval", + }, // Levantine + ]; + } + + generate() { + TIME && console.time("generateCultures"); + this.cells = pack.cells; + const cultureIds = new Uint16Array(this.cells.i.length); // cell cultures + + const culturesInputNumber = +(byId("culturesInput") as HTMLInputElement) + .value; + const culturesInSetNumber = +( + (byId("culturesSet") as HTMLSelectElement).selectedOptions[0].dataset + .max ?? "0" + ); + let count = Math.min(culturesInputNumber, culturesInSetNumber); + const populated = this.cells.i.filter((i: number) => this.cells.s[i]); // populated cells + + if (populated.length < count * 25) { + count = Math.floor(populated.length / 50); + if (!count) { + WARN && + console.warn( + `There are no populated cells. Cannot generate cultures`, + ); + pack.cultures = [{ name: "Wildlands", i: 0, base: 1, shield: "round" }]; + this.cells.culture = cultureIds; + + alertMessage.innerHTML = /* html */ `The climate is harsh and people cannot live in this world.
+ No cultures, states and burgs will be created.
+ Please consider changing climate settings in the World Configurator`; + + $("#alert").dialog({ + resizable: false, + title: "Extreme climate warning", + buttons: { + Ok: function () { + $(this).dialog("close"); + }, + }, + }); + return; + } else { + WARN && + console.warn( + `Not enough populated cells (${populated.length}). Will generate only ${count} cultures`, + ); + alertMessage.innerHTML = /* html */ ` There are only ${populated.length} populated cells and it's insufficient livable area.
+ Only ${count} out of ${culturesInput.value} requested cultures will be generated.
+ Please consider changing climate settings in the World Configurator`; + $("#alert").dialog({ + resizable: false, + title: "Extreme climate warning", + buttons: { + Ok: function () { + $(this).dialog("close"); + }, + }, + }); + } + } + + const selectCultures = (culturesNumber: number): Culture[] => { + const defaultCultures = this.getDefault(culturesNumber); + const cultures: Culture[] = []; + + pack.cultures?.forEach((culture) => { + if (culture.lock && !culture.removed) cultures.push(culture); + }); + + if (!cultures.length) { + if (culturesNumber === defaultCultures.length) + return defaultCultures as Culture[]; + if (defaultCultures.every((d) => d.odd === 1)) + return defaultCultures.splice(0, culturesNumber) as Culture[]; + } + + for ( + let culture: Culture, rnd: number, i = 0; + cultures.length < culturesNumber && defaultCultures.length > 0; + ) { + do { + rnd = rand(defaultCultures.length - 1); + culture = defaultCultures[rnd] as Culture; + i++; + } while (i < 200 && !P(culture.odd as number)); + cultures.push(culture); + defaultCultures.splice(rnd, 1); + } + return cultures; + }; + + const cultures = selectCultures(count); + pack.cultures = cultures; + const centers = quadtree(); + const colors = getColors(count); + const emblemShape = (byId("emblemShape") as HTMLInputElement).value; + + const codes: string[] = []; + + const placeCenter = (sortingFn: (i: number) => number) => { + let spacing = (graphWidth + graphHeight) / 2 / count; + const MAX_ATTEMPTS = 100; + + const sorted = [...populated].sort((a, b) => sortingFn(b) - sortingFn(a)); + const max = Math.floor(sorted.length / 2); + + let cellId = 0; + for (let i = 0; i < MAX_ATTEMPTS; i++) { + cellId = sorted[biased(0, max, 5)]; + spacing *= 0.9; + if ( + !cultureIds[cellId] && + !centers.find( + this.cells.p[cellId][0], + this.cells.p[cellId][1], + spacing, + ) + ) + break; + } + + return cellId; + }; + + // set culture type based on culture center position + const defineCultureType = (i: number) => { + if (this.cells.h[i] < 70 && [1, 2, 4].includes(this.cells.biome[i])) + return "Nomadic"; // high penalty in forest biomes and near coastline + if (this.cells.h[i] > 50) return "Highland"; // no penalty for hills and mountains, high for other elevations + const f = pack.features[this.cells.f[this.cells.haven[i]]]; // opposite feature + if (f.type === "lake" && f.cells > 5) return "Lake"; // low water cross penalty and high for growth not along coastline + if ( + (this.cells.harbor[i] && f.type !== "lake" && P(0.1)) || + (this.cells.harbor[i] === 1 && P(0.6)) || + (pack.features[this.cells.f[i]].group === "isle" && P(0.4)) + ) + return "Naval"; // low water cross penalty and high for non-along-coastline growth + if (this.cells.r[i] && this.cells.fl[i] > 100) return "River"; // no River cross penalty, penalty for non-River growth + if ( + this.cells.t[i] > 2 && + [3, 7, 8, 9, 10, 12].includes(this.cells.biome[i]) + ) + return "Hunting"; // high penalty in non-native biomes + return "Generic"; + }; + + const defineCultureExpansionism = (type: string) => { + let base = 1; // Generic + if (type === "Lake") base = 0.8; + else if (type === "Naval") base = 1.5; + else if (type === "River") base = 0.9; + else if (type === "Nomadic") base = 1.5; + else if (type === "Hunting") base = 0.7; + else if (type === "Highland") base = 1.2; + return rn( + ((Math.random() * + (byId("sizeVariety") as HTMLInputElement).valueAsNumber) / + 2 + + 1) * + base, + 1, + ); + }; + + cultures.forEach((c: Culture, i: number) => { + const newId = i + 1; + + if (c.lock) { + codes.push(c.code as string); + centers.add(c.center as number); + + for (const i of this.cells.i) { + if (this.cells.culture[i] === c.i) cultureIds[i] = newId; + } + + c.i = newId; + return; + } + + const sortingFn = c.sort ? c.sort : (i: number) => this.cells.s[i]; + const center = placeCenter(sortingFn); + + centers.add(this.cells.p[center]); + c.center = center; + c.i = newId; + delete c.odd; + delete c.sort; + c.color = colors[i]; + c.type = defineCultureType(center); + c.expansionism = defineCultureExpansionism(c.type); + c.origins = [0]; + c.code = abbreviate(c.name, codes); + codes.push(c.code); + cultureIds[center] = newId; + if (emblemShape === "random") c.shield = this.getRandomShield(); + }); + + this.cells.culture = cultureIds; + + // the first culture with id 0 is for wildlands + cultures.unshift({ + name: "Wildlands", + i: 0, + base: 1, + origins: [null], + shield: "round", + }); + + // make sure all bases exist in nameBases + if (!nameBases.length) { + ERROR && + console.error("Name base is empty, default nameBases will be applied"); + nameBases = Names.getNameBases(); + } + + cultures.forEach((c: Culture) => { + c.base = c.base % nameBases.length; + }); + + TIME && console.timeEnd("generateCultures"); + } + + add(center: number) { + const defaultCultures = this.getDefault(); + let culture: number, base: number, name: string; + + if (pack.cultures.length < defaultCultures.length) { + // add one of the default cultures + culture = pack.cultures.length; + base = defaultCultures[culture].base; + name = defaultCultures[culture].name; + } else { + // add random culture based on one of the current ones + culture = rand(pack.cultures.length - 1); + name = Names.getCulture(culture, 5, 8, ""); + base = pack.cultures[culture].base; + } + + const code = abbreviate(name, pack.cultures.map((c) => c.code) as string[]); + const i = pack.cultures.length; + const color = getRandomColor(); + + // define emblem shape + const emblemShape = ( + document.getElementById("emblemShape") as HTMLInputElement + ).value; + + pack.cultures.push({ + name, + color, + base, + center, + i, + expansionism: 1, + type: "Generic", + cells: 0, + area: 0, + rural: 0, + urban: 0, + origins: [pack.cells.culture[center]], + code, + shield: emblemShape === "random" ? this.getRandomShield() : "", + }); + } + + expand() { + TIME && console.time("expandCultures"); + const { cells, cultures } = pack; + + const queue = new FlatQueue(); + const cost: number[] = []; + + const neutralRate = + (byId("neutralRate") as HTMLInputElement)?.valueAsNumber || 1; + const maxExpansionCost = cells.i.length * 0.6 * neutralRate; // limit cost for culture growth + + // remove culture from all cells except of locked + const hasLocked = cultures.some((c) => !c.removed && c.lock); + if (hasLocked) { + for (const cellId of cells.i) { + const culture = cultures[cells.culture[cellId]]; + if (culture.lock) continue; + cells.culture[cellId] = 0; + } + } else { + cells.culture = new Uint16Array(cells.i.length) as unknown as number[]; + } + + for (const culture of cultures) { + if (!culture.i || culture.removed || culture.lock) continue; + queue.push( + { cellId: culture.center, cultureId: culture.i, priority: 0 }, + 0, + ); + } + + const getBiomeCost = (c: number, biome: number, type: string) => { + if (cells.biome[cultures[c].center as number] === biome) return 10; // tiny penalty for native biome + if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters + if (type === "Nomadic" && biome > 4 && biome < 10) + return biomesData.cost[biome] * 10; // forest biome penalty for nomads + return biomesData.cost[biome] * 2; // general non-native biome penalty + }; + + const getHeightCost = (i: number, h: number, type: string) => { + const f = pack.features[cells.f[i]], + a = cells.area[i]; + if (type === "Lake" && f.type === "lake") return 10; // no lake crossing penalty for Lake cultures + if (type === "Naval" && h < 20) return a * 2; // low sea/lake crossing penalty for Naval cultures + if (type === "Nomadic" && h < 20) return a * 50; // giant sea/lake crossing penalty for Nomads + if (h < 20) return a * 6; // general sea/lake crossing penalty + if (type === "Highland" && h < 44) return 3000; // giant penalty for highlanders on lowlands + if (type === "Highland" && h < 62) return 200; // giant penalty for highlanders on lowhills + if (type === "Highland") return 0; // no penalty for highlanders on highlands + if (h >= 67) return 200; // general mountains crossing penalty + if (h >= 44) return 30; // general hills crossing penalty + return 0; + }; + + const getRiverCost = (riverId: number, cellId: number, type: string) => { + if (type === "River") return riverId ? 0 : 100; // penalty for river cultures + if (!riverId) return 0; // no penalty for others if there is no river + return minmax(cells.fl[cellId] / 10, 20, 100); // river penalty from 20 to 100 based on flux + }; + + const getTypeCost = (t: number, type: string) => { + if (t === 1) + return type === "Naval" || type === "Lake" + ? 0 + : type === "Nomadic" + ? 60 + : 20; // penalty for coastline + if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads + if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals + return 0; + }; + + while (queue.length) { + const { cellId, priority, cultureId } = queue.pop(); + const { type, expansionism } = cultures[cultureId]; + + cells.c[cellId].forEach((neibCellId) => { + if (hasLocked) { + const neibCultureId = cells.culture[neibCellId]; + if (neibCultureId && cultures[neibCultureId].lock) return; // do not overwrite cell of locked culture + } + + const biome = cells.biome[neibCellId]; + const biomeCost = getBiomeCost(cultureId, biome, type as string); + const biomeChangeCost = biome === cells.biome[neibCellId] ? 0 : 20; // penalty on biome change + const heightCost = getHeightCost( + neibCellId, + cells.h[neibCellId], + type as string, + ); + const riverCost = getRiverCost( + cells.r[neibCellId], + neibCellId, + type as string, + ); + const typeCost = getTypeCost(cells.t[neibCellId], type as string); + const cellCost = + (biomeCost + biomeChangeCost + heightCost + riverCost + typeCost) / + (expansionism as number); + const totalCost = priority + cellCost; + + if (totalCost > maxExpansionCost) return; + + if (!cost[neibCellId] || totalCost < cost[neibCellId]) { + if (cells.pop[neibCellId] > 0) cells.culture[neibCellId] = cultureId; // assign culture to populated cell + cost[neibCellId] = totalCost; + queue.push( + { cellId: neibCellId, cultureId, priority: totalCost }, + totalCost, + ); + } + }); + } + + TIME && console.timeEnd("expandCultures"); + } +} + +window.Cultures = new CulturesModule(); diff --git a/src/modules/index.ts b/src/modules/index.ts index 9db7aaef..03b6f5eb 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -6,3 +6,4 @@ import "./ocean-layers"; import "./lakes"; import "./river-generator"; import "./biomes"; +import "./cultures-generator"; diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 2cbec13a..fd16b773 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -1,3 +1,4 @@ +import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; import type { River } from "../modules/river-generator"; @@ -19,15 +20,18 @@ export interface PackedGraph { b: boolean[]; // cell is on border h: TypedArray; // cell heights t: TypedArray; // cell terrain types - r: Uint16Array; // river id passing through cell - f: Uint16Array; // feature id occupying cell + r: TypedArray; // river id passing through cell + f: TypedArray; // feature id occupying cell fl: TypedArray; // flux presence in cell + s: TypedArray; // cell suitability + pop: TypedArray; // cell population conf: TypedArray; // cell water confidence haven: TypedArray; // cell is a haven g: number[]; // cell ground type culture: number[]; // cell culture id biome: TypedArray; // cell biome id harbor: TypedArray; // cell harbour presence + area: TypedArray; // cell area }; vertices: { i: number[]; // vertex indices @@ -39,5 +43,5 @@ export interface PackedGraph { }; rivers: River[]; features: PackedGraphFeature[]; - cultures: any[]; + cultures: Culture[]; } diff --git a/src/types/global.ts b/src/types/global.ts index fc0b8613..46633f11 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -16,7 +16,10 @@ declare global { var nameBases: NameBase[]; var pointsInput: HTMLInputElement; + var culturesInput: HTMLInputElement; + var culturesSet: HTMLSelectElement; var heightExponentInput: HTMLInputElement; + var alertMessage: HTMLElement; var mapName: HTMLInputElement; var rivers: Selection; @@ -31,6 +34,8 @@ declare global { icons: string[][]; cost: number[]; }; + var COA: any; + var FlatQueue: any; var tip: ( message: string, @@ -39,4 +44,5 @@ declare global { ) => void; var locked: (settingId: string) => boolean; var unlock: (settingId: string) => void; + var $: (selector: any) => any; } From e938bc780246dd23e40ef7997e3be36ae834f245 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Wed, 28 Jan 2026 00:11:22 +0100 Subject: [PATCH 14/24] refactor: migrate burg module (#1288) * refactor: migrate burg module * Update src/modules/burgs-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: lint --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/modules/burgs-generator.js | 597 ------------------------ src/index.html | 1 - src/modules/burgs-generator.ts | 734 ++++++++++++++++++++++++++++++ src/modules/index.ts | 1 + src/types/PackedGraph.ts | 6 + src/types/global.ts | 14 + 6 files changed, 755 insertions(+), 598 deletions(-) delete mode 100644 public/modules/burgs-generator.js create mode 100644 src/modules/burgs-generator.ts diff --git a/public/modules/burgs-generator.js b/public/modules/burgs-generator.js deleted file mode 100644 index 20cd0fd1..00000000 --- a/public/modules/burgs-generator.js +++ /dev/null @@ -1,597 +0,0 @@ -"use strict"; - -window.Burgs = (() => { - const generate = () => { - TIME && console.time("generateBurgs"); - const {cells} = pack; - - let burgs = [0]; // burgs array - cells.burg = new Uint16Array(cells.i.length); - - const populatedCells = cells.i.filter(i => cells.s[i] > 0 && cells.culture[i]); - if (!populatedCells.length) { - ERROR && console.error("There is no populated cells with culture assigned. Cannot generate states"); - return burgs; - } - - let quadtree = d3.quadtree(); - generateCapitals(); - generateTowns(); - - pack.burgs = burgs; - shift(); - - TIME && console.timeEnd("generateBurgs"); - - function generateCapitals() { - const randomize = score => score * (0.5 + Math.random() * 0.5); - const score = new Int16Array(cells.s.map(randomize)); - const sorted = populatedCells.sort((a, b) => score[b] - score[a]); - - const capitalsNumber = getCapitalsNumber(); - let spacing = (graphWidth + graphHeight) / 2 / capitalsNumber; // min distance between capitals - - for (let i = 0; burgs.length <= capitalsNumber; i++) { - const cell = sorted[i]; - const [x, y] = cells.p[cell]; - - if (quadtree.find(x, y, spacing) === undefined) { - burgs.push({cell, x, y}); - quadtree.add([x, y]); - } - - // reset if all cells were checked - if (i === sorted.length - 1) { - WARN && console.warn("Cannot place capitals with current spacing. Trying again with reduced spacing"); - quadtree = d3.quadtree(); - i = -1; - burgs = [0]; - spacing /= 1.2; - } - } - - burgs.forEach((burg, burgId) => { - if (!burgId) return; - burg.i = burgId; - burg.state = burgId; - burg.culture = cells.culture[burg.cell]; - burg.name = Names.getCultureShort(burg.culture); - burg.feature = cells.f[burg.cell]; - burg.capital = 1; - cells.burg[burg.cell] = burgId; - }); - } - - function generateTowns() { - const randomize = score => score * gauss(1, 3, 0, 20, 3); - const score = new Int16Array(cells.s.map(randomize)); - const sorted = populatedCells.sort((a, b) => score[b] - score[a]); - - const burgsNumber = getTownsNumber(); - let spacing = (graphWidth + graphHeight) / 150 / (burgsNumber ** 0.7 / 66); // min distance between town - - for (let added = 0; added < burgsNumber && spacing > 1; ) { - for (let i = 0; added < burgsNumber && i < sorted.length; i++) { - if (cells.burg[sorted[i]]) continue; - const cell = sorted[i]; - const [x, y] = cells.p[cell]; - - const minSpacing = spacing * gauss(1, 0.3, 0.2, 2, 2); // randomize to make placement not uniform - if (quadtree.find(x, y, minSpacing) !== undefined) continue; // to close to existing burg - - const burgId = burgs.length; - const culture = cells.culture[cell]; - const name = Names.getCulture(culture); - const feature = cells.f[cell]; - burgs.push({cell, x, y, i: burgId, state: 0, culture, name, feature, capital: 0}); - added++; - cells.burg[cell] = burgId; - } - - spacing *= 0.5; - } - } - - function getCapitalsNumber() { - let number = +byId("statesNumber").value; - - if (populatedCells.length < number * 10) { - number = Math.floor(populatedCells.length / 10); - WARN && console.warn(`Not enough populated cells. Generating only ${number} capitals/states`); - } - - return number; - } - - function getTownsNumber() { - const manorsInput = byId("manorsInput"); - const isAuto = manorsInput.value === "1000"; // '1000' is considered as auto - if (isAuto) return rn(populatedCells.length / 5 / (grid.points.length / 10000) ** 0.8); - - return Math.min(manorsInput.valueAsNumber, populatedCells.length); - } - }; - - // define port status and shift ports and burgs on rivers close to the edge of the water body - function shift() { - const {cells, features, burgs} = pack; - const temp = grid.cells.temp; - - // port is a capital with any harbor OR any burg with a safe harbor - // safe harbor is a cell having just one adjacent water cell - const featurePortCandidates = {}; - for (const burg of burgs) { - if (!burg.i || burg.lock) continue; - delete burg.port; // reset port status - const cellId = burg.cell; - - const haven = cells.haven[cellId]; - const harbor = cells.harbor[cellId]; - const featureId = cells.f[haven]; - if (!featureId) continue; // no adjacent water body - - const isMulticell = features[featureId].cells > 1; - const isHarbor = (harbor && burg.capital) || harbor === 1; - const isFrozen = temp[cells.g[cellId]] <= 0; - - if (isMulticell && isHarbor && !isFrozen) { - if (!featurePortCandidates[featureId]) featurePortCandidates[featureId] = []; - featurePortCandidates[featureId].push(burg); - } - } - - // shift ports to the edge of the water body - Object.entries(featurePortCandidates).forEach(([featureId, burgs]) => { - if (burgs.length < 2) return; // only one port on water body - skip - burgs.forEach(burg => { - burg.port = featureId; - const haven = cells.haven[burg.cell]; - const [x, y] = getCloseToEdgePoint(burg.cell, haven); - burg.x = x; - burg.y = y; - }); - }); - - // shift non-port river burgs a bit - for (const burg of burgs) { - if (!burg.i || burg.lock || burg.port || !cells.r[burg.cell]) continue; - const cellId = burg.cell; - const shift = Math.min(cells.fl[cellId] / 150, 1); - burg.x = cellId % 2 ? rn(burg.x + shift, 2) : rn(burg.x - shift, 2); - burg.y = cells.r[cellId] % 2 ? rn(burg.y + shift, 2) : rn(burg.y - shift, 2); - } - - function getCloseToEdgePoint(cell1, cell2) { - const {cells, vertices} = pack; - - const [x0, y0] = cells.p[cell1]; - const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2)); - const [x1, y1] = vertices.p[commonVertices[0]]; - const [x2, y2] = vertices.p[commonVertices[1]]; - const xEdge = (x1 + x2) / 2; - const yEdge = (y1 + y2) / 2; - - const x = rn(x0 + 0.95 * (xEdge - x0), 2); - const y = rn(y0 + 0.95 * (yEdge - y0), 2); - - return [x, y]; - } - } - - const specify = () => { - TIME && console.time("specifyBurgs"); - - pack.burgs.forEach(burg => { - if (!burg.i || burg.removed || burg.lock) return; - definePopulation(burg); - defineEmblem(burg); - defineFeatures(burg); - }); - - const populations = pack.burgs - .filter(b => b.i && !b.removed) - .map(b => b.population) - .sort((a, b) => a - b); // ascending - - pack.burgs.forEach(burg => { - if (!burg.i || burg.removed) return; - defineGroup(burg, populations); - }); - - TIME && console.timeEnd("specifyBurgs"); - }; - - const getType = (cellId, port) => { - const {cells, features} = pack; - - if (port) return "Naval"; - - const haven = cells.haven[cellId]; - if (haven !== undefined && features[cells.f[haven]].type === "lake") return "Lake"; - - if (cells.h[cellId] > 60) return "Highland"; - - if (cells.r[cellId] && cells.fl[cellId] >= 100) return "River"; - - const biome = cells.biome[cellId]; - const population = cells.pop[cellId]; - if (!cells.burg[cellId] || population <= 5) { - if (population < 5 && [1, 2, 3, 4].includes(biome)) return "Nomadic"; - if (biome > 4 && biome < 10) return "Hunting"; - } - - return "Generic"; - }; - - function definePopulation(burg) { - const cellId = burg.cell; - let population = pack.cells.s[cellId] / 5; - if (burg.capital) population *= 1.5; - const connectivityRate = Routes.getConnectivityRate(cellId); - if (connectivityRate) population *= connectivityRate; - population *= gauss(1, 1, 0.25, 4, 5); // randomize - population += ((burg.i % 100) - (cellId % 100)) / 1000; // unround - burg.population = rn(Math.max(population, 0.01), 3); - } - - function defineEmblem(burg) { - burg.type = getType(burg.cell, burg.port); - - const state = pack.states[burg.state]; - const stateCOA = state.coa; - - let kinship = 0.25; - if (burg.capital) kinship += 0.1; - else if (burg.port) kinship -= 0.1; - if (burg.culture !== state.culture) kinship -= 0.25; - - const type = burg.capital && P(0.2) ? "Capital" : burg.type === "Generic" ? "City" : burg.type; - burg.coa = COA.generate(stateCOA, kinship, null, type); - burg.coa.shield = COA.getShield(burg.culture, burg.state); - } - - function defineFeatures(burg) { - const pop = burg.population; - burg.citadel = Number(burg.capital || (pop > 50 && P(0.75)) || (pop > 15 && P(0.5)) || P(0.1)); - burg.plaza = Number( - Routes.isCrossroad(burg.cell) || (Routes.hasRoad(burg.cell) && P(0.7)) || pop > 20 || (pop > 10 && P(0.8)) - ); - burg.walls = Number(burg.capital || pop > 30 || (pop > 20 && P(0.75)) || (pop > 10 && P(0.5)) || P(0.1)); - burg.shanty = Number(pop > 60 || (pop > 40 && P(0.75)) || (pop > 20 && burg.walls && P(0.4))); - const religion = pack.cells.religion[burg.cell]; - const theocracy = pack.states[burg.state].form === "Theocracy"; - burg.temple = Number( - (religion && theocracy && P(0.5)) || pop > 50 || (pop > 35 && P(0.75)) || (pop > 20 && P(0.5)) - ); - } - - const getDefaultGroups = () => [ - {name: "capital", active: true, order: 9, features: {capital: true}, preview: "watabou-city"}, - {name: "city", active: true, order: 8, percentile: 90, min: 5, preview: "watabou-city"}, - { - name: "fort", - active: true, - features: {citadel: true, walls: false, plaza: false, port: false}, - order: 6, - max: 1 - }, - { - name: "monastery", - active: true, - features: {temple: true, walls: false, plaza: false, port: false}, - order: 5, - max: 0.8 - }, - { - name: "caravanserai", - active: true, - features: {port: false, plaza: true}, - order: 4, - max: 0.8, - biomes: [1, 2, 3] - }, - { - name: "trading_post", - active: true, - order: 3, - features: {plaza: true}, - max: 0.8, - biomes: [5, 6, 7, 8, 9, 10, 11, 12] - }, - { - name: "village", - active: true, - order: 2, - min: 0.1, - max: 2, - preview: "watabou-village" - }, - { - name: "hamlet", - active: true, - order: 1, - features: {plaza: false}, - max: 0.1, - preview: "watabou-village" - }, - {name: "town", active: true, order: 7, isDefault: true, preview: "watabou-city"} - ]; - - function defineGroup(burg, populations) { - if (burg.lock && burg.group) { - // locked burgs: don't change group if it still exists - const group = options.burgs.groups.find(g => g.name === burg.group); - if (group) return; - } - - const defaultGroup = options.burgs.groups.find(g => g.isDefault); - if (!defaultGroup) { - ERROR && console.error("No default group defined"); - return; - } - burg.group = defaultGroup.name; - - for (const group of options.burgs.groups) { - if (!group.active) continue; - - if (group.min) { - const isFit = burg.population >= group.min; - if (!isFit) continue; - } - - if (group.max) { - const isFit = burg.population <= group.max; - if (!isFit) continue; - } - - if (group.features) { - const isFit = Object.entries(group.features).every(([feature, value]) => Boolean(burg[feature]) === value); - if (!isFit) continue; - } - - if (group.biomes) { - const isFit = group.biomes.includes(pack.cells.biome[burg.cell]); - if (!isFit) continue; - } - - if (group.percentile) { - const index = populations.indexOf(burg.population); - const isFit = index >= Math.floor((populations.length * group.percentile) / 100); - if (!isFit) continue; - } - - burg.group = group.name; // apply fitting group - return; - } - } - - const previewGeneratorsMap = { - "watabou-city": createWatabouCityLinks, - "watabou-village": createWatabouVillageLinks, - "watabou-dwelling": createWatabouDwellingLinks - }; - - function getPreview(burg) { - if (burg.link) return {link: burg.link, preview: burg.link}; - - const group = options.burgs.groups.find(g => g.name === burg.group); - if (!group?.preview || !previewGeneratorsMap[group.preview]) return {link: null, preview: null}; - - return previewGeneratorsMap[group.preview](burg); - } - - function createWatabouCityLinks(burg) { - const cells = pack.cells; - const {i, name, population: burgPopulation, cell} = burg; - const burgSeed = burg.MFCG || seed + String(burg.i).padStart(4, 0); - - const sizeRaw = 2.13 * Math.pow((burgPopulation * populationRate) / urbanDensity, 0.385); - const size = minmax(Math.ceil(sizeRaw), 6, 100); - const population = rn(burgPopulation * populationRate * urbanization); - - const river = cells.r[cell] ? 1 : 0; - const coast = Number(burg.port > 0); - const sea = (() => { - if (!coast || !cells.haven[cell]) return null; - - // calculate see direction: 0 = east, 0.5 = north, 1 = west, 1.5 = south - const [x1, y1] = cells.p[cell]; - const [x2, y2] = cells.p[cells.haven[cell]]; - const deg = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI; - - if (deg <= 0) return rn(normalize(Math.abs(deg), 0, 180), 2); - return rn(2 - normalize(deg, 0, 180), 2); - })(); - - const arableBiomes = river ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8]; - const farms = +arableBiomes.includes(cells.biome[cell]); - - const citadel = +burg.citadel; - const urban_castle = +(citadel && each(2)(i)); - - const hub = Routes.isCrossroad(cell); - const walls = +burg.walls; - const plaza = +burg.plaza; - const temple = +burg.temple; - const shantytown = +burg.shanty; - - const style = "natural"; - - const url = new URL("https://watabou.github.io/city-generator/"); - url.search = new URLSearchParams({ - name, - population, - size, - seed: burgSeed, - river, - coast, - farms, - citadel, - urban_castle, - hub, - plaza, - temple, - walls, - shantytown, - gates: -1, - style - }); - if (sea) url.searchParams.append("sea", sea); - - const link = url.toString(); - return {link, preview: link + "&preview=1"}; - } - - function createWatabouVillageLinks(burg) { - const {cells, features} = pack; - const {i, population, cell} = burg; - - const burgSeed = seed + String(i).padStart(4, 0); - const pop = rn(population * populationRate * urbanization); - const tags = []; - - if (cells.r[cell] && cells.haven[cell]) tags.push("estuary"); - else if (cells.haven[cell] && features[cells.f[cell]].cells === 1) tags.push("island,district"); - else if (burg.port) tags.push("coast"); - else if (cells.conf[cell]) tags.push("confluence"); - else if (cells.r[cell]) tags.push("river"); - else if (pop < 200 && each(4)(cell)) tags.push("pond"); - - const connectivityRate = Routes.getConnectivityRate(cell); - tags.push(connectivityRate > 1 ? "highway" : connectivityRate === 1 ? "dead end" : "isolated"); - - const biome = cells.biome[cell]; - const arableBiomes = cells.r[cell] ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8]; - if (!arableBiomes.includes(biome)) tags.push("uncultivated"); - else if (each(6)(cell)) tags.push("farmland"); - - const temp = grid.cells.temp[cells.g[cell]]; - if (temp <= 0 || temp > 28 || (temp > 25 && each(3)(cell))) tags.push("no orchards"); - - if (!burg.plaza) tags.push("no square"); - if (burg.walls) tags.push("palisade"); - - if (pop < 100) tags.push("sparse"); - else if (pop > 300) tags.push("dense"); - - const width = (() => { - if (pop > 1500) return 1600; - if (pop > 1000) return 1400; - if (pop > 500) return 1000; - if (pop > 200) return 800; - if (pop > 100) return 600; - return 400; - })(); - const height = rn(width / 2.05); - - const style = (() => { - if ([1, 2].includes(biome)) return "sand"; - if (temp <= 5 || [9, 10, 11].includes(biome)) return "snow"; - return "default"; - })(); - - const url = new URL("https://watabou.github.io/village-generator/"); - url.search = new URLSearchParams({pop, name: burg.name, seed: burgSeed, width, height, style, tags}); - - const link = url.toString(); - return {link, preview: link + "&preview=1"}; - } - - function createWatabouDwellingLinks(burg) { - const burgSeed = seed + String(burg.i).padStart(4, 0); - const pop = rn(burg.population * populationRate * urbanization); - - const tags = (() => { - if (pop > 200) return ["large", "tall"]; - if (pop > 100) return ["large"]; - if (pop > 50) return ["tall"]; - if (pop > 20) return ["low"]; - return ["small"]; - })(); - - const url = new URL("https://watabou.github.io/dwellings/"); - url.search = new URLSearchParams({pop, name: "", seed: burgSeed, tags}); - - const link = url.toString(); - return {link, preview: link + "&preview=1"}; - } - - function add([x, y]) { - const {cells} = pack; - - const burgId = pack.burgs.length; - const cellId = findCell(x, y); - const culture = cells.culture[cellId]; - const name = Names.getCulture(culture); - const state = cells.state[cellId]; - const feature = cells.f[cellId]; - - const burg = { - cell: cellId, - x, - y, - i: burgId, - state, - culture, - name, - feature, - capital: 0, - port: 0 - }; - definePopulation(burg); - defineEmblem(burg); - defineFeatures(burg); - - const populations = pack.burgs - .filter(b => b.i && !b.removed) - .map(b => b.population) - .sort((a, b) => a - b); // ascending - defineGroup(burg, populations); - - pack.burgs.push(burg); - cells.burg[cellId] = burgId; - - const newRoute = Routes.connect(cellId); - if (newRoute && layerIsOn("toggleRoutes")) drawRoute(newRoute); - - drawBurgIcon(burg); - drawBurgLabel(burg); - - return burgId; - } - - function changeGroup(burg, group) { - if (group) { - burg.group = group; - } else { - const validBurgs = pack.burgs.filter(b => b.i && !b.removed); - const populations = validBurgs.map(b => b.population).sort((a, b) => a - b); - defineGroup(burg, populations); - } - - drawBurgIcon(burg); - drawBurgLabel(burg); - } - - function remove(burgId) { - const burg = pack.burgs[burgId]; - if (!burg) return tip(`Burg ${burgId} not found`, false, "error"); - - pack.cells.burg[burg.cell] = 0; - burg.removed = true; - - const noteId = notes.findIndex(note => note.id === `burg${burgId}`); - if (noteId !== -1) notes.splice(noteId, 1); - - if (burg.coa) { - byId("burgCOA" + burgId)?.remove(); - emblems.select(`#burgEmblems > use[data-i='${burgId}']`).remove(); - delete burg.coa; - } - - removeBurgIcon(burg.i); - removeBurgLabel(burg.i); - } - - return {generate, getDefaultGroups, shift, specify, defineGroup, getPreview, getType, add, changeGroup, remove}; -})(); diff --git a/src/index.html b/src/index.html index e65ad0ad..cf121474 100644 --- a/src/index.html +++ b/src/index.html @@ -8494,7 +8494,6 @@ - diff --git a/src/modules/burgs-generator.ts b/src/modules/burgs-generator.ts new file mode 100644 index 00000000..2983c30d --- /dev/null +++ b/src/modules/burgs-generator.ts @@ -0,0 +1,734 @@ +import { quadtree } from "d3-quadtree"; +import { byId, each, gauss, minmax, normalize, P, rn } from "../utils"; + +declare global { + var Burgs: BurgModule; +} +export interface Burg { + cell: number; + x: number; + y: number; + i?: number; + state?: number; + culture?: number; + name?: string; + feature?: number; + capital?: number; + lock?: boolean; + port?: string; + removed?: boolean; + population?: number; + type?: string; + coa?: any; + citadel?: number; + plaza?: number; + walls?: number; + shanty?: number; + temple?: number; + group?: string; + link?: string; + MFCG?: string; +} + +class BurgModule { + shift() { + const { cells, features, burgs } = pack; + const temp = grid.cells.temp; + + // port is a capital with any harbor OR any burg with a safe harbor + // safe harbor is a cell having just one adjacent water cell + const featurePortCandidates: Record = {}; + for (const burg of burgs) { + if (!burg.i || burg.lock) continue; + delete burg.port; // reset port status + const cellId = burg.cell; + + const haven = cells.haven[cellId]; + const harbor = cells.harbor[cellId]; + const featureId = cells.f[haven]; + if (!featureId) continue; // no adjacent water body + + const isMulticell = features[featureId].cells > 1; + const isHarbor = (harbor && burg.capital) || harbor === 1; + const isFrozen = temp[cells.g[cellId]] <= 0; + + if (isMulticell && isHarbor && !isFrozen) { + if (!featurePortCandidates[featureId]) + featurePortCandidates[featureId] = []; + featurePortCandidates[featureId].push(burg); + } + } + + const getCloseToEdgePoint = (cell1: number, cell2: number) => { + const { cells, vertices } = pack; + + const [x0, y0] = cells.p[cell1]; + const commonVertices = cells.v[cell1].filter((vertex) => + vertices.c[vertex].some((cell) => cell === cell2), + ); + const [x1, y1] = vertices.p[commonVertices[0]]; + const [x2, y2] = vertices.p[commonVertices[1]]; + const xEdge = (x1 + x2) / 2; + const yEdge = (y1 + y2) / 2; + + const x = rn(x0 + 0.95 * (xEdge - x0), 2); + const y = rn(y0 + 0.95 * (yEdge - y0), 2); + + return [x, y]; + }; + + // shift ports to the edge of the water body + Object.entries(featurePortCandidates).forEach(([featureId, burgs]) => { + if (burgs.length < 2) return; // only one port on water body - skip + burgs.forEach((burg) => { + burg.port = featureId; + const haven = cells.haven[burg.cell]; + const [x, y] = getCloseToEdgePoint(burg.cell, haven); + burg.x = x; + burg.y = y; + }); + }); + + // shift non-port river burgs a bit + for (const burg of burgs) { + if (!burg.i || burg.lock || burg.port || !cells.r[burg.cell]) continue; + const cellId = burg.cell; + const shift = Math.min(cells.fl[cellId] / 150, 1); + burg.x = cellId % 2 ? rn(burg.x + shift, 2) : rn(burg.x - shift, 2); + burg.y = + cells.r[cellId] % 2 ? rn(burg.y + shift, 2) : rn(burg.y - shift, 2); + } + } + + generate() { + TIME && console.time("generateBurgs"); + const { cells } = pack; + + let burgs: Burg[] = [0 as any]; // burgs array + cells.burg = new Uint16Array(cells.i.length); + + const populatedCells = cells.i.filter( + (i) => cells.s[i] > 0 && cells.culture[i], + ); + if (!populatedCells.length) { + ERROR && + console.error( + "There is no populated cells with culture assigned. Cannot generate states", + ); + return burgs; + } + + let burgsQuadtree = quadtree(); + + const generateCapitals = () => { + const randomize = (score: number) => score * (0.5 + Math.random() * 0.5); + const score = new Int16Array(cells.s.map(randomize)); + const sorted = populatedCells.sort((a, b) => score[b] - score[a]); + + const capitalsNumber = getCapitalsNumber(); + let spacing = (graphWidth + graphHeight) / 2 / capitalsNumber; // min distance between capitals + + for (let i = 0; burgs.length <= capitalsNumber; i++) { + const cell = sorted[i]; + const [x, y] = cells.p[cell]; + + if (burgsQuadtree.find(x, y, spacing) === undefined) { + burgs.push({ cell, x, y }); + burgsQuadtree.add([x, y]); + } + + // reset if all cells were checked + if (i === sorted.length - 1) { + WARN && + console.warn( + "Cannot place capitals with current spacing. Trying again with reduced spacing", + ); + burgsQuadtree = quadtree(); + i = -1; + burgs = [0 as any]; + spacing /= 1.2; + } + } + + burgs.forEach((burg, burgId) => { + if (!burgId) return; + burg.i = burgId; + burg.state = burgId; + burg.culture = cells.culture[burg.cell]; + burg.name = Names.getCultureShort(burg.culture); + burg.feature = cells.f[burg.cell]; + burg.capital = 1; + cells.burg[burg.cell] = burgId; + }); + }; + + const generateTowns = () => { + const randomize = (score: number) => score * gauss(1, 3, 0, 20, 3); + const score = new Int16Array(cells.s.map(randomize)); + const sorted = populatedCells.sort((a, b) => score[b] - score[a]); + + const burgsNumber = getTownsNumber(); + let spacing = + (graphWidth + graphHeight) / 150 / (burgsNumber ** 0.7 / 66); // min distance between town + + for (let added = 0; added < burgsNumber && spacing > 1; ) { + for (let i = 0; added < burgsNumber && i < sorted.length; i++) { + if (cells.burg[sorted[i]]) continue; + const cell = sorted[i]; + const [x, y] = cells.p[cell]; + + const minSpacing = spacing * gauss(1, 0.3, 0.2, 2, 2); // randomize to make placement not uniform + if (burgsQuadtree.find(x, y, minSpacing) !== undefined) continue; // to close to existing burg + + const burgId = burgs.length; + const culture = cells.culture[cell]; + const name = Names.getCulture(culture); + const feature = cells.f[cell]; + burgs.push({ + cell, + x, + y, + i: burgId, + state: 0, + culture, + name, + feature, + capital: 0, + }); + added++; + cells.burg[cell] = burgId; + } + + spacing *= 0.5; + } + }; + + generateCapitals(); + generateTowns(); + + pack.burgs = burgs; + this.shift(); + + TIME && console.timeEnd("generateBurgs"); + + function getCapitalsNumber() { + let number = (byId("statesNumber") as HTMLInputElement).valueAsNumber; + + if (populatedCells.length < number * 10) { + number = Math.floor(populatedCells.length / 10); + WARN && + console.warn( + `Not enough populated cells. Generating only ${number} capitals/states`, + ); + } + + return number; + } + + function getTownsNumber() { + const manorsInput = byId("manorsInput") as HTMLInputElement; + const isAuto = manorsInput.value === "1000"; // '1000' is considered as auto + if (isAuto) + return rn( + populatedCells.length / 5 / (grid.points.length / 10000) ** 0.8, + ); + + return Math.min(manorsInput.valueAsNumber, populatedCells.length); + } + } + + getType(cellId: number, port: string) { + const { cells, features } = pack; + + if (port) return "Naval"; + + const haven = cells.haven[cellId]; + if (haven !== undefined && features[cells.f[haven]].type === "lake") + return "Lake"; + + if (cells.h[cellId] > 60) return "Highland"; + + if (cells.r[cellId] && cells.fl[cellId] >= 100) return "River"; + + const biome = cells.biome[cellId]; + const population = cells.pop[cellId]; + if (!cells.burg[cellId] || population <= 5) { + if (population < 5 && [1, 2, 3, 4].includes(biome)) return "Nomadic"; + if (biome > 4 && biome < 10) return "Hunting"; + } + + return "Generic"; + } + + private definePopulation(burg: Burg) { + const cellId = burg.cell; + let population = pack.cells.s[cellId] / 5; + if (burg.capital) population *= 1.5; + const connectivityRate = Routes.getConnectivityRate(cellId); + if (connectivityRate) population *= connectivityRate; + population *= gauss(1, 1, 0.25, 4, 5); // randomize + population += (((burg.i as number) % 100) - (cellId % 100)) / 1000; // unround + burg.population = rn(Math.max(population, 0.01), 3); + } + + private defineEmblem(burg: Burg) { + burg.type = this.getType(burg.cell, burg.port as string); + + const state = pack.states[burg.state as number]; + const stateCOA = state.coa; + + let kinship = 0.25; + if (burg.capital) kinship += 0.1; + else if (burg.port) kinship -= 0.1; + if (burg.culture !== state.culture) kinship -= 0.25; + + const type = + burg.capital && P(0.2) + ? "Capital" + : burg.type === "Generic" + ? "City" + : burg.type; + burg.coa = COA.generate(stateCOA, kinship, null, type); + burg.coa.shield = COA.getShield(burg.culture, burg.state); + } + + private defineFeatures(burg: Burg) { + const pop = burg.population as number; + burg.citadel = Number( + burg.capital || (pop > 50 && P(0.75)) || (pop > 15 && P(0.5)) || P(0.1), + ); + burg.plaza = Number( + Routes.isCrossroad(burg.cell) || + (Routes.hasRoad(burg.cell) && P(0.7)) || + pop > 20 || + (pop > 10 && P(0.8)), + ); + burg.walls = Number( + burg.capital || + pop > 30 || + (pop > 20 && P(0.75)) || + (pop > 10 && P(0.5)) || + P(0.1), + ); + burg.shanty = Number( + pop > 60 || (pop > 40 && P(0.75)) || (pop > 20 && burg.walls && P(0.4)), + ); + const religion = pack.cells.religion[burg.cell] as number; + const theocracy = pack.states[burg.state as number].form === "Theocracy"; + burg.temple = Number( + (religion && theocracy && P(0.5)) || + pop > 50 || + (pop > 35 && P(0.75)) || + (pop > 20 && P(0.5)), + ); + } + + getDefaultGroups() { + return [ + { + name: "capital", + active: true, + order: 9, + features: { capital: true }, + preview: "watabou-city", + }, + { + name: "city", + active: true, + order: 8, + percentile: 90, + min: 5, + preview: "watabou-city", + }, + { + name: "fort", + active: true, + features: { citadel: true, walls: false, plaza: false, port: false }, + order: 6, + max: 1, + }, + { + name: "monastery", + active: true, + features: { temple: true, walls: false, plaza: false, port: false }, + order: 5, + max: 0.8, + }, + { + name: "caravanserai", + active: true, + features: { port: false, plaza: true }, + order: 4, + max: 0.8, + biomes: [1, 2, 3], + }, + { + name: "trading_post", + active: true, + order: 3, + features: { plaza: true }, + max: 0.8, + biomes: [5, 6, 7, 8, 9, 10, 11, 12], + }, + { + name: "village", + active: true, + order: 2, + min: 0.1, + max: 2, + preview: "watabou-village", + }, + { + name: "hamlet", + active: true, + order: 1, + features: { plaza: false }, + max: 0.1, + preview: "watabou-village", + }, + { + name: "town", + active: true, + order: 7, + isDefault: true, + preview: "watabou-city", + }, + ]; + } + + defineGroup(burg: Burg, populations: number[]) { + if (burg.lock && burg.group) { + // locked burgs: don't change group if it still exists + const group = options.burgs.groups.find( + (g: any) => g.name === burg.group, + ); + if (group) return; + } + + const defaultGroup = options.burgs.groups.find((g: any) => g.isDefault); + if (!defaultGroup) { + ERROR && console.error("No default group defined"); + return; + } + burg.group = defaultGroup.name; + + for (const group of options.burgs.groups) { + if (!group.active) continue; + + if (group.min) { + const isFit = (burg.population as number) >= group.min; + if (!isFit) continue; + } + + if (group.max) { + const isFit = (burg.population as number) <= group.max; + if (!isFit) continue; + } + + if (group.features) { + const isFit = Object.entries( + group.features as Record, + ).every( + ([feature, value]) => Boolean(burg[feature as keyof Burg]) === value, + ); + if (!isFit) continue; + } + + if (group.biomes) { + const isFit = group.biomes.includes(pack.cells.biome[burg.cell]); + if (!isFit) continue; + } + + if (group.percentile) { + const index = populations.indexOf(burg.population as number); + const isFit = + index >= Math.floor((populations.length * group.percentile) / 100); + if (!isFit) continue; + } + + burg.group = group.name; // apply fitting group + return; + } + } + + specify() { + TIME && console.time("specifyBurgs"); + + pack.burgs.forEach((burg) => { + if (!burg.i || burg.removed || burg.lock) return; + this.definePopulation(burg); + this.defineEmblem(burg); + this.defineFeatures(burg); + }); + + const populations = pack.burgs + .filter((b) => b.i && !b.removed) + .map((b) => b.population as number) + .sort((a: number, b: number) => a - b); // ascending + + pack.burgs.forEach((burg) => { + if (!burg.i || burg.removed) return; + this.defineGroup(burg, populations); + }); + + TIME && console.timeEnd("specifyBurgs"); + } + + private createWatabouCityLinks(burg: Burg) { + const cells = pack.cells; + const { i, name, population: burgPopulation, cell } = burg; + const burgSeed = burg.MFCG || seed + String(burg.i).padStart(4, "0"); + + const sizeRaw = + 2.13 * ((burgPopulation! * populationRate) / urbanDensity) ** 0.385; + const size = minmax(Math.ceil(sizeRaw), 6, 100); + const population = rn(burgPopulation! * populationRate * urbanization); + + const river = cells.r[cell] ? 1 : 0; + const coast = Number(parseInt(burg.port as string, 10) > 0); + const sea = (() => { + if (!coast || !cells.haven[cell]) return null; + + // calculate see direction: 0 = east, 0.5 = north, 1 = west, 1.5 = south + const [x1, y1] = cells.p[cell]; + const [x2, y2] = cells.p[cells.haven[cell]]; + const deg = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI; + + if (deg <= 0) return rn(normalize(Math.abs(deg), 0, 180), 2); + return rn(2 - normalize(deg, 0, 180), 2); + })(); + + const arableBiomes = river ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8]; + const farms = +arableBiomes.includes(cells.biome[cell]); + + const citadel = +(burg.citadel as number); + const urban_castle = +(citadel && each(2)(i as number)); + + const hub = Routes.isCrossroad(cell); + const walls = +(burg.walls as number); + const plaza = +(burg.plaza as number); + const temple = +(burg.temple as number); + const shantytown = +(burg.shanty as number); + + const style = "natural"; + + const url = new URL("https://watabou.github.io/city-generator/"); + url.search = new URLSearchParams({ + name: name || "", + population: population.toString(), + size: size.toString(), + seed: burgSeed, + river: river.toString(), + coast: coast.toString(), + farms: farms.toString(), + citadel: citadel.toString(), + urban_castle: urban_castle.toString(), + hub: hub.toString(), + plaza: plaza.toString(), + temple: temple.toString(), + walls: walls.toString(), + shantytown: shantytown.toString(), + gates: (-1).toString(), + style, + }).toString(); + if (sea) url.searchParams.append("sea", sea.toString()); + + const link = url.toString(); + return { link, preview: `${link}&preview=1` }; + } + + private createWatabouVillageLinks(burg: Burg) { + const { cells, features } = pack; + const { i, population, cell } = burg; + + const burgSeed = seed + String(i).padStart(4, "0"); + const pop = rn(population! * populationRate * urbanization); + const tags = []; + + if (cells.r[cell] && cells.haven[cell]) tags.push("estuary"); + else if (cells.haven[cell] && features[cells.f[cell]].cells === 1) + tags.push("island,district"); + else if (burg.port) tags.push("coast"); + else if (cells.conf[cell]) tags.push("confluence"); + else if (cells.r[cell]) tags.push("river"); + else if (pop < 200 && each(4)(cell)) tags.push("pond"); + + const connectivityRate = Routes.getConnectivityRate(cell); + tags.push( + connectivityRate > 1 + ? "highway" + : connectivityRate === 1 + ? "dead end" + : "isolated", + ); + + const biome = cells.biome[cell]; + const arableBiomes = cells.r[cell] + ? [1, 2, 3, 4, 5, 6, 7, 8] + : [5, 6, 7, 8]; + if (!arableBiomes.includes(biome)) tags.push("uncultivated"); + else if (each(6)(cell)) tags.push("farmland"); + + const temp = grid.cells.temp[cells.g[cell]]; + if (temp <= 0 || temp > 28 || (temp > 25 && each(3)(cell))) + tags.push("no orchards"); + + if (!burg.plaza) tags.push("no square"); + if (burg.walls) tags.push("palisade"); + + if (pop < 100) tags.push("sparse"); + else if (pop > 300) tags.push("dense"); + + const width = (() => { + if (pop > 1500) return 1600; + if (pop > 1000) return 1400; + if (pop > 500) return 1000; + if (pop > 200) return 800; + if (pop > 100) return 600; + return 400; + })(); + const height = rn(width / 2.05); + + const style = (() => { + if ([1, 2].includes(biome)) return "sand"; + if (temp <= 5 || [9, 10, 11].includes(biome)) return "snow"; + return "default"; + })(); + + const url = new URL("https://watabou.github.io/village-generator/"); + url.search = new URLSearchParams({ + pop: pop.toString(), + name: burg.name || "", + seed: burgSeed, + width: width.toString(), + height: height.toString(), + style, + tags: tags.join(","), + }).toString(); + + const link = url.toString(); + return { link, preview: `${link}&preview=1` }; + } + + private createWatabouDwellingLinks(burg: Burg) { + const burgSeed = seed + String(burg.i).padStart(4, "0"); + const pop = rn(burg.population! * populationRate * urbanization); + + const tags = (() => { + if (pop > 200) return ["large", "tall"]; + if (pop > 100) return ["large"]; + if (pop > 50) return ["tall"]; + if (pop > 20) return ["low"]; + return ["small"]; + })(); + + const url = new URL("https://watabou.github.io/dwellings/"); + url.search = new URLSearchParams({ + pop: pop.toString(), + name: "", + seed: burgSeed, + tags: tags.join(","), + }).toString(); + + const link = url.toString(); + return { link, preview: `${link}&preview=1` }; + } + + getPreview(burg: Burg): { link: string | null; preview: string | null } { + const previewGeneratorsMap: Record< + string, + (burg: Burg) => { link: string | null; preview: string | null } + > = { + "watabou-city": (burg: Burg) => this.createWatabouCityLinks(burg), + "watabou-village": (burg: Burg) => this.createWatabouVillageLinks(burg), + "watabou-dwelling": (burg: Burg) => this.createWatabouDwellingLinks(burg), + }; + if (burg.link) return { link: burg.link, preview: burg.link }; + + const group = options.burgs.groups.find((g: any) => g.name === burg.group); + if (!group?.preview || !previewGeneratorsMap[group.preview]) + return { link: null, preview: null }; + + return previewGeneratorsMap[group.preview](burg); + } + + add([x, y]: [number, number]) { + const { cells } = pack; + + const burgId = pack.burgs.length; + const cellId = window.findCell(x, y, undefined, pack); + const culture = cells.culture[cellId as number]; + const name = Names.getCulture(culture); + const state = cells.state[cellId as number]; + const feature = cells.f[cellId as number]; + + const burg: Burg = { + cell: cellId as number, + x, + y, + i: burgId, + state, + culture, + name, + feature, + capital: 0, + port: "0", + }; + this.definePopulation(burg); + this.defineEmblem(burg); + this.defineFeatures(burg); + + const populations = pack.burgs + .filter((b) => b.i && !b.removed) + .map((b) => b.population as number) + .sort((a: number, b: number) => a - b); // ascending + this.defineGroup(burg, populations); + + pack.burgs.push(burg); + cells.burg[cellId as number] = burgId; + + const newRoute = Routes.connect(cellId as number); + if (newRoute && layerIsOn("toggleRoutes")) drawRoute(newRoute); + + drawBurgIcon(burg); + drawBurgLabel(burg); + + return burgId; + } + + changeGroup(burg: Burg, group: string | null) { + if (group) { + burg.group = group; + } else { + const validBurgs = pack.burgs.filter((b) => b.i && !b.removed); + const populations = validBurgs + .map((b) => b.population as number) + .sort((a, b) => a - b); + this.defineGroup(burg, populations); + } + + drawBurgIcon(burg); + drawBurgLabel(burg); + } + + remove(burgId: number) { + const burg = pack.burgs[burgId]; + if (!burg) return tip(`Burg ${burgId} not found`, false, "error"); + + pack.cells.burg[burg.cell] = 0; + burg.removed = true; + + const noteId = notes.findIndex((note) => note.id === `burg${burgId}`); + if (noteId !== -1) notes.splice(noteId, 1); + + if (burg.coa) { + byId(`burgCOA${burgId}`)?.remove(); + emblems.select(`#burgEmblems > use[data-i='${burgId}']`).remove(); + delete burg.coa; + } + + removeBurgIcon(burg.i); + removeBurgLabel(burg.i); + } +} +window.Burgs = new BurgModule(); diff --git a/src/modules/index.ts b/src/modules/index.ts index 03b6f5eb..f4a3def3 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -5,5 +5,6 @@ import "./names-generator"; import "./ocean-layers"; import "./lakes"; import "./river-generator"; +import "./burgs-generator"; import "./biomes"; import "./cultures-generator"; diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index fd16b773..4df397e4 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -1,3 +1,4 @@ +import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; import type { River } from "../modules/river-generator"; @@ -31,6 +32,9 @@ export interface PackedGraph { culture: number[]; // cell culture id biome: TypedArray; // cell biome id harbor: TypedArray; // cell harbour presence + burg: TypedArray; // cell burg id + religion: TypedArray; // cell religion id + state: number[]; // cell state id area: TypedArray; // cell area }; vertices: { @@ -43,5 +47,7 @@ export interface PackedGraph { }; rivers: River[]; features: PackedGraphFeature[]; + burgs: Burg[]; + states: any[]; cultures: Culture[]; } diff --git a/src/types/global.ts b/src/types/global.ts index 46633f11..fc4cfba9 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -11,8 +11,13 @@ declare global { var TIME: boolean; var WARN: boolean; var ERROR: boolean; + var options: any; var heightmapTemplates: any; + var Routes: any; + var populationRate: number; + var urbanDensity: number; + var urbanization: number; var nameBases: NameBase[]; var pointsInput: HTMLInputElement; @@ -24,6 +29,7 @@ declare global { var rivers: Selection; var oceanLayers: Selection; + var emblems: Selection; var biomesData: { i: number[]; name: string[]; @@ -35,6 +41,14 @@ declare global { cost: number[]; }; var COA: any; + var notes: 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 FlatQueue: any; var tip: ( From 363c82ee30fc6b63bc9df3a35077fc7f9a319f4b Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 30 Jan 2026 13:42:33 +0100 Subject: [PATCH 15/24] fix: update port type from string to number and add tests for inland burgs (#1292) --- src/modules/burgs-generator.ts | 12 ++-- tests/e2e/burgs.spec.ts | 109 +++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/burgs.spec.ts diff --git a/src/modules/burgs-generator.ts b/src/modules/burgs-generator.ts index 2983c30d..ca18a539 100644 --- a/src/modules/burgs-generator.ts +++ b/src/modules/burgs-generator.ts @@ -15,7 +15,7 @@ export interface Burg { feature?: number; capital?: number; lock?: boolean; - port?: string; + port?: number; removed?: boolean; population?: number; type?: string; @@ -81,7 +81,7 @@ class BurgModule { Object.entries(featurePortCandidates).forEach(([featureId, burgs]) => { if (burgs.length < 2) return; // only one port on water body - skip burgs.forEach((burg) => { - burg.port = featureId; + burg.port = Number(featureId); const haven = cells.haven[burg.cell]; const [x, y] = getCloseToEdgePoint(burg.cell, haven); burg.x = x; @@ -237,7 +237,7 @@ class BurgModule { } } - getType(cellId: number, port: string) { + getType(cellId: number, port?: number) { const { cells, features } = pack; if (port) return "Naval"; @@ -272,7 +272,7 @@ class BurgModule { } private defineEmblem(burg: Burg) { - burg.type = this.getType(burg.cell, burg.port as string); + burg.type = this.getType(burg.cell, burg.port); const state = pack.states[burg.state as number]; const stateCOA = state.coa; @@ -485,7 +485,7 @@ class BurgModule { const population = rn(burgPopulation! * populationRate * urbanization); const river = cells.r[cell] ? 1 : 0; - const coast = Number(parseInt(burg.port as string, 10) > 0); + const coast = Number((burg.port || 0) > 0); const sea = (() => { if (!coast || !cells.haven[cell]) return null; @@ -672,7 +672,7 @@ class BurgModule { name, feature, capital: 0, - port: "0", + port: 0, }; this.definePopulation(burg); this.defineEmblem(burg); diff --git a/tests/e2e/burgs.spec.ts b/tests/e2e/burgs.spec.ts new file mode 100644 index 00000000..f78bc38f --- /dev/null +++ b/tests/e2e/burgs.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Burgs.add", () => { + test.beforeEach(async ({ context, page }) => { + await context.clearCookies(); + + await page.goto("/"); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + // Navigate with seed parameter and wait for full load + await page.goto("/?seed=test-burgs&width=1280&height=720"); + + // Wait for map generation to complete + await page.waitForFunction( + () => (window as any).mapId !== undefined, + { timeout: 60000 } + ); + + // Additional wait for any rendering/animations to settle + await page.waitForTimeout(500); + }); + + test("should create burg with falsy port value when not on coast", async ({ + page, + }) => { + const result = await page.evaluate(() => { + const { cells, burgs } = (window as any).pack; + + // Find a land cell that is not on the coast (no harbor) + let inlandCellId: number | null = null; + for (let i = 1; i < cells.i.length; i++) { + const isLand = cells.h[i] >= 20; + const hasNoHarbor = !cells.harbor[i]; + const hasNoBurg = !cells.burg[i]; + if (isLand && hasNoHarbor && hasNoBurg) { + inlandCellId = i; + break; + } + } + + if (!inlandCellId) { + return { error: "No inland cell found" }; + } + + // Get coordinates for the inland cell + const [x, y] = cells.p[inlandCellId]; + + // Add a new burg at this inland location + const Burgs = (window as any).Burgs; + const burgId = Burgs.add([x, y]); + const burg = burgs[burgId]; + + return { + burgId, + port: burg.port, + portType: typeof burg.port, + portIsFalsy: !burg.port, + x: burg.x, + y: burg.y, + }; + }); + + expect(result.error).toBeUndefined(); + // Port should be 0 (number), not "0" (string) + expect(result.port).toBe(0); + expect(result.portType).toBe("number"); + expect(result.portIsFalsy).toBe(true); + // Explicitly verify it's not the buggy string "0" + expect(result.port).not.toBe("0"); + }); + + test("port toggle button should be inactive for non-coastal burg", async ({ + page, + }) => { + // Add a burg on an inland cell + const burgId = await page.evaluate(() => { + const { cells } = (window as any).pack; + + // Find a land cell that is not on the coast + for (let i = 1; i < cells.i.length; i++) { + const isLand = cells.h[i] >= 20; + const hasNoHarbor = !cells.harbor[i]; + const hasNoBurg = !cells.burg[i]; + if (isLand && hasNoHarbor && hasNoBurg) { + const [x, y] = cells.p[i]; + return (window as any).Burgs.add([x, y]); + } + } + return null; + }); + + expect(burgId).not.toBeNull(); + + // Open the burg editor + await page.evaluate((id: number) => { + (window as any).editBurg(id); + }, burgId!); + + // Wait for the editor dialog to appear + await page.waitForSelector("#burgEditor", { state: "visible" }); + + // The port toggle button should have the "inactive" class + const portButton = page.locator("#burgPort"); + await expect(portButton).toHaveClass(/inactive/); + }); +}); From 88c70b92640c92bb25b5e0b755bbcb2a0a32758f Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 30 Jan 2026 16:44:09 +0100 Subject: [PATCH 16/24] refactor: migrate states generator (#1291) * refactor: migrate states generator * Update src/modules/states-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/states-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/modules/states-generator.js | 640 ---------------------- src/index.html | 1 - src/modules/index.ts | 1 + src/modules/names-generator.ts | 2 +- src/modules/states-generator.ts | 824 +++++++++++++++++++++++++++++ src/types/PackedGraph.ts | 4 +- 6 files changed, 829 insertions(+), 643 deletions(-) delete mode 100644 public/modules/states-generator.js create mode 100644 src/modules/states-generator.ts diff --git a/public/modules/states-generator.js b/public/modules/states-generator.js deleted file mode 100644 index 9662e648..00000000 --- a/public/modules/states-generator.js +++ /dev/null @@ -1,640 +0,0 @@ -"use strict"; - -window.States = (() => { - const generate = () => { - TIME && console.time("generateStates"); - pack.states = createStates(); - expandStates(); - normalize(); - getPoles(); - findNeighbors(); - assignColors(); - generateCampaigns(); - generateDiplomacy(); - - TIME && console.timeEnd("generateStates"); - - // for each capital create a state - function createStates() { - const states = [{i: 0, name: "Neutrals"}]; - const each5th = each(5); - const sizeVariety = byId("sizeVariety").valueAsNumber; - - pack.burgs.forEach(burg => { - if (!burg.i || !burg.capital) return; - - const expansionism = rn(Math.random() * sizeVariety + 1, 1); - const basename = burg.name.length < 9 && each5th(burg.cell) ? burg.name : Names.getCultureShort(burg.culture); - const name = Names.getState(basename, burg.culture); - const type = pack.cultures[burg.culture].type; - const coa = COA.generate(null, null, null, type); - coa.shield = COA.getShield(burg.culture, null); - states.push({ - i: burg.i, - name, - expansionism, - capital: burg.i, - type, - center: burg.cell, - culture: burg.culture, - coa - }); - }); - - return states; - } - }; - - // expand cultures across the map (Dijkstra-like algorithm) - const expandStates = () => { - TIME && console.time("expandStates"); - const {cells, states, cultures, burgs} = pack; - - cells.state = cells.state || new Uint16Array(cells.i.length); - - const queue = new FlatQueue(); - const cost = []; - - const globalGrowthRate = byId("growthRate").valueAsNumber || 1; - const statesGrowthRate = byId("statesGrowthRate")?.valueAsNumber || 1; - const growthRate = (cells.i.length / 2) * globalGrowthRate * statesGrowthRate; // limit cost for state growth - - // remove state from all cells except of locked - for (const cellId of cells.i) { - const state = states[cells.state[cellId]]; - if (state.lock) continue; - cells.state[cellId] = 0; - } - - for (const state of states) { - if (!state.i || state.removed) continue; - - const capitalCell = burgs[state.capital].cell; - cells.state[capitalCell] = state.i; - const cultureCenter = cultures[state.culture].center; - const b = cells.biome[cultureCenter]; // state native biome - queue.push({e: state.center, p: 0, s: state.i, b}, 0); - cost[state.center] = 1; - } - - while (queue.length) { - const next = queue.pop(); - - const {e, p, s, b} = next; - const {type, culture} = states[s]; - - cells.c[e].forEach(e => { - const state = states[cells.state[e]]; - if (state.lock) return; // do not overwrite cell of locked states - if (cells.state[e] && e === state.center) return; // do not overwrite capital cells - - const cultureCost = culture === cells.culture[e] ? -9 : 100; - const populationCost = cells.h[e] < 20 ? 0 : cells.s[e] ? Math.max(20 - cells.s[e], 0) : 5000; - const biomeCost = getBiomeCost(b, cells.biome[e], type); - const heightCost = getHeightCost(pack.features[cells.f[e]], cells.h[e], type); - const riverCost = getRiverCost(cells.r[e], e, type); - const typeCost = getTypeCost(cells.t[e], type); - const cellCost = Math.max(cultureCost + populationCost + biomeCost + heightCost + riverCost + typeCost, 0); - const totalCost = p + 10 + cellCost / states[s].expansionism; - - if (totalCost > growthRate) return; - - if (!cost[e] || totalCost < cost[e]) { - if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell - cost[e] = totalCost; - queue.push({e, p: totalCost, s, b}, totalCost); - } - }); - } - - burgs.filter(b => b.i && !b.removed).forEach(b => (b.state = cells.state[b.cell])); // assign state to burgs - - function getBiomeCost(b, biome, type) { - if (b === biome) return 10; // tiny penalty for native biome - if (type === "Hunting") return biomesData.cost[biome] * 2; // non-native biome penalty for hunters - if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 3; // forest biome penalty for nomads - return biomesData.cost[biome]; // general non-native biome penalty - } - - function getHeightCost(f, h, type) { - if (type === "Lake" && f.type === "lake") return 10; // low lake crossing penalty for Lake cultures - if (type === "Naval" && h < 20) return 300; // low sea crossing penalty for Navals - if (type === "Nomadic" && h < 20) return 10000; // giant sea crossing penalty for Nomads - if (h < 20) return 1000; // general sea crossing penalty - if (type === "Highland" && h < 62) return 1100; // penalty for highlanders on lowlands - if (type === "Highland") return 0; // no penalty for highlanders on highlands - if (h >= 67) return 2200; // general mountains crossing penalty - if (h >= 44) return 300; // general hills crossing penalty - return 0; - } - - function getRiverCost(r, i, type) { - if (type === "River") return r ? 0 : 100; // penalty for river cultures - if (!r) return 0; // no penalty for others if there is no river - return minmax(cells.fl[i] / 10, 20, 100); // river penalty from 20 to 100 based on flux - } - - function getTypeCost(t, type) { - if (t === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline - if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads - if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals - return 0; - } - - TIME && console.timeEnd("expandStates"); - }; - - const normalize = () => { - TIME && console.time("normalizeStates"); - const {cells, burgs} = pack; - - for (const i of cells.i) { - if (cells.h[i] < 20 || cells.burg[i]) continue; // do not overwrite burgs - if (pack.states[cells.state[i]]?.lock) continue; // do not overwrite cells of locks states - if (cells.c[i].some(c => burgs[cells.burg[c]].capital)) continue; // do not overwrite near capital - const neibs = cells.c[i].filter(c => cells.h[c] >= 20); - const adversaries = neibs.filter(c => !pack.states[cells.state[c]]?.lock && cells.state[c] !== cells.state[i]); - if (adversaries.length < 2) continue; - const buddies = neibs.filter(c => !pack.states[cells.state[c]]?.lock && cells.state[c] === cells.state[i]); - if (buddies.length > 2) continue; - if (adversaries.length <= buddies.length) continue; - cells.state[i] = cells.state[adversaries[0]]; - } - TIME && console.timeEnd("normalizeStates"); - }; - - // calculate pole of inaccessibility for each state - const getPoles = () => { - const getType = cellId => pack.cells.state[cellId]; - const poles = getPolesOfInaccessibility(pack, getType); - - pack.states.forEach(s => { - if (!s.i || s.removed) return; - s.pole = poles[s.i] || [0, 0]; - }); - }; - - const findNeighbors = () => { - const {cells, states} = pack; - - states.forEach(s => { - if (s.removed) return; - s.neighbors = new Set(); - }); - - for (const i of cells.i) { - if (cells.h[i] < 20) continue; - const s = cells.state[i]; - - cells.c[i] - .filter(c => cells.h[c] >= 20 && cells.state[c] !== s) - .forEach(c => states[s].neighbors.add(cells.state[c])); - } - - // convert neighbors Set object into array - states.forEach(s => { - if (!s.neighbors || s.removed) return; - s.neighbors = Array.from(s.neighbors); - }); - }; - - const assignColors = () => { - TIME && console.time("assignColors"); - const colors = ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f"]; // d3.schemeSet2; - const states = pack.states; - - // assign basic color using greedy coloring algorithm - states.forEach(state => { - if (!state.i || state.removed || state.lock) return; - state.color = colors.find(color => state.neighbors.every(neibStateId => states[neibStateId].color !== color)); - if (!state.color) state.color = getRandomColor(); - colors.push(colors.shift()); - }); - - // randomize each already used color a bit - colors.forEach(c => { - const sameColored = states.filter(state => state.color === c && state.i && !state.lock); - sameColored.forEach((state, index) => { - if (!index) return; - state.color = getMixedColor(state.color); - }); - }); - - TIME && console.timeEnd("assignColors"); - }; - - // calculate states data like area, population etc. - const collectStatistics = () => { - TIME && console.time("collectStatistics"); - const {cells, states} = pack; - - states.forEach(s => { - if (s.removed) return; - s.cells = s.area = s.burgs = s.rural = s.urban = 0; - }); - - for (const i of cells.i) { - if (cells.h[i] < 20) continue; - const s = cells.state[i]; - - // collect stats - states[s].cells += 1; - states[s].area += cells.area[i]; - states[s].rural += cells.pop[i]; - if (cells.burg[i]) { - states[s].urban += pack.burgs[cells.burg[i]].population; - states[s].burgs++; - } - } - - TIME && console.timeEnd("collectStatistics"); - }; - - const wars = { - War: 6, - Conflict: 2, - Campaign: 4, - Invasion: 2, - Rebellion: 2, - Conquest: 2, - Intervention: 1, - Expedition: 1, - Crusade: 1 - }; - - const generateCampaign = state => { - const neighbors = state.neighbors.length ? state.neighbors : [0]; - return neighbors - .map(i => { - const name = i && P(0.8) ? pack.states[i].name : Names.getCultureShort(state.culture); - const start = gauss(options.year - 100, 150, 1, options.year - 6); - const end = start + gauss(4, 5, 1, options.year - start - 1); - return {name: getAdjective(name) + " " + rw(wars), start, end}; - }) - .sort((a, b) => a.start - b.start); - }; - - // generate historical conflicts of each state - const generateCampaigns = () => { - pack.states.forEach(s => { - if (!s.i || s.removed) return; - s.campaigns = generateCampaign(s); - }); - }; - - // generate Diplomatic Relationships - const generateDiplomacy = () => { - TIME && console.time("generateDiplomacy"); - const {cells, states} = pack; - const chronicle = (states[0].diplomacy = []); - const valid = states.filter(s => s.i && !states.removed); - - const neibs = {Ally: 1, Friendly: 2, Neutral: 1, Suspicion: 10, Rival: 9}; // relations to neighbors - const neibsOfNeibs = {Ally: 10, Friendly: 8, Neutral: 5, Suspicion: 1}; // relations to neighbors of neighbors - const far = {Friendly: 1, Neutral: 12, Suspicion: 2, Unknown: 6}; // relations to other - const navals = {Neutral: 1, Suspicion: 2, Rival: 1, Unknown: 1}; // relations of naval powers - - valid.forEach(s => (s.diplomacy = new Array(states.length).fill("x"))); // clear all relationships - if (valid.length < 2) return; // no states to renerate relations with - const areaMean = d3.mean(valid.map(s => s.area)); // average state area - - // generic relations - for (let f = 1; f < states.length; f++) { - if (states[f].removed) continue; - - if (states[f].diplomacy.includes("Vassal")) { - // Vassals copy relations from their Suzerains - const suzerain = states[f].diplomacy.indexOf("Vassal"); - - for (let i = 1; i < states.length; i++) { - if (i === f || i === suzerain) continue; - states[f].diplomacy[i] = states[suzerain].diplomacy[i]; - if (states[suzerain].diplomacy[i] === "Suzerain") states[f].diplomacy[i] = "Ally"; - for (let e = 1; e < states.length; e++) { - if (e === f || e === suzerain) continue; - if (states[e].diplomacy[suzerain] === "Suzerain" || states[e].diplomacy[suzerain] === "Vassal") continue; - states[e].diplomacy[f] = states[e].diplomacy[suzerain]; - } - } - continue; - } - - for (let t = f + 1; t < states.length; t++) { - if (states[t].removed) continue; - - if (states[t].diplomacy.includes("Vassal")) { - const suzerain = states[t].diplomacy.indexOf("Vassal"); - states[f].diplomacy[t] = states[f].diplomacy[suzerain]; - continue; - } - - const naval = - states[f].type === "Naval" && - states[t].type === "Naval" && - cells.f[states[f].center] !== cells.f[states[t].center]; - const neib = naval ? false : states[f].neighbors.includes(t); - const neibOfNeib = - naval || neib - ? false - : states[f].neighbors - .map(n => states[n].neighbors) - .join("") - .includes(t); - - let status = naval ? rw(navals) : neib ? rw(neibs) : neibOfNeib ? rw(neibsOfNeibs) : rw(far); - - // add Vassal - if ( - neib && - P(0.8) && - states[f].area > areaMean && - states[t].area < areaMean && - states[f].area / states[t].area > 2 - ) - status = "Vassal"; - states[f].diplomacy[t] = status === "Vassal" ? "Suzerain" : status; - states[t].diplomacy[f] = status; - } - } - - // declare wars - for (let attacker = 1; attacker < states.length; attacker++) { - const ad = states[attacker].diplomacy; // attacker relations; - if (states[attacker].removed) continue; - if (!ad.includes("Rival")) continue; // no rivals to attack - if (ad.includes("Vassal")) continue; // not independent - if (ad.includes("Enemy")) continue; // already at war - - // random independent rival - const defender = ra( - ad.map((r, d) => (r === "Rival" && !states[d].diplomacy.includes("Vassal") ? d : 0)).filter(d => d) - ); - let ap = states[attacker].area * states[attacker].expansionism; - let dp = states[defender].area * states[defender].expansionism; - if (ap < dp * gauss(1.6, 0.8, 0, 10, 2)) continue; // defender is too strong - - const an = states[attacker].name; - const dn = states[defender].name; // names - const attackers = [attacker]; - const defenders = [defender]; // attackers and defenders array - const dd = states[defender].diplomacy; // defender relations; - - // start an ongoing war - const name = `${an}-${trimVowels(dn)}ian War`; - const start = options.year - gauss(2, 3, 0, 10); - const war = [name, `${an} declared a war on its rival ${dn}`]; - const campaign = {name, start, attacker, defender}; - states[attacker].campaigns.push(campaign); - states[defender].campaigns.push(campaign); - - // attacker vassals join the war - ad.forEach((r, d) => { - if (r === "Suzerain") { - attackers.push(d); - war.push(`${an}'s vassal ${states[d].name} joined the war on attackers side`); - } - }); - - // defender vassals join the war - dd.forEach((r, d) => { - if (r === "Suzerain") { - defenders.push(d); - war.push(`${dn}'s vassal ${states[d].name} joined the war on defenders side`); - } - }); - - ap = d3.sum(attackers.map(a => states[a].area * states[a].expansionism)); // attackers joined power - dp = d3.sum(defenders.map(d => states[d].area * states[d].expansionism)); // defender joined power - - // defender allies join - dd.forEach((r, d) => { - if (r !== "Ally" || states[d].diplomacy.includes("Vassal")) return; - if (states[d].diplomacy[attacker] !== "Rival" && ap / dp > 2 * gauss(1.6, 0.8, 0, 10, 2)) { - const reason = states[d].diplomacy.includes("Enemy") ? "Being already at war," : `Frightened by ${an},`; - war.push(`${reason} ${states[d].name} severed the defense pact with ${dn}`); - dd[d] = states[d].diplomacy[defender] = "Suspicion"; - return; - } - defenders.push(d); - dp += states[d].area * states[d].expansionism; - war.push(`${dn}'s ally ${states[d].name} joined the war on defenders side`); - - // ally vassals join - states[d].diplomacy - .map((r, d) => (r === "Suzerain" ? d : 0)) - .filter(d => d) - .forEach(v => { - defenders.push(v); - dp += states[v].area * states[v].expansionism; - war.push(`${states[d].name}'s vassal ${states[v].name} joined the war on defenders side`); - }); - }); - - // attacker allies join if the defender is their rival or joined power > defenders power and defender is not an ally - ad.forEach((r, d) => { - if (r !== "Ally" || states[d].diplomacy.includes("Vassal") || defenders.includes(d)) return; - const name = states[d].name; - if (states[d].diplomacy[defender] !== "Rival" && (P(0.2) || ap <= dp * 1.2)) { - war.push(`${an}'s ally ${name} avoided entering the war`); - return; - } - const allies = states[d].diplomacy.map((r, d) => (r === "Ally" ? d : 0)).filter(d => d); - if (allies.some(ally => defenders.includes(ally))) { - war.push(`${an}'s ally ${name} did not join the war as its allies are in war on both sides`); - return; - } - - attackers.push(d); - ap += states[d].area * states[d].expansionism; - war.push(`${an}'s ally ${name} joined the war on attackers side`); - - // ally vassals join - states[d].diplomacy - .map((r, d) => (r === "Suzerain" ? d : 0)) - .filter(d => d) - .forEach(v => { - attackers.push(v); - dp += states[v].area * states[v].expansionism; - war.push(`${states[d].name}'s vassal ${states[v].name} joined the war on attackers side`); - }); - }); - - // change relations to Enemy for all participants - attackers.forEach(a => defenders.forEach(d => (states[a].diplomacy[d] = states[d].diplomacy[a] = "Enemy"))); - chronicle.push(war); // add a record to diplomatical history - } - - TIME && console.timeEnd("generateDiplomacy"); - }; - - // select a forms for listed or all valid states - const defineStateForms = list => { - TIME && console.time("defineStateForms"); - const states = pack.states.filter(s => s.i && !s.removed && !s.lock); - if (states.length < 1) return; - - const generic = {Monarchy: 25, Republic: 2, Union: 1}; - const naval = {Monarchy: 25, Republic: 8, Union: 3}; - - const median = d3.median(pack.states.map(s => s.area)); - const empireMin = states.map(s => s.area).sort((a, b) => b - a)[Math.max(Math.ceil(states.length ** 0.4) - 2, 0)]; - const expTiers = pack.states.map(s => { - let tier = Math.min(Math.floor((s.area / median) * 2.6), 4); - if (tier === 4 && s.area < empireMin) tier = 3; - return tier; - }); - - const monarchy = ["Duchy", "Grand Duchy", "Principality", "Kingdom", "Empire"]; // per expansionism tier - const republic = { - Republic: 75, - Federation: 4, - "Trade Company": 4, - "Most Serene Republic": 2, - Oligarchy: 2, - Tetrarchy: 1, - Triumvirate: 1, - Diarchy: 1, - Junta: 1 - }; // weighted random - const union = { - Union: 3, - League: 4, - Confederation: 1, - "United Kingdom": 1, - "United Republic": 1, - "United Provinces": 2, - Commonwealth: 1, - Heptarchy: 1 - }; // weighted random - const theocracy = {Theocracy: 20, Brotherhood: 1, Thearchy: 2, See: 1, "Holy State": 1}; - const anarchy = {"Free Territory": 2, Council: 3, Commune: 1, Community: 1}; - - for (const s of states) { - if (list && !list.includes(s.i)) continue; - const tier = expTiers[s.i]; - - const religion = pack.cells.religion[s.center]; - const isTheocracy = - (religion && pack.religions[religion].expansion === "state") || - (P(0.1) && ["Organized", "Cult"].includes(pack.religions[religion].type)); - const isAnarchy = P(0.01 - tier / 500); - - if (isTheocracy) s.form = "Theocracy"; - else if (isAnarchy) s.form = "Anarchy"; - else s.form = s.type === "Naval" ? rw(naval) : rw(generic); - s.formName = selectForm(s, tier); - s.fullName = getFullName(s); - } - - function selectForm(s, tier) { - const base = pack.cultures[s.culture].base; - - if (s.form === "Monarchy") { - const form = monarchy[tier]; - // Default name depends on exponent tier, some culture bases have special names for tiers - if (s.diplomacy) { - if ( - form === "Duchy" && - s.neighbors.length > 1 && - rand(6) < s.neighbors.length && - s.diplomacy.includes("Vassal") - ) - return "Marches"; // some vassal duchies on borderland - if (base === 1 && P(0.3) && s.diplomacy.includes("Vassal")) return "Dominion"; // English vassals - if (P(0.3) && s.diplomacy.includes("Vassal")) return "Protectorate"; // some vassals - } - - if (base === 31 && (form === "Empire" || form === "Kingdom")) return "Khanate"; // Mongolian - if (base === 16 && form === "Principality") return "Beylik"; // Turkic - if (base === 5 && (form === "Empire" || form === "Kingdom")) return "Tsardom"; // Ruthenian - if (base === 16 && (form === "Empire" || form === "Kingdom")) return "Khaganate"; // Turkic - if (base === 12 && (form === "Kingdom" || form === "Grand Duchy")) return "Shogunate"; // Japanese - if ([18, 17].includes(base) && form === "Empire") return "Caliphate"; // Arabic, Berber - if (base === 18 && (form === "Grand Duchy" || form === "Duchy")) return "Emirate"; // Arabic - if (base === 7 && (form === "Grand Duchy" || form === "Duchy")) return "Despotate"; // Greek - if (base === 31 && (form === "Grand Duchy" || form === "Duchy")) return "Ulus"; // Mongolian - if (base === 16 && (form === "Grand Duchy" || form === "Duchy")) return "Horde"; // Turkic - if (base === 24 && (form === "Grand Duchy" || form === "Duchy")) return "Satrapy"; // Iranian - return form; - } - - if (s.form === "Republic") { - // Default name is from weighted array, special case for small states with only 1 burg - if (tier < 2 && s.burgs === 1) { - if (trimVowels(s.name) === trimVowels(pack.burgs[s.capital].name)) { - s.name = pack.burgs[s.capital].name; - return "Free City"; - } - if (P(0.3)) return "City-state"; - } - return rw(republic); - } - - if (s.form === "Union") return rw(union); - if (s.form === "Anarchy") return rw(anarchy); - - if (s.form === "Theocracy") { - // European - if ([0, 1, 2, 3, 4, 6, 8, 9, 13, 15, 20].includes(base)) { - if (P(0.1)) return "Divine " + monarchy[tier]; - if (tier < 2 && P(0.5)) return "Diocese"; - if (tier < 2 && P(0.5)) return "Bishopric"; - } - if (P(0.9) && [7, 5].includes(base)) { - // Greek, Ruthenian - if (tier < 2) return "Eparchy"; - if (tier === 2) return "Exarchate"; - if (tier > 2) return "Patriarchate"; - } - if (P(0.9) && [21, 16].includes(base)) return "Imamah"; // Nigerian, Turkish - if (tier > 2 && P(0.8) && [18, 17, 28].includes(base)) return "Caliphate"; // Arabic, Berber, Swahili - return rw(theocracy); - } - } - - TIME && console.timeEnd("defineStateForms"); - }; - - // state forms requiring Adjective + Name, all other forms use scheme Form + Of + Name - const adjForms = [ - "Empire", - "Sultanate", - "Khaganate", - "Shogunate", - "Caliphate", - "Despotate", - "Theocracy", - "Oligarchy", - "Union", - "Confederation", - "Trade Company", - "League", - "Tetrarchy", - "Triumvirate", - "Diarchy", - "Horde", - "Marches" - ]; - - const getFullName = state => { - if (!state.formName) return state.name; - if (!state.name && state.formName) return "The " + state.formName; - const adjName = adjForms.includes(state.formName) && !/-| /.test(state.name); - return adjName ? `${getAdjective(state.name)} ${state.formName}` : `${state.formName} of ${state.name}`; - }; - - return { - generate, - expandStates, - normalize, - getPoles, - findNeighbors, - assignColors, - collectStatistics, - generateCampaign, - generateCampaigns, - generateDiplomacy, - defineStateForms, - getFullName - }; -})(); diff --git a/src/index.html b/src/index.html index cf121474..14b61949 100644 --- a/src/index.html +++ b/src/index.html @@ -8494,7 +8494,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index f4a3def3..660fc100 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -8,3 +8,4 @@ import "./river-generator"; import "./burgs-generator"; import "./biomes"; import "./cultures-generator"; +import "./states-generator"; diff --git a/src/modules/names-generator.ts b/src/modules/names-generator.ts index da60beca..5805cc92 100644 --- a/src/modules/names-generator.ts +++ b/src/modules/names-generator.ts @@ -223,7 +223,7 @@ class NamesGenerator { } // generate state name based on capital or random name and culture-specific suffix - getState(name: string, culture: number, base: number): string { + getState(name: string, culture: number, base?: number): string { if (name === undefined) { ERROR && console.error("Please define a base name"); return "ERROR"; diff --git a/src/modules/states-generator.ts b/src/modules/states-generator.ts new file mode 100644 index 00000000..cb13dd76 --- /dev/null +++ b/src/modules/states-generator.ts @@ -0,0 +1,824 @@ +import { mean, median, sum } from "d3"; +import { + byId, + each, + gauss, + getAdjective, + getMixedColor, + getPolesOfInaccessibility, + getRandomColor, + minmax, + P, + ra, + rand, + rn, + rw, + trimVowels, +} from "../utils"; + +declare global { + var States: StatesModule; +} + +interface Campaign { + name: string; + start: number; + end?: number; +} + +export interface State { + i: number; + name: string; + expansionism: number; + capital: number; + type: string; + center: number; + culture: number; + coa: any; + lock?: boolean; + removed?: boolean; + pole?: [number, number]; + neighbors?: number[]; + color?: string; + cells?: number; + area?: number; + burgs?: number; + rural?: number; + urban?: number; + campaigns?: Campaign[]; + diplomacy?: string[]; + formName?: string; + fullName?: string; + form?: string; +} + +class StatesModule { + private createStates() { + const states: State[] = [{ i: 0, name: "Neutrals" } as State]; + const each5th = each(5); + const sizeVariety = (byId("sizeVariety") as HTMLInputElement).valueAsNumber; + + pack.burgs.forEach((burg) => { + if (!burg.i || !burg.capital) return; + + const expansionism = rn(Math.random() * sizeVariety + 1, 1); + const basename = + burg.name!.length < 9 && each5th(burg.cell) + ? burg.name! + : Names.getCultureShort(burg.culture!); + const name = Names.getState(basename, burg.culture!); + const type = pack.cultures[burg.culture!].type; + const coa = COA.generate(null, null, null, type); + coa.shield = COA.getShield(burg.culture, null); + states.push({ + i: burg.i, + name, + expansionism, + capital: burg.i, + type: type!, + center: burg.cell, + culture: burg.culture!, + coa, + }); + }); + + return states; + } + + private getBiomeCost(b: number, biome: number, type: string) { + if (b === biome) return 10; // tiny penalty for native biome + if (type === "Hunting") return biomesData.cost[biome] * 2; // non-native biome penalty for hunters + if (type === "Nomadic" && biome > 4 && biome < 10) + return biomesData.cost[biome] * 3; // forest biome penalty for nomads + return biomesData.cost[biome]; // general non-native biome penalty + } + + private getHeightCost(f: any, h: number, type: string) { + if (type === "Lake" && f.type === "lake") return 10; // low lake crossing penalty for Lake cultures + if (type === "Naval" && h < 20) return 300; // low sea crossing penalty for Navals + if (type === "Nomadic" && h < 20) return 10000; // giant sea crossing penalty for Nomads + if (h < 20) return 1000; // general sea crossing penalty + if (type === "Highland" && h < 62) return 1100; // penalty for highlanders on lowlands + if (type === "Highland") return 0; // no penalty for highlanders on highlands + if (h >= 67) return 2200; // general mountains crossing penalty + if (h >= 44) return 300; // general hills crossing penalty + return 0; + } + + private getRiverCost(r: any, i: number, type: string) { + if (type === "River") return r ? 0 : 100; // penalty for river cultures + if (!r) return 0; // no penalty for others if there is no river + return minmax(pack.cells.fl[i] / 10, 20, 100); // river penalty from 20 to 100 based on flux + } + + private getTypeCost(t: number, type: string) { + if (t === 1) + return type === "Naval" || type === "Lake" + ? 0 + : type === "Nomadic" + ? 60 + : 20; // penalty for coastline + if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads + if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals + return 0; + } + + generate() { + TIME && console.time("generateStates"); + pack.states = this.createStates(); + this.expandStates(); + this.normalize(); + this.getPoles(); + this.findNeighbors(); + this.assignColors(); + this.generateCampaigns(); + this.generateDiplomacy(); + + TIME && console.timeEnd("generateStates"); + } + + expandStates() { + TIME && console.time("expandStates"); + const { cells, states, cultures, burgs } = pack; + + cells.state = cells.state || new Uint16Array(cells.i.length); + + const queue = new FlatQueue(); + const cost: number[] = []; + + const globalGrowthRate = + (byId("growthRate") as HTMLInputElement)?.valueAsNumber || 1; + const statesGrowthRate = + (byId("statesGrowthRate") as HTMLInputElement)?.valueAsNumber || 1; + const growthRate = + (cells.i.length / 2) * globalGrowthRate * statesGrowthRate; // limit cost for state growth + + // remove state from all cells except of locked + for (const cellId of cells.i) { + const state = states[cells.state[cellId]]; + if (state.lock) continue; + cells.state[cellId] = 0; + } + + for (const state of states) { + if (!state.i || state.removed) continue; + + const capitalCell = burgs[state.capital].cell; + cells.state[capitalCell] = state.i; + const cultureCenter = cultures[state.culture].center!; + const b = cells.biome[cultureCenter]; // state native biome + queue.push({ e: state.center, p: 0, s: state.i, b }, 0); + cost[state.center] = 1; + } + + while (queue.length) { + const next = queue.pop(); + + const { e, p, s, b } = next; + const { type, culture } = states[s]; + + cells.c[e].forEach((e) => { + const state = states[cells.state[e]]; + if (state.lock) return; // do not overwrite cell of locked states + if (cells.state[e] && e === state.center) return; // do not overwrite capital cells + + const cultureCost = culture === cells.culture[e] ? -9 : 100; + const populationCost = + cells.h[e] < 20 + ? 0 + : cells.s[e] + ? Math.max(20 - cells.s[e], 0) + : 5000; + const biomeCost = this.getBiomeCost(b, cells.biome[e], type); + const heightCost = this.getHeightCost( + pack.features[cells.f[e]], + cells.h[e], + type, + ); + const riverCost = this.getRiverCost(cells.r[e], e, type); + const typeCost = this.getTypeCost(cells.t[e], type); + const cellCost = Math.max( + cultureCost + + populationCost + + biomeCost + + heightCost + + riverCost + + typeCost, + 0, + ); + const totalCost = p + 10 + cellCost / states[s].expansionism; + + if (totalCost > growthRate) return; + + if (!cost[e] || totalCost < cost[e]) { + if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell + cost[e] = totalCost; + queue.push({ e, p: totalCost, s, b }, totalCost); + } + }); + } + + burgs + .filter((b) => b.i && !b.removed) + .forEach((b) => { + b.state = cells.state[b.cell]; // assign state to burgs + }); + TIME && console.timeEnd("expandStates"); + } + + normalize() { + TIME && console.time("normalizeStates"); + const { cells, burgs } = pack; + + for (const i of cells.i) { + if (cells.h[i] < 20 || cells.burg[i]) continue; // do not overwrite burgs + if (pack.states[cells.state[i]]?.lock) continue; // do not overwrite cells of locks states + if (cells.c[i].some((c) => burgs[cells.burg[c]].capital)) continue; // do not overwrite near capital + const neibs = cells.c[i].filter((c) => cells.h[c] >= 20); + const adversaries = neibs.filter( + (c) => + !pack.states[cells.state[c]]?.lock && + cells.state[c] !== cells.state[i], + ); + if (adversaries.length < 2) continue; + const buddies = neibs.filter( + (c) => + !pack.states[cells.state[c]]?.lock && + cells.state[c] === cells.state[i], + ); + if (buddies.length > 2) continue; + if (adversaries.length <= buddies.length) continue; + cells.state[i] = cells.state[adversaries[0]]; + } + TIME && console.timeEnd("normalizeStates"); + } + + // calculate pole of inaccessibility for each state + getPoles() { + const getType = (cellId: number) => pack.cells.state[cellId]; + const poles = getPolesOfInaccessibility(pack, getType); + + pack.states.forEach((s) => { + if (!s.i || s.removed) return; + s.pole = poles[s.i] || [0, 0]; + }); + } + + findNeighbors() { + const { cells, states } = pack; + + const stateNeighbors: Set[] = []; + + states.forEach((s) => { + if (s.removed) return; + stateNeighbors[s.i] = new Set(); + // s.neighbors = stateNeighbors[s.i]; + }); + + for (const i of cells.i) { + if (cells.h[i] < 20) continue; + const s = cells.state[i]; + + cells.c[i] + .filter((c) => cells.h[c] >= 20 && cells.state[c] !== s) + .forEach((c) => { + stateNeighbors[s].add(cells.state[c]); + }); + } + + // convert neighbors Set object into array + states.forEach((s) => { + if (!stateNeighbors[s.i] || s.removed) return; + s.neighbors = Array.from(stateNeighbors[s.i]); + }); + } + + assignColors() { + TIME && console.time("assignColors"); + const colors = [ + "#66c2a5", + "#fc8d62", + "#8da0cb", + "#e78ac3", + "#a6d854", + "#ffd92f", + ]; // d3.schemeSet2; + const states = pack.states; + + // assign basic color using greedy coloring algorithm + states.forEach((state) => { + if (!state.i || state.removed || state.lock) return; + state.color = colors.find((color) => + state.neighbors!.every( + (neibStateId) => states[neibStateId].color !== color, + ), + ); + if (!state.color) state.color = getRandomColor(); + colors.push(colors.shift() as string); + }); + + // randomize each already used color a bit + colors.forEach((c) => { + const sameColored = states.filter( + (state) => state.color === c && state.i && !state.lock, + ); + sameColored.forEach((state, index) => { + if (!index) return; + state.color = getMixedColor(state.color!); + }); + }); + + TIME && console.timeEnd("assignColors"); + } + + // calculate states data like area, population etc. + collectStatistics() { + TIME && console.time("collectStatistics"); + const { cells, states } = pack; + + states.forEach((s) => { + if (s.removed) return; + s.cells = s.area = s.burgs = s.rural = s.urban = 0; + }); + + for (const i of cells.i) { + if (cells.h[i] < 20) continue; + const s = cells.state[i]; + + // collect stats + states[s].cells! += 1; + states[s].area! += cells.area[i]; + states[s].rural! += cells.pop[i]; + if (cells.burg[i]) { + states[s].urban! += pack.burgs[cells.burg[i]].population!; + states[s].burgs!++; + } + } + + TIME && console.timeEnd("collectStatistics"); + } + + generateCampaign(state: State) { + const wars = { + War: 6, + Conflict: 2, + Campaign: 4, + Invasion: 2, + Rebellion: 2, + Conquest: 2, + Intervention: 1, + Expedition: 1, + Crusade: 1, + }; + const neighbors = state.neighbors?.length ? state.neighbors : [0]; + return neighbors + .map((i: number) => { + const name = + i && P(0.8) + ? pack.states[i].name + : Names.getCultureShort(state.culture); + const start = gauss(options.year - 100, 150, 1, options.year - 6); + const end = start + gauss(4, 5, 1, options.year - start - 1); + return { name: `${getAdjective(name)} ${rw(wars)}`, start, end }; + }) + .sort((a, b) => a.start - b.start); + } + + generateCampaigns() { + pack.states.forEach((s) => { + if (!s.i || s.removed) return; + s.campaigns = this.generateCampaign(s); + }); + } + + // generate Diplomatic Relationships + generateDiplomacy() { + TIME && console.time("generateDiplomacy"); + const { cells, states } = pack; + states[0].diplomacy = []; + // FIRST STATE IS ALWAYS NEUTRAL and contains the history of diplomacy + const chronicle = states[0].diplomacy; + const valid = states.filter((s) => s.i && !s.removed); // will filter out neutral as i is 0 => false + + const neibs = { Ally: 1, Friendly: 2, Neutral: 1, Suspicion: 10, Rival: 9 }; // relations to neighbors + const neibsOfNeibs = { Ally: 10, Friendly: 8, Neutral: 5, Suspicion: 1 }; // relations to neighbors of neighbors + const far = { Friendly: 1, Neutral: 12, Suspicion: 2, Unknown: 6 }; // relations to other + const navals = { Neutral: 1, Suspicion: 2, Rival: 1, Unknown: 1 }; // relations of naval powers + + valid.forEach((s) => { + s.diplomacy = new Array(states.length).fill("x"); // clear all relationships + }); + if (valid.length < 2) return; // no states to generate relations with + const areaMean: number = mean(valid.map((s) => s.area!)) as number; // average state area + + // generic relations + for (let f = 1; f < states.length; f++) { + if (states[f].removed) continue; + if (states[f].diplomacy!.includes("Vassal")) { + // Vassals copy relations from their Suzerains + const suzerain = states[f].diplomacy!.indexOf("Vassal"); + + for (let i = 1; i < states.length; i++) { + if (i === f || i === suzerain) continue; + states[f].diplomacy![i] = states[suzerain].diplomacy![i]; + if (states[suzerain].diplomacy![i] === "Suzerain") + states[f].diplomacy![i] = "Ally"; + for (let e = 1; e < states.length; e++) { + if (e === f || e === suzerain) continue; + if ( + states[e].diplomacy![suzerain] === "Suzerain" || + states[e].diplomacy![suzerain] === "Vassal" + ) + continue; + states[e].diplomacy![f] = states[e].diplomacy![suzerain]; + } + } + continue; + } + + for (let t = f + 1; t < states.length; t++) { + if (states[t].removed) continue; + + if (states[t].diplomacy!.includes("Vassal")) { + const suzerain = states[t].diplomacy!.indexOf("Vassal"); + states[f].diplomacy![t] = states[f].diplomacy![suzerain]; + continue; + } + + const naval = + states[f].type === "Naval" && + states[t].type === "Naval" && + cells.f[states[f].center] !== cells.f[states[t].center]; + const neib = naval ? false : states[f].neighbors!.includes(t); + const neibOfNeib = + naval || neib + ? false + : states[f] + .neighbors!.map((n) => states[n].neighbors) + .join("") + .includes(t.toString()); + + let status = naval + ? rw(navals) + : neib + ? rw(neibs) + : neibOfNeib + ? rw(neibsOfNeibs) + : rw(far); + + // add Vassal + if ( + neib && + P(0.8) && + states[f].area! > areaMean && + states[t].area! < areaMean && + states[f].area! / states[t].area! > 2 + ) + status = "Vassal"; + states[f].diplomacy![t] = status === "Vassal" ? "Suzerain" : status; + states[t].diplomacy![f] = status; + } + } + + // declare wars + for (let attacker = 1; attacker < states.length; attacker++) { + const ad = states[attacker].diplomacy as string[]; // attacker relations; + if (states[attacker].removed) continue; + if (!ad.includes("Rival")) continue; // no rivals to attack + if (ad.includes("Vassal")) continue; // not independent + if (ad.includes("Enemy")) continue; // already at war + + // random independent rival + const defender = ra( + ad + .map((r, d) => + r === "Rival" && !states[d].diplomacy!.includes("Vassal") ? d : 0, + ) + .filter((d) => d), + ); + let ap = states[attacker].area! * states[attacker].expansionism; + let dp = states[defender].area! * states[defender].expansionism; + if (ap < dp * gauss(1.6, 0.8, 0, 10, 2)) continue; // defender is too strong + + const an = states[attacker].name; + const dn = states[defender].name; // names + const attackers = [attacker]; + const defenders = [defender]; // attackers and defenders array + const dd = states[defender].diplomacy as string[]; // defender relations; + + // start an ongoing war + const name = `${an}-${trimVowels(dn)}ian War`; + const start = options.year - gauss(2, 3, 0, 10); + const war = [name, `${an} declared a war on its rival ${dn}`]; + const campaign = { name, start, attacker, defender }; + states[attacker].campaigns!.push(campaign); + states[defender].campaigns!.push(campaign); + + // attacker vassals join the war + ad.forEach((r, d) => { + if (r === "Suzerain") { + attackers.push(d); + war.push( + `${an}'s vassal ${states[d].name} joined the war on attackers side`, + ); + } + }); + + // defender vassals join the war + dd.forEach((r, d) => { + if (r === "Suzerain") { + defenders.push(d); + war.push( + `${dn}'s vassal ${states[d].name} joined the war on defenders side`, + ); + } + }); + + ap = sum(attackers.map((a) => states[a].area! * states[a].expansionism)); // attackers joined power + dp = sum(defenders.map((d) => states[d].area! * states[d].expansionism)); // defender joined power + + // defender allies join + dd.forEach((r, d) => { + if (r !== "Ally" || states[d].diplomacy!.includes("Vassal")) return; + if ( + states[d].diplomacy![attacker] !== "Rival" && + ap / dp > 2 * gauss(1.6, 0.8, 0, 10, 2) + ) { + const reason = states[d].diplomacy!.includes("Enemy") + ? "Being already at war," + : `Frightened by ${an},`; + war.push( + `${reason} ${states[d].name} severed the defense pact with ${dn}`, + ); + dd[d] = states[d].diplomacy![defender] = "Suspicion"; + return; + } + defenders.push(d); + dp += states[d].area! * states[d].expansionism; + war.push( + `${dn}'s ally ${states[d].name} joined the war on defenders side`, + ); + + // ally vassals join + states[d] + .diplomacy!.map((r, d) => (r === "Suzerain" ? d : 0)) + .filter((d) => d) + .forEach((v) => { + defenders.push(v); + dp += states[v].area! * states[v].expansionism; + war.push( + `${states[d].name}'s vassal ${states[v].name} joined the war on defenders side`, + ); + }); + }); + + // attacker allies join if the defender is their rival or joined power > defenders power and defender is not an ally + ad.forEach((r, d) => { + if ( + r !== "Ally" || + states[d].diplomacy!.includes("Vassal") || + defenders.includes(d) + ) + return; + const name = states[d].name; + if ( + states[d].diplomacy![defender] !== "Rival" && + (P(0.2) || ap <= dp * 1.2) + ) { + war.push(`${an}'s ally ${name} avoided entering the war`); + return; + } + const allies = states[d] + .diplomacy!.map((r, d) => (r === "Ally" ? d : 0)) + .filter((d) => d); + if (allies.some((ally) => defenders.includes(ally))) { + war.push( + `${an}'s ally ${name} did not join the war as its allies are in war on both sides`, + ); + return; + } + + attackers.push(d); + ap += states[d].area! * states[d].expansionism; + war.push(`${an}'s ally ${name} joined the war on attackers side`); + + // ally vassals join + states[d] + .diplomacy!.map((r, d) => (r === "Suzerain" ? d : 0)) + .filter((d) => d) + .forEach((v) => { + attackers.push(v); + // TODO: I think here is a bug, it should be ap instead of dp + ap += states[v].area! * states[v].expansionism; + war.push( + `${states[d].name}'s vassal ${states[v].name} joined the war on attackers side`, + ); + }); + }); + + // change relations to Enemy for all participants + attackers.forEach((a) => { + defenders.forEach((d: number) => { + states[a].diplomacy![d] = states[d].diplomacy![a] = "Enemy"; + }); + }); + // TODO: record war in chronicle to keep state interface clean + chronicle.push(war as any); // add a record to diplomatical history + } + TIME && console.timeEnd("generateDiplomacy"); + } + + // select a forms for listed or all valid states + defineStateForms(list: number[] | null = null) { + TIME && console.time("defineStateForms"); + const states = pack.states.filter((s) => s.i && !s.removed && !s.lock); + if (states.length < 1) return; + + const generic = { Monarchy: 25, Republic: 2, Union: 1 }; + const naval = { Monarchy: 25, Republic: 8, Union: 3 }; + + const medianState = median(pack.states.map((s) => s.area))!; + const empireMin = states.map((s) => s.area).sort((a = 0, b = 0) => b - a)[ + Math.max(Math.ceil(states.length ** 0.4) - 2, 0) + ]!; + const expTiers = pack.states.map((s) => { + let tier = Math.min(Math.floor((s.area! / medianState) * 2.6), 4); + if (tier === 4 && s.area! < empireMin) tier = 3; + return tier; + }); + + const monarchy = [ + "Duchy", + "Grand Duchy", + "Principality", + "Kingdom", + "Empire", + ]; // per expansionism tier + const republic = { + Republic: 75, + Federation: 4, + "Trade Company": 4, + "Most Serene Republic": 2, + Oligarchy: 2, + Tetrarchy: 1, + Triumvirate: 1, + Diarchy: 1, + Junta: 1, + }; // weighted random + const union = { + Union: 3, + League: 4, + Confederation: 1, + "United Kingdom": 1, + "United Republic": 1, + "United Provinces": 2, + Commonwealth: 1, + Heptarchy: 1, + }; // weighted random + const theocracy = { + Theocracy: 20, + Brotherhood: 1, + Thearchy: 2, + See: 1, + "Holy State": 1, + }; + const anarchy = { + "Free Territory": 2, + Council: 3, + Commune: 1, + Community: 1, + }; + + for (const s of states) { + if (list && !list.includes(s.i)) continue; + const tier = expTiers[s.i]; + + const religion = pack.cells.religion[s.center]; + const isTheocracy = + (religion && pack.religions[religion].expansion === "state") || + (P(0.1) && + ["Organized", "Cult"].includes(pack.religions[religion].type)); + const isAnarchy = P(0.01 - tier / 500); + + if (isTheocracy) s.form = "Theocracy"; + else if (isAnarchy) s.form = "Anarchy"; + else s.form = s.type === "Naval" ? rw(naval) : rw(generic); + + const selectForm = (s: any, tier: number) => { + const base = pack.cultures[s.culture].base; + + if (s.form === "Monarchy") { + const form = monarchy[tier]; + // Default name depends on exponent tier, some culture bases have special names for tiers + if (s.diplomacy) { + if ( + form === "Duchy" && + s.neighbors.length > 1 && + rand(6) < s.neighbors.length && + s.diplomacy.includes("Vassal") + ) + return "Marches"; // some vassal duchies on borderland + if (base === 1 && P(0.3) && s.diplomacy.includes("Vassal")) + return "Dominion"; // English vassals + if (P(0.3) && s.diplomacy.includes("Vassal")) return "Protectorate"; // some vassals + } + + if (base === 31 && (form === "Empire" || form === "Kingdom")) + return "Khanate"; // Mongolian + if (base === 16 && form === "Principality") return "Beylik"; // Turkic + if (base === 5 && (form === "Empire" || form === "Kingdom")) + return "Tsardom"; // Ruthenian + if (base === 16 && (form === "Empire" || form === "Kingdom")) + return "Khaganate"; // Turkic + if (base === 12 && (form === "Kingdom" || form === "Grand Duchy")) + return "Shogunate"; // Japanese + if ([18, 17].includes(base) && form === "Empire") return "Caliphate"; // Arabic, Berber + if (base === 18 && (form === "Grand Duchy" || form === "Duchy")) + return "Emirate"; // Arabic + if (base === 7 && (form === "Grand Duchy" || form === "Duchy")) + return "Despotate"; // Greek + if (base === 31 && (form === "Grand Duchy" || form === "Duchy")) + return "Ulus"; // Mongolian + if (base === 16 && (form === "Grand Duchy" || form === "Duchy")) + return "Horde"; // Turkic + if (base === 24 && (form === "Grand Duchy" || form === "Duchy")) + return "Satrapy"; // Iranian + return form; + } + + if (s.form === "Republic") { + // Default name is from weighted array, special case for small states with only 1 burg + if (tier < 2 && s.burgs === 1) { + if ( + trimVowels(s.name) === trimVowels(pack.burgs[s.capital].name!) + ) { + s.name = pack.burgs[s.capital].name; + return "Free City"; + } + if (P(0.3)) return "City-state"; + } + return rw(republic); + } + + if (s.form === "Union") return rw(union); + if (s.form === "Anarchy") return rw(anarchy); + + if (s.form === "Theocracy") { + // European + if ([0, 1, 2, 3, 4, 6, 8, 9, 13, 15, 20].includes(base)) { + if (P(0.1)) return `Divine ${monarchy[tier]}`; + if (tier < 2 && P(0.5)) return "Diocese"; + if (tier < 2 && P(0.5)) return "Bishopric"; + } + if (P(0.9) && [7, 5].includes(base)) { + // Greek, Ruthenian + if (tier < 2) return "Eparchy"; + if (tier === 2) return "Exarchate"; + if (tier > 2) return "Patriarchate"; + } + if (P(0.9) && [21, 16].includes(base)) return "Imamah"; // Nigerian, Turkish + if (tier > 2 && P(0.8) && [18, 17, 28].includes(base)) + return "Caliphate"; // Arabic, Berber, Swahili + return rw(theocracy); + } + }; + + s.formName = selectForm(s, tier); + s.fullName = this.getFullName(s); + } + + TIME && console.timeEnd("defineStateForms"); + } + + getFullName(state: State) { + // state forms requiring Adjective + Name, all other forms use scheme Form + Of + Name + const adjForms = [ + "Empire", + "Sultanate", + "Khaganate", + "Shogunate", + "Caliphate", + "Despotate", + "Theocracy", + "Oligarchy", + "Union", + "Confederation", + "Trade Company", + "League", + "Tetrarchy", + "Triumvirate", + "Diarchy", + "Horde", + "Marches", + ]; + if (!state.formName) return state.name; + if (!state.name && state.formName) return `The ${state.formName}`; + const adjName = + adjForms.includes(state.formName) && !/-| /.test(state.name); + return adjName + ? `${getAdjective(state.name)} ${state.formName}` + : `${state.formName} of ${state.name}`; + } +} + +window.States = new StatesModule(); diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 4df397e4..26bfab5d 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -2,6 +2,7 @@ import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; import type { River } from "../modules/river-generator"; +import type { State } from "../modules/states-generator"; type TypedArray = | Uint8Array @@ -48,6 +49,7 @@ export interface PackedGraph { rivers: River[]; features: PackedGraphFeature[]; burgs: Burg[]; - states: any[]; + states: State[]; cultures: Culture[]; + religions: any[]; } From 454178fa99b3aeadda07d80e25919b17734311be Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 30 Jan 2026 18:29:44 +0100 Subject: [PATCH 17/24] refactor: migrate routes (#1294) * refactor: migrate routes * refactor: format findPath call for improved readability * refactor: update findPath call to include pack parameter * refactor: optimize route deletion logic in RoutesModule --- public/modules/routes-generator.js | 677 --------------- src/index.html | 1 - src/modules/index.ts | 1 + src/modules/routes-generator.ts | 786 ++++++++++++++++++ src/types/PackedGraph.ts | 4 + src/types/global.ts | 2 + .../e2e/layers.spec.ts-snapshots/routes.html | 2 +- 7 files changed, 794 insertions(+), 679 deletions(-) delete mode 100644 public/modules/routes-generator.js create mode 100644 src/modules/routes-generator.ts diff --git a/public/modules/routes-generator.js b/public/modules/routes-generator.js deleted file mode 100644 index 460625ed..00000000 --- a/public/modules/routes-generator.js +++ /dev/null @@ -1,677 +0,0 @@ -const ROUTES_SHARP_ANGLE = 135; -const ROUTES_VERY_SHARP_ANGLE = 115; - -const MIN_PASSABLE_SEA_TEMP = -4; -const ROUTE_TYPE_MODIFIERS = { - "-1": 1, // coastline - "-2": 1.8, // sea - "-3": 4, // open sea - "-4": 6, // ocean - default: 8 // far ocean -}; - -window.Routes = (function () { - function generate(lockedRoutes = []) { - const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs); - - const connections = new Map(); - lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2]))); - - const mainRoads = generateMainRoads(); - const trails = generateTrails(); - const seaRoutes = generateSeaRoutes(); - - pack.routes = createRoutesData(lockedRoutes); - pack.cells.routes = buildLinks(pack.routes); - - function sortBurgsByFeature(burgs) { - const burgsByFeature = {}; - const capitalsByFeature = {}; - const portsByFeature = {}; - - const addBurg = (collection, feature, burg) => { - if (!collection[feature]) collection[feature] = []; - collection[feature].push(burg); - }; - - for (const burg of burgs) { - if (burg.i && !burg.removed) { - const {feature, capital, port} = burg; - addBurg(burgsByFeature, feature, burg); - if (capital) addBurg(capitalsByFeature, feature, burg); - if (port) addBurg(portsByFeature, port, burg); - } - } - - return {burgsByFeature, capitalsByFeature, portsByFeature}; - } - - function generateMainRoads() { - TIME && console.time("generateMainRoads"); - const mainRoads = []; - - for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) { - const points = featureCapitals.map(burg => [burg.x, burg.y]); - const urquhartEdges = calculateUrquhartEdges(points); - urquhartEdges.forEach(([fromId, toId]) => { - const start = featureCapitals[fromId].cell; - const exit = featureCapitals[toId].cell; - - const segments = findPathSegments({isWater: false, connections, start, exit}); - for (const segment of segments) { - addConnections(segment); - mainRoads.push({feature: Number(key), cells: segment}); - } - }); - } - - TIME && console.timeEnd("generateMainRoads"); - return mainRoads; - } - - function generateTrails() { - TIME && console.time("generateTrails"); - const trails = []; - - for (const [key, featureBurgs] of Object.entries(burgsByFeature)) { - const points = featureBurgs.map(burg => [burg.x, burg.y]); - const urquhartEdges = calculateUrquhartEdges(points); - urquhartEdges.forEach(([fromId, toId]) => { - const start = featureBurgs[fromId].cell; - const exit = featureBurgs[toId].cell; - - const segments = findPathSegments({isWater: false, connections, start, exit}); - for (const segment of segments) { - addConnections(segment); - trails.push({feature: Number(key), cells: segment}); - } - }); - } - - TIME && console.timeEnd("generateTrails"); - return trails; - } - - function generateSeaRoutes() { - TIME && console.time("generateSeaRoutes"); - const seaRoutes = []; - - for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { - const points = featurePorts.map(burg => [burg.x, burg.y]); - const urquhartEdges = calculateUrquhartEdges(points); - - urquhartEdges.forEach(([fromId, toId]) => { - const start = featurePorts[fromId].cell; - const exit = featurePorts[toId].cell; - const segments = findPathSegments({isWater: true, connections, start, exit}); - for (const segment of segments) { - addConnections(segment); - seaRoutes.push({feature: Number(featureId), cells: segment}); - } - }); - } - - TIME && console.timeEnd("generateSeaRoutes"); - return seaRoutes; - } - - function addConnections(segment) { - for (let i = 0; i < segment.length; i++) { - const cellId = segment[i]; - const nextCellId = segment[i + 1]; - if (nextCellId) { - connections.set(`${cellId}-${nextCellId}`, true); - connections.set(`${nextCellId}-${cellId}`, true); - } - } - } - - function findPathSegments({isWater, connections, start, exit}) { - const getCost = createCostEvaluator({isWater, connections}); - const pathCells = findPath(start, current => current === exit, getCost); - if (!pathCells) return []; - const segments = getRouteSegments(pathCells, connections); - return segments; - } - - function createRoutesData(routes) { - const pointsArray = preparePointsArray(); - - for (const {feature, cells, merged} of mergeRoutes(mainRoads)) { - if (merged) continue; - const points = getPoints("roads", cells, pointsArray); - routes.push({i: routes.length, group: "roads", feature, points}); - } - - for (const {feature, cells, merged} of mergeRoutes(trails)) { - if (merged) continue; - const points = getPoints("trails", cells, pointsArray); - routes.push({i: routes.length, group: "trails", feature, points}); - } - - for (const {feature, cells, merged} of mergeRoutes(seaRoutes)) { - if (merged) continue; - const points = getPoints("searoutes", cells, pointsArray); - routes.push({i: routes.length, group: "searoutes", feature, points}); - } - - return routes; - } - - // merge routes so that the last cell of one route is the first cell of the next route - function mergeRoutes(routes) { - let routesMerged = 0; - - for (let i = 0; i < routes.length; i++) { - const thisRoute = routes[i]; - if (thisRoute.merged) continue; - - for (let j = i + 1; j < routes.length; j++) { - const nextRoute = routes[j]; - if (nextRoute.merged) continue; - - if (nextRoute.cells.at(0) === thisRoute.cells.at(-1)) { - routesMerged++; - thisRoute.cells = thisRoute.cells.concat(nextRoute.cells.slice(1)); - nextRoute.merged = true; - } - } - } - - return routesMerged > 1 ? mergeRoutes(routes) : routes; - } - } - - function createCostEvaluator({isWater, connections}) { - return isWater ? getWaterPathCost : getLandPathCost; - - function getLandPathCost(current, next) { - if (pack.cells.h[next] < 20) return Infinity; // ignore water cells - - const habitability = biomesData.habitability[pack.cells.biome[next]]; - if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier) - - const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]); - const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1]; - const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3]; - const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1; - const burgModifier = pack.cells.burg[next] ? 1 : 3; - - const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier; - return pathCost; - } - - function getWaterPathCost(current, next) { - if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells - if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) return Infinity; // ignore too cold cells - - const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]); - const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default; - const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1; - - const pathCost = distanceCost * typeModifier * connectionModifier; - return pathCost; - } - } - - function buildLinks(routes) { - const links = {}; - - for (const {points, i: routeId} of routes) { - const cells = points.map(p => p[2]); - - for (let i = 0; i < cells.length - 1; i++) { - const cellId = cells[i]; - const nextCellId = cells[i + 1]; - - if (cellId !== nextCellId) { - if (!links[cellId]) links[cellId] = {}; - links[cellId][nextCellId] = routeId; - - if (!links[nextCellId]) links[nextCellId] = {}; - links[nextCellId][cellId] = routeId; - } - } - } - - return links; - } - - function preparePointsArray() { - const {cells, burgs} = pack; - return cells.p.map(([x, y], cellId) => { - const burgId = cells.burg[cellId]; - if (burgId) return [burgs[burgId].x, burgs[burgId].y]; - return [x, y]; - }); - } - - function getPoints(group, cells, points) { - const data = cells.map(cellId => [...points[cellId], cellId]); - - // resolve sharp angles - if (group !== "searoutes") { - for (let i = 1; i < cells.length - 1; i++) { - const cellId = cells[i]; - if (pack.cells.burg[cellId]) continue; - - const [prevX, prevY] = data[i - 1]; - const [currX, currY] = data[i]; - const [nextX, nextY] = data[i + 1]; - - const dAx = prevX - currX; - const dAy = prevY - currY; - const dBx = nextX - currX; - const dBy = nextY - currY; - const angle = Math.abs((Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / Math.PI); - - if (angle < ROUTES_SHARP_ANGLE) { - const middleX = (prevX + nextX) / 2; - const middleY = (prevY + nextY) / 2; - let newX, newY; - - if (angle < ROUTES_VERY_SHARP_ANGLE) { - newX = rn((currX + middleX * 2) / 3, 2); - newY = rn((currY + middleY * 2) / 3, 2); - } else { - newX = rn((currX + middleX) / 2, 2); - newY = rn((currY + middleY) / 2, 2); - } - - if (findCell(newX, newY) === cellId) { - data[i] = [newX, newY, cellId]; - points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes - } - } - } - } - - return data; // [[x, y, cell], [x, y, cell]]; - } - - function getRouteSegments(pathCells, connections) { - const segments = []; - let segment = []; - - for (let i = 0; i < pathCells.length; i++) { - const cellId = pathCells[i]; - const nextCellId = pathCells[i + 1]; - const isConnected = connections.has(`${cellId}-${nextCellId}`) || connections.has(`${nextCellId}-${cellId}`); - - if (isConnected) { - if (segment.length) { - // segment stepped into existing segment - segment.push(pathCells[i]); - segments.push(segment); - segment = []; - } - continue; - } - - segment.push(pathCells[i]); - } - - if (segment.length > 1) segments.push(segment); - - return segments; - } - - // Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation - // this gives us an aproximation of a desired road network, i.e. connections between burgs - // code from https://observablehq.com/@mbostock/urquhart-graph - function calculateUrquhartEdges(points) { - const score = (p0, p1) => dist2(points[p0], points[p1]); - - const {halfedges, triangles} = Delaunator.from(points); - const n = triangles.length; - - const removed = new Uint8Array(n); - const edges = []; - - for (let e = 0; e < n; e += 3) { - const p0 = triangles[e], - p1 = triangles[e + 1], - p2 = triangles[e + 2]; - - const p01 = score(p0, p1), - p12 = score(p1, p2), - p20 = score(p2, p0); - - removed[ - p20 > p01 && p20 > p12 - ? Math.max(e + 2, halfedges[e + 2]) - : p12 > p01 && p12 > p20 - ? Math.max(e + 1, halfedges[e + 1]) - : Math.max(e, halfedges[e]) - ] = 1; - } - - for (let e = 0; e < n; ++e) { - if (e > halfedges[e] && !removed[e]) { - const t0 = triangles[e]; - const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1]; - edges.push([t0, t1]); - } - } - - return edges; - } - - // connect cell with routes system by land - function connect(cellId) { - const getCost = createCostEvaluator({isWater: false, connections: new Map()}); - const isExit = c => isLand(c) && isConnected(c); - const pathCells = findPath(cellId, isExit, getCost); - if (!pathCells) return; - - const pointsArray = preparePointsArray(); - const points = getPoints("trails", pathCells, pointsArray); - const feature = pack.cells.f[cellId]; - const routeId = getNextId(); - const newRoute = {i: routeId, group: "trails", feature, points}; - pack.routes.push(newRoute); - - for (let i = 0; i < pathCells.length; i++) { - const currentCell = pathCells[i]; - const nextCellId = pathCells[i + 1]; - if (nextCellId) addConnection(currentCell, nextCellId, routeId); - } - - return newRoute; - - function addConnection(from, to, routeId) { - const routes = pack.cells.routes; - - if (!routes[from]) routes[from] = {}; - routes[from][to] = routeId; - - if (!routes[to]) routes[to] = {}; - routes[to][from] = routeId; - } - } - - // utility functions - function isConnected(cellId) { - const routes = pack.cells.routes; - return routes[cellId] && Object.keys(routes[cellId]).length > 0; - } - - function areConnected(from, to) { - const routeId = pack.cells.routes[from]?.[to]; - return routeId !== undefined; - } - - function getRoute(from, to) { - const routeId = pack.cells.routes[from]?.[to]; - if (routeId === undefined) return null; - - const route = pack.routes.find(route => route.i === routeId); - if (!route) return null; - - return route; - } - - function hasRoad(cellId) { - const connections = pack.cells.routes[cellId]; - if (!connections) return false; - - return Object.values(connections).some(routeId => { - const route = pack.routes.find(route => route.i === routeId); - if (!route) return false; - return route.group === "roads"; - }); - } - - function isCrossroad(cellId) { - const connections = pack.cells.routes[cellId]; - if (!connections) return false; - if (Object.keys(connections).length > 3) return true; - const roadConnections = Object.values(connections).filter(routeId => { - const route = pack.routes.find(route => route.i === routeId); - return route?.group === "roads"; - }); - return roadConnections.length > 2; - } - - const connectivityRateMap = { - roads: 0.2, - trails: 0.1, - searoutes: 0.2, - default: 0.1 - }; - - function getConnectivityRate(cellId) { - const connections = pack.cells.routes[cellId]; - if (!connections) return 0; - - const connectivity = Object.values(connections).reduce((acc, routeId) => { - const route = pack.routes.find(route => route.i === routeId); - if (!route) return acc; - const rate = connectivityRateMap[route.group] || connectivityRateMap.default; - return acc + rate; - }, 0.8); - - return connectivity; - } - - // name generator data - const models = { - roads: {burg_suffix: 3, prefix_suffix: 6, the_descriptor_prefix_suffix: 2, the_descriptor_burg_suffix: 1}, - trails: {burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1}, - searoutes: {burg_suffix: 4, prefix_suffix: 2, the_descriptor_prefix_suffix: 1} - }; - - const prefixes = [ - "King", - "Queen", - "Military", - "Old", - "New", - "Ancient", - "Royal", - "Imperial", - "Great", - "Grand", - "High", - "Silver", - "Dragon", - "Shadow", - "Star", - "Mystic", - "Whisper", - "Eagle", - "Golden", - "Crystal", - "Enchanted", - "Frost", - "Moon", - "Sun", - "Thunder", - "Phoenix", - "Sapphire", - "Celestial", - "Wandering", - "Echo", - "Twilight", - "Crimson", - "Serpent", - "Iron", - "Forest", - "Flower", - "Whispering", - "Eternal", - "Frozen", - "Rain", - "Luminous", - "Stardust", - "Arcane", - "Glimmering", - "Jade", - "Ember", - "Azure", - "Gilded", - "Divine", - "Shadowed", - "Cursed", - "Moonlit", - "Sable", - "Everlasting", - "Amber", - "Nightshade", - "Wraith", - "Scarlet", - "Platinum", - "Whirlwind", - "Obsidian", - "Ethereal", - "Ghost", - "Spike", - "Dusk", - "Raven", - "Spectral", - "Burning", - "Verdant", - "Copper", - "Velvet", - "Falcon", - "Enigma", - "Glowing", - "Silvered", - "Molten", - "Radiant", - "Astral", - "Wild", - "Flame", - "Amethyst", - "Aurora", - "Shadowy", - "Solar", - "Lunar", - "Whisperwind", - "Fading", - "Titan", - "Dawn", - "Crystalline", - "Jeweled", - "Sylvan", - "Twisted", - "Ebon", - "Thorn", - "Cerulean", - "Halcyon", - "Infernal", - "Storm", - "Eldritch", - "Sapphire", - "Crimson", - "Tranquil", - "Paved" - ]; - - const descriptors = [ - "Great", - "Shrouded", - "Sacred", - "Fabled", - "Frosty", - "Winding", - "Echoing", - "Serpentine", - "Breezy", - "Misty", - "Rustic", - "Silent", - "Cobbled", - "Cracked", - "Shaky", - "Obscure" - ]; - - const suffixes = { - roads: {road: 7, route: 3, way: 2, highway: 1}, - trails: {trail: 4, path: 1, track: 1, pass: 1}, - searoutes: {"sea route": 5, lane: 2, passage: 1, seaway: 1} - }; - - function generateName({group, points}) { - if (points.length < 4) return "Unnamed route segment"; - - const model = rw(models[group]); - const suffix = rw(suffixes[group]); - - const burgName = getBurgName(); - if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`; - if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`; - if (model === "the_descriptor_prefix_suffix") return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`; - if (model === "the_descriptor_burg_suffix" && burgName) return `The ${ra(descriptors)} ${burgName} ${suffix}`; - return "Unnamed route"; - - function getBurgName() { - const priority = [points.at(-1), points.at(0), points.slice(1, -1).reverse()]; - for (const [_x, _y, cellId] of priority) { - const burgId = pack.cells.burg[cellId]; - if (burgId) return getAdjective(pack.burgs[burgId].name); - } - return null; - } - } - - const ROUTE_CURVES = { - roads: d3.curveCatmullRom.alpha(0.1), - trails: d3.curveCatmullRom.alpha(0.1), - searoutes: d3.curveCatmullRom.alpha(0.5), - default: d3.curveCatmullRom.alpha(0.1) - }; - - function getPath({group, points}) { - const lineGen = d3.line(); - lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default); - const path = round(lineGen(points.map(p => [p[0], p[1]])), 1); - return path; - } - - function getLength(routeId) { - const path = routes.select("#route" + routeId).node(); - return path.getTotalLength(); - } - - function getNextId() { - return pack.routes.length ? Math.max(...pack.routes.map(r => r.i)) + 1 : 0; - } - - function remove(route) { - const routes = pack.cells.routes; - - for (const point of route.points) { - const from = point[2]; - if (!routes[from]) continue; - - for (const [to, routeId] of Object.entries(routes[from])) { - if (routeId === route.i) { - delete routes[from][to]; - delete routes[to][from]; - } - } - } - - pack.routes = pack.routes.filter(r => r.i !== route.i); - viewbox.select("#route" + route.i).remove(); - } - - return { - generate, - buildLinks, - connect, - isConnected, - areConnected, - getRoute, - hasRoad, - isCrossroad, - getConnectivityRate, - generateName, - getPath, - getLength, - getNextId, - remove - }; -})(); diff --git a/src/index.html b/src/index.html index 14b61949..f4b605b2 100644 --- a/src/index.html +++ b/src/index.html @@ -8495,7 +8495,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index 660fc100..a3dbe219 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -8,4 +8,5 @@ import "./river-generator"; import "./burgs-generator"; import "./biomes"; import "./cultures-generator"; +import "./routes-generator"; import "./states-generator"; diff --git a/src/modules/routes-generator.ts b/src/modules/routes-generator.ts new file mode 100644 index 00000000..b233db47 --- /dev/null +++ b/src/modules/routes-generator.ts @@ -0,0 +1,786 @@ +import { curveCatmullRom, line } from "d3"; +import Delaunator from "delaunator"; +import { + distanceSquared, + findClosestCell, + findPath, + getAdjective, + isLand, + ra, + rn, + round, + rw, +} from "../utils"; +import type { Burg } from "./burgs-generator"; +import type { Point } from "./voronoi"; + +const ROUTES_SHARP_ANGLE = 135; +const ROUTES_VERY_SHARP_ANGLE = 115; + +const MIN_PASSABLE_SEA_TEMP = -4; +const ROUTE_TYPE_MODIFIERS: Record = { + "-1": 1, // coastline + "-2": 1.8, // sea + "-3": 4, // open sea + "-4": 6, // ocean + default: 8, // far ocean +}; + +// name generator data +const models: Record> = { + roads: { + burg_suffix: 3, + prefix_suffix: 6, + the_descriptor_prefix_suffix: 2, + the_descriptor_burg_suffix: 1, + }, + trails: { burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1 }, + searoutes: { + burg_suffix: 4, + prefix_suffix: 2, + the_descriptor_prefix_suffix: 1, + }, +}; + +const prefixes: string[] = [ + "King", + "Queen", + "Military", + "Old", + "New", + "Ancient", + "Royal", + "Imperial", + "Great", + "Grand", + "High", + "Silver", + "Dragon", + "Shadow", + "Star", + "Mystic", + "Whisper", + "Eagle", + "Golden", + "Crystal", + "Enchanted", + "Frost", + "Moon", + "Sun", + "Thunder", + "Phoenix", + "Sapphire", + "Celestial", + "Wandering", + "Echo", + "Twilight", + "Crimson", + "Serpent", + "Iron", + "Forest", + "Flower", + "Whispering", + "Eternal", + "Frozen", + "Rain", + "Luminous", + "Stardust", + "Arcane", + "Glimmering", + "Jade", + "Ember", + "Azure", + "Gilded", + "Divine", + "Shadowed", + "Cursed", + "Moonlit", + "Sable", + "Everlasting", + "Amber", + "Nightshade", + "Wraith", + "Scarlet", + "Platinum", + "Whirlwind", + "Obsidian", + "Ethereal", + "Ghost", + "Spike", + "Dusk", + "Raven", + "Spectral", + "Burning", + "Verdant", + "Copper", + "Velvet", + "Falcon", + "Enigma", + "Glowing", + "Silvered", + "Molten", + "Radiant", + "Astral", + "Wild", + "Flame", + "Amethyst", + "Aurora", + "Shadowy", + "Solar", + "Lunar", + "Whisperwind", + "Fading", + "Titan", + "Dawn", + "Crystalline", + "Jeweled", + "Sylvan", + "Twisted", + "Ebon", + "Thorn", + "Cerulean", + "Halcyon", + "Infernal", + "Storm", + "Eldritch", + "Sapphire", + "Crimson", + "Tranquil", + "Paved", +]; + +const descriptors = [ + "Great", + "Shrouded", + "Sacred", + "Fabled", + "Frosty", + "Winding", + "Echoing", + "Serpentine", + "Breezy", + "Misty", + "Rustic", + "Silent", + "Cobbled", + "Cracked", + "Shaky", + "Obscure", +]; + +const suffixes: Record> = { + roads: { road: 7, route: 3, way: 2, highway: 1 }, + trails: { trail: 4, path: 1, track: 1, pass: 1 }, + searoutes: { "sea route": 5, lane: 2, passage: 1, seaway: 1 }, +}; + +export interface Route { + i: number; + group: "roads" | "trails" | "searoutes"; + feature: number; + points: number[][]; + cells?: number[]; + merged?: boolean; +} + +class RoutesModule { + buildLinks(routes: Route[]): Record> { + const links: Record> = {}; + + for (const { points, i: routeId } of routes) { + const cells = points.map((p) => p[2]); + + for (let i = 0; i < cells.length - 1; i++) { + const cellId = cells[i]; + const nextCellId = cells[i + 1]; + + if (cellId !== nextCellId) { + if (!links[cellId]) links[cellId] = {}; + links[cellId][nextCellId] = routeId; + + if (!links[nextCellId]) links[nextCellId] = {}; + links[nextCellId][cellId] = routeId; + } + } + } + + return links; + } + + private sortBurgsByFeature(burgs: Burg[]) { + const burgsByFeature: Record = {}; + const capitalsByFeature: Record = {}; + const portsByFeature: Record = {}; + + const addBurg = ( + collection: Record, + feature: number, + burg: Burg, + ) => { + if (!collection[feature]) collection[feature] = []; + collection[feature].push(burg); + }; + + for (const burg of burgs) { + if (burg.i && !burg.removed) { + const { feature, capital, port } = burg; + addBurg(burgsByFeature, feature as number, burg); + if (capital) addBurg(capitalsByFeature, feature as number, burg); + if (port) addBurg(portsByFeature, port as number, burg); + } + } + + return { burgsByFeature, capitalsByFeature, portsByFeature }; + } + + // Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation + // this gives us an aproximation of a desired road network, i.e. connections between burgs + // code from https://observablehq.com/@mbostock/urquhart-graph + private calculateUrquhartEdges(points: Point[]) { + const score = (p0: number, p1: number) => + distanceSquared(points[p0], points[p1]); + + const { halfedges, triangles } = Delaunator.from(points); + const n = triangles.length; + + const removed = new Uint8Array(n); + const edges = []; + + for (let e = 0; e < n; e += 3) { + const p0 = triangles[e], + p1 = triangles[e + 1], + p2 = triangles[e + 2]; + + const p01 = score(p0, p1), + p12 = score(p1, p2), + p20 = score(p2, p0); + + removed[ + p20 > p01 && p20 > p12 + ? Math.max(e + 2, halfedges[e + 2]) + : p12 > p01 && p12 > p20 + ? Math.max(e + 1, halfedges[e + 1]) + : Math.max(e, halfedges[e]) + ] = 1; + } + + for (let e = 0; e < n; ++e) { + if (e > halfedges[e] && !removed[e]) { + const t0 = triangles[e]; + const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1]; + edges.push([t0, t1]); + } + } + + return edges; + } + + private createCostEvaluator({ + isWater, + connections, + }: { + isWater: boolean; + connections: Map; + }) { + function getLandPathCost(current: number, next: number) { + if (pack.cells.h[next] < 20) return Infinity; // ignore water cells + + const habitability = biomesData.habitability[pack.cells.biome[next]]; + if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier) + + const distanceCost = distanceSquared( + pack.cells.p[current], + pack.cells.p[next], + ); + const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1]; + const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3]; + const connectionModifier = connections.has(`${current}-${next}`) + ? 0.5 + : 1; + const burgModifier = pack.cells.burg[next] ? 1 : 3; + + const pathCost = + distanceCost * + habitabilityModifier * + heightModifier * + connectionModifier * + burgModifier; + return pathCost; + } + + function getWaterPathCost(current: number, next: number) { + if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells + if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) + return Infinity; // ignore too cold cells + + const distanceCost = distanceSquared( + pack.cells.p[current], + pack.cells.p[next], + ); + const typeModifier = + ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || + ROUTE_TYPE_MODIFIERS.default; + const connectionModifier = connections.has(`${current}-${next}`) + ? 0.5 + : 1; + + const pathCost = distanceCost * typeModifier * connectionModifier; + return pathCost; + } + return isWater ? getWaterPathCost : getLandPathCost; + } + + private getRouteSegments( + pathCells: number[], + connections: Map, + ) { + const segments = []; + let segment = []; + + for (let i = 0; i < pathCells.length; i++) { + const cellId = pathCells[i]; + const nextCellId = pathCells[i + 1]; + const isConnected = + connections.has(`${cellId}-${nextCellId}`) || + connections.has(`${nextCellId}-${cellId}`); + + if (isConnected) { + if (segment.length) { + // segment stepped into existing segment + segment.push(pathCells[i]); + segments.push(segment); + segment = []; + } + continue; + } + + segment.push(pathCells[i]); + } + + if (segment.length > 1) segments.push(segment); + + return segments; + } + + private findPathSegments({ + isWater, + connections, + start, + exit, + }: { + isWater: boolean; + connections: Map; + start: number; + exit: number; + }) { + const getCost = this.createCostEvaluator({ isWater, connections }); + const pathCells = findPath( + start, + (current) => current === exit, + getCost, + pack, + ); + if (!pathCells) return []; + const segments = this.getRouteSegments(pathCells, connections); + return segments; + } + + private generateMainRoads(connections: Map) { + TIME && console.time("generateMainRoads"); + const { capitalsByFeature } = this.sortBurgsByFeature(pack.burgs); + const mainRoads: Route[] = []; + + for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) { + const points = featureCapitals.map((burg) => [burg.x, burg.y] as Point); + const urquhartEdges = this.calculateUrquhartEdges(points); + urquhartEdges.forEach(([fromId, toId]) => { + const start = featureCapitals[fromId].cell; + const exit = featureCapitals[toId].cell; + + const segments = this.findPathSegments({ + isWater: false, + connections, + start, + exit, + }); + for (const segment of segments) { + this.addConnections(segment, connections); + mainRoads.push({ feature: Number(key), cells: segment } as Route); + } + }); + } + + TIME && console.timeEnd("generateMainRoads"); + return mainRoads; + } + + private addConnections(segment: number[], connections: Map) { + for (let i = 0; i < segment.length; i++) { + const cellId = segment[i]; + const nextCellId = segment[i + 1]; + if (nextCellId) { + connections.set(`${cellId}-${nextCellId}`, true); + connections.set(`${nextCellId}-${cellId}`, true); + } + } + } + + private generateTrails(connections: Map) { + TIME && console.time("generateTrails"); + const { burgsByFeature } = this.sortBurgsByFeature(pack.burgs); + const trails: Route[] = []; + + for (const [key, featureBurgs] of Object.entries(burgsByFeature)) { + const points = featureBurgs.map((burg) => [burg.x, burg.y] as Point); + const urquhartEdges = this.calculateUrquhartEdges(points); + urquhartEdges.forEach(([fromId, toId]) => { + const start = featureBurgs[fromId].cell; + const exit = featureBurgs[toId].cell; + + const segments = this.findPathSegments({ + isWater: false, + connections, + start, + exit, + }); + for (const segment of segments) { + this.addConnections(segment, connections); + trails.push({ feature: Number(key), cells: segment } as Route); + } + }); + } + + TIME && console.timeEnd("generateTrails"); + return trails; + } + + private generateSeaRoutes(connections: Map) { + TIME && console.time("generateSeaRoutes"); + const { portsByFeature } = this.sortBurgsByFeature(pack.burgs); + const seaRoutes: Route[] = []; + + for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { + const points = featurePorts.map((burg) => [burg.x, burg.y] as Point); + const urquhartEdges = this.calculateUrquhartEdges(points); + + urquhartEdges.forEach(([fromId, toId]) => { + const start = featurePorts[fromId].cell; + const exit = featurePorts[toId].cell; + const segments = this.findPathSegments({ + isWater: true, + connections, + start, + exit, + }); + for (const segment of segments) { + this.addConnections(segment, connections); + seaRoutes.push({ + feature: Number(featureId), + cells: segment, + } as Route); + } + }); + } + + TIME && console.timeEnd("generateSeaRoutes"); + return seaRoutes; + } + + private preparePointsArray(): Point[] { + const { cells, burgs } = pack; + return cells.p.map(([x, y], cellId) => { + const burgId = cells.burg[cellId]; + if (burgId) return [burgs[burgId].x, burgs[burgId].y]; + return [x, y]; + }); + } + + private getPoints(group: string, cells: number[], points: Point[]) { + const data = cells.map((cellId) => [...points[cellId], cellId]); + + // resolve sharp angles + if (group !== "searoutes") { + for (let i = 1; i < cells.length - 1; i++) { + const cellId = cells[i]; + if (pack.cells.burg[cellId]) continue; + + const [prevX, prevY] = data[i - 1]; + const [currX, currY] = data[i]; + const [nextX, nextY] = data[i + 1]; + + const dAx = prevX - currX; + const dAy = prevY - currY; + const dBx = nextX - currX; + const dBy = nextY - currY; + const angle = Math.abs( + (Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / + Math.PI, + ); + + if (angle < ROUTES_SHARP_ANGLE) { + const middleX = (prevX + nextX) / 2; + const middleY = (prevY + nextY) / 2; + let newX: number, newY: number; + + if (angle < ROUTES_VERY_SHARP_ANGLE) { + newX = rn((currX + middleX * 2) / 3, 2); + newY = rn((currY + middleY * 2) / 3, 2); + } else { + newX = rn((currX + middleX) / 2, 2); + newY = rn((currY + middleY) / 2, 2); + } + + if (findClosestCell(newX, newY, undefined, pack) === cellId) { + data[i] = [newX, newY, cellId]; + points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes + } + } + } + } + + return data; // [[x, y, cell], [x, y, cell]]; + } + + // merge routes so that the last cell of one route is the first cell of the next route + private mergeRoutes(routes: Route[]): Route[] { + let routesMerged = 0; + + for (let i = 0; i < routes.length; i++) { + const thisRoute = routes[i]; + if (thisRoute.merged) continue; + + for (let j = i + 1; j < routes.length; j++) { + const nextRoute = routes[j]; + if (nextRoute.merged) continue; + + if (nextRoute.cells!.at(0) === thisRoute.cells!.at(-1)) { + routesMerged++; + thisRoute.cells = thisRoute.cells!.concat(nextRoute.cells!.slice(1)); + nextRoute.merged = true; + } + } + } + + return routesMerged > 1 ? this.mergeRoutes(routes) : routes; + } + private createRoutesData(routes: Route[], connections: Map) { + const mainRoads = this.generateMainRoads(connections); + const trails = this.generateTrails(connections); + const seaRoutes = this.generateSeaRoutes(connections); + const pointsArray = this.preparePointsArray(); + + for (const { feature, cells, merged } of this.mergeRoutes(mainRoads)) { + if (merged) continue; + const points = this.getPoints("roads", cells!, pointsArray); + routes.push({ i: routes.length, group: "roads", feature, points }); + } + + for (const { feature, cells, merged } of this.mergeRoutes(trails)) { + if (merged) continue; + const points = this.getPoints("trails", cells!, pointsArray); + routes.push({ i: routes.length, group: "trails", feature, points }); + } + + for (const { feature, cells, merged } of this.mergeRoutes(seaRoutes)) { + if (merged) continue; + const points = this.getPoints("searoutes", cells!, pointsArray); + routes.push({ i: routes.length, group: "searoutes", feature, points }); + } + + return routes; + } + + generate(lockedRoutes: Route[] = []) { + const connections = new Map(); + lockedRoutes.forEach((route: Route) => { + this.addConnections( + route.points.map((p) => p[2]), + connections, + ); + }); + + pack.routes = this.createRoutesData(lockedRoutes, connections); + pack.cells.routes = this.buildLinks(pack.routes); + } + + // utility functions + isConnected(cellId: number): boolean { + const routes = pack.cells.routes; + return routes[cellId] && Object.keys(routes[cellId]).length > 0; + } + + getNextId() { + return pack.routes.length + ? Math.max(...pack.routes.map((r) => r.i)) + 1 + : 0; + } + + // connect cell with routes system by land + connect(cellId: number): Route | undefined { + const getCost = this.createCostEvaluator({ + isWater: false, + connections: new Map(), + }); + const isExit = (c: number) => isLand(c, pack) && this.isConnected(c); + const pathCells = findPath(cellId, isExit, getCost, pack); + if (!pathCells) return; + + const pointsArray = this.preparePointsArray(); + const points = this.getPoints("trails", pathCells, pointsArray); + const feature = pack.cells.f[cellId]; + const routeId = this.getNextId(); + const newRoute = { i: routeId, group: "trails", feature, points }; + pack.routes.push(newRoute as Route); + + const addConnection = (from: number, to: number, routeId: number) => { + const routes = pack.cells.routes; + + if (!routes[from]) routes[from] = {}; + routes[from][to] = routeId; + + if (!routes[to]) routes[to] = {}; + routes[to][from] = routeId; + }; + + for (let i = 0; i < pathCells.length; i++) { + const currentCell = pathCells[i]; + const nextCellId = pathCells[i + 1]; + if (nextCellId) addConnection(currentCell, nextCellId, routeId); + } + + return newRoute as Route; + } + + areConnected(from: number, to: number): boolean { + const routeId = pack.cells.routes[from]?.[to]; + return routeId !== undefined; + } + + getRoute(from: number, to: number) { + const routeId = pack.cells.routes[from]?.[to]; + if (routeId === undefined) return null; + + const route = pack.routes.find((route) => route.i === routeId); + if (!route) return null; + + return route; + } + + hasRoad(cellId: number): boolean { + const connections = pack.cells.routes[cellId]; + if (!connections) return false; + + return Object.values(connections).some((routeId) => { + const route = pack.routes.find((route) => route.i === routeId); + if (!route) return false; + return route.group === "roads"; + }); + } + + isCrossroad(cellId: number): boolean { + const connections = pack.cells.routes[cellId]; + if (!connections) return false; + if (Object.keys(connections).length > 3) return true; + const roadConnections = Object.values(connections).filter((routeId) => { + const route = pack.routes.find((route) => route.i === routeId); + return route?.group === "roads"; + }); + return roadConnections.length > 2; + } + + remove(route: Route) { + const routes = pack.cells.routes; + + for (const point of route.points) { + const from = point[2]; + if (!routes[from]) continue; + + for (const [to, routeId] of Object.entries(routes[from])) { + if (routeId === route.i) { + delete routes[from][parseInt(to, 10)]; + delete routes[parseInt(to, 10)][from]; + } + } + } + + pack.routes = pack.routes.filter((r) => r.i !== route.i); + viewbox.select(`#route${route.i}`).remove(); + } + + getConnectivityRate(cellId: number): number { + const connections = pack.cells.routes[cellId]; + if (!connections) return 0; + + const connectivityRateMap = { + roads: 0.2, + trails: 0.1, + searoutes: 0.2, + default: 0.1, + }; + + const connectivity = Object.values(connections).reduce((acc, routeId) => { + const route = pack.routes.find((route) => route.i === routeId); + if (!route) return acc; + const rate = + connectivityRateMap[route.group] || connectivityRateMap.default; + return acc + rate; + }, 0.8); + + return connectivity; + } + + generateName({ + group, + points, + }: { + group: string; + points: number[][]; + }): string { + if (points.length < 4) return "Unnamed route segment"; + + function getBurgName() { + const priority = [ + points.at(-1), + points.at(0), + points.slice(1, -1).reverse(), + ]; + for (const [_x, _y, cellId] of priority as [number, number, number][]) { + const burgId = pack.cells.burg[cellId as number]; + if (burgId) return getAdjective(pack.burgs[burgId].name!); + } + return null; + } + + const model = rw(models[group]); + const suffix = rw(suffixes[group]); + + const burgName = getBurgName(); + if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`; + if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`; + if (model === "the_descriptor_prefix_suffix") + return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`; + if (model === "the_descriptor_burg_suffix" && burgName) + return `The ${ra(descriptors)} ${burgName} ${suffix}`; + return "Unnamed route"; + } + + getPath({ group, points }: { group: string; points: number[][] }): string { + const lineGen = line(); + const ROUTE_CURVES: Record = { + roads: curveCatmullRom.alpha(0.1), + trails: curveCatmullRom.alpha(0.1), + searoutes: curveCatmullRom.alpha(0.5), + default: curveCatmullRom.alpha(0.1), + }; + lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default); + const path = round(lineGen(points.map((p) => [p[0], p[1]])) as string, 1); + return path; + } + + getLength(routeId: number): number { + const path = routes.select(`#route${routeId}`).node() as SVGPathElement; + return path.getTotalLength(); + } +} + +window.Routes = new RoutesModule(); diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 26bfab5d..d193ead5 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -2,6 +2,7 @@ import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; import type { River } from "../modules/river-generator"; +import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; type TypedArray = @@ -21,6 +22,7 @@ export interface PackedGraph { p: [number, number][]; // cell polygon points b: boolean[]; // cell is on border h: TypedArray; // cell heights + /** Terrain type */ t: TypedArray; // cell terrain types r: TypedArray; // river id passing through cell f: TypedArray; // feature id occupying cell @@ -37,6 +39,7 @@ export interface PackedGraph { religion: TypedArray; // cell religion id state: number[]; // cell state id area: TypedArray; // cell area + routes: Record>; }; vertices: { i: number[]; // vertex indices @@ -51,5 +54,6 @@ export interface PackedGraph { burgs: Burg[]; states: State[]; cultures: Culture[]; + routes: Route[]; religions: any[]; } diff --git a/src/types/global.ts b/src/types/global.ts index fc4cfba9..d0e7fe70 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -30,6 +30,8 @@ declare global { var rivers: Selection; var oceanLayers: Selection; var emblems: Selection; + var viewbox: Selection; + var routes: Selection; var biomesData: { i: number[]; name: string[]; diff --git a/tests/e2e/layers.spec.ts-snapshots/routes.html b/tests/e2e/layers.spec.ts-snapshots/routes.html index 16e6f5ec..5c7688c3 100644 --- a/tests/e2e/layers.spec.ts-snapshots/routes.html +++ b/tests/e2e/layers.spec.ts-snapshots/routes.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 0f19902a56ef896d621bd601739ad8e1e83f6534 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Sun, 1 Feb 2026 22:16:04 +0100 Subject: [PATCH 18/24] refactor: migrate provinces generator to new module structure (#1295) * refactor: migrate provinces generator to new module structure * fix: after merge fixes of state * refactor: fixed a bug so had to update tests --- public/modules/provinces-generator.js | 257 ------------ src/index.html | 1 - src/modules/index.ts | 1 + src/modules/provinces-generator.ts | 393 ++++++++++++++++++ src/modules/states-generator.ts | 1 + src/types/PackedGraph.ts | 3 + .../e2e/layers.spec.ts-snapshots/borders.html | 2 +- 7 files changed, 399 insertions(+), 259 deletions(-) delete mode 100644 public/modules/provinces-generator.js create mode 100644 src/modules/provinces-generator.ts diff --git a/public/modules/provinces-generator.js b/public/modules/provinces-generator.js deleted file mode 100644 index 3276fdf0..00000000 --- a/public/modules/provinces-generator.js +++ /dev/null @@ -1,257 +0,0 @@ -"use strict"; - -window.Provinces = (function () { - const forms = { - Monarchy: {County: 22, Earldom: 6, Shire: 2, Landgrave: 2, Margrave: 2, Barony: 2, Captaincy: 1, Seneschalty: 1}, - Republic: {Province: 6, Department: 2, Governorate: 2, District: 1, Canton: 1, Prefecture: 1}, - Theocracy: {Parish: 3, Deanery: 1}, - Union: {Province: 1, State: 1, Canton: 1, Republic: 1, County: 1, Council: 1}, - Anarchy: {Council: 1, Commune: 1, Community: 1, Tribe: 1}, - Wild: {Territory: 10, Land: 5, Region: 2, Tribe: 1, Clan: 1, Dependency: 1, Area: 1} - }; - - const generate = (regenerate = false, regenerateLockedStates = false) => { - TIME && console.time("generateProvinces"); - const localSeed = regenerate ? generateSeed() : seed; - Math.random = aleaPRNG(localSeed); - - const {cells, states, burgs} = pack; - const provinces = [0]; // 0 index is reserved for "no province" - const provinceIds = new Uint16Array(cells.i.length); - - const isProvinceLocked = province => province.lock || (!regenerateLockedStates && states[province.state]?.lock); - const isProvinceCellLocked = cell => provinceIds[cell] && isProvinceLocked(provinces[provinceIds[cell]]); - - if (regenerate) { - pack.provinces.forEach(province => { - if (!province.i || province.removed || !isProvinceLocked(province)) return; - - const newId = provinces.length; - for (const i of cells.i) { - if (cells.province[i] === province.i) provinceIds[i] = newId; - } - - province.i = newId; - provinces.push(province); - }); - } - - const provincesRatio = +byId("provincesRatio").value; - const max = provincesRatio == 100 ? 1000 : gauss(20, 5, 5, 100) * provincesRatio ** 0.5; // max growth - - // generate provinces for selected burgs - states.forEach(s => { - s.provinces = []; - if (!s.i || s.removed) return; - if (provinces.length) s.provinces = provinces.filter(p => p.state === s.i).map(p => p.i); // locked provinces ids - if (s.lock && !regenerateLockedStates) return; // don't regenerate provinces of a locked state - - const stateBurgs = burgs - .filter(b => b.state === s.i && !b.removed && !provinceIds[b.cell]) - .sort((a, b) => b.population * gauss(1, 0.2, 0.5, 1.5, 3) - a.population) - .sort((a, b) => b.capital - a.capital); - if (stateBurgs.length < 2) return; // at least 2 provinces are required - - const provincesNumber = Math.max(Math.ceil((stateBurgs.length * provincesRatio) / 100), 2); - const form = Object.assign({}, forms[s.form]); - - for (let i = 0; i < provincesNumber; i++) { - const provinceId = provinces.length; - const center = stateBurgs[i].cell; - const burg = stateBurgs[i]; - const c = stateBurgs[i].culture; - const nameByBurg = P(0.5); - const name = nameByBurg ? stateBurgs[i].name : Names.getState(Names.getCultureShort(c), c); - const formName = rw(form); - form[formName] += 10; - const fullName = name + " " + formName; - const color = getMixedColor(s.color); - const kinship = nameByBurg ? 0.8 : 0.4; - const type = Burgs.getType(center, burg.port); - const coa = COA.generate(stateBurgs[i].coa, kinship, null, type); - coa.shield = COA.getShield(c, s.i); - - s.provinces.push(provinceId); - provinces.push({i: provinceId, state: s.i, center, burg: burg.i, name, formName, fullName, color, coa}); - } - }); - - // expand generated provinces - const queue = new FlatQueue(); - const cost = []; - - provinces.forEach(p => { - if (!p.i || p.removed || isProvinceLocked(p)) return; - provinceIds[p.center] = p.i; - queue.push({e: p.center, province: p.i, state: p.state, p: 0}, 0); - cost[p.center] = 1; - }); - - while (queue.length) { - const {e, p, province, state} = queue.pop(); - - cells.c[e].forEach(e => { - if (isProvinceCellLocked(e)) return; // do not overwrite cell of locked provinces - - const land = cells.h[e] >= 20; - if (!land && !cells.t[e]) return; // cannot pass deep ocean - if (land && cells.state[e] !== state) return; - const evevation = cells.h[e] >= 70 ? 100 : cells.h[e] >= 50 ? 30 : cells.h[e] >= 20 ? 10 : 100; - const totalCost = p + evevation; - - if (totalCost > max) return; - if (!cost[e] || totalCost < cost[e]) { - if (land) provinceIds[e] = province; // assign province to a cell - cost[e] = totalCost; - queue.push({e, province, state, p: totalCost}, totalCost); - } - }); - } - - // justify provinces shapes a bit - for (const i of cells.i) { - if (cells.burg[i]) continue; // do not overwrite burgs - if (isProvinceCellLocked(i)) continue; // do not overwrite cell of locked provinces - - const neibs = cells.c[i] - .filter(c => cells.state[c] === cells.state[i] && !isProvinceCellLocked(c)) - .map(c => provinceIds[c]); - const adversaries = neibs.filter(c => c !== provinceIds[i]); - if (adversaries.length < 2) continue; - - const buddies = neibs.filter(c => c === provinceIds[i]).length; - if (buddies.length > 2) continue; - - const competitors = adversaries.map(p => adversaries.reduce((s, v) => (v === p ? s + 1 : s), 0)); - const max = d3.max(competitors); - if (buddies >= max) continue; - - provinceIds[i] = adversaries[competitors.indexOf(max)]; - } - - // add "wild" provinces if some cells don't have a province assigned - const noProvince = Array.from(cells.i).filter(i => cells.state[i] && !provinceIds[i]); // cells without province assigned - states.forEach(s => { - if (!s.i || s.removed) return; - if (s.lock && !regenerateLockedStates) return; - if (!s.provinces.length) return; - - const coreProvinceNames = s.provinces.map(p => provinces[p]?.name); - const colonyNamePool = [s.name, ...coreProvinceNames].filter(name => name && !/new/i.test(name)); - const getColonyName = () => { - if (colonyNamePool.length < 1) return null; - - const index = rand(colonyNamePool.length - 1); - const spliced = colonyNamePool.splice(index, 1); - return spliced[0] ? `New ${spliced[0]}` : null; - }; - - let stateNoProvince = noProvince.filter(i => cells.state[i] === s.i && !provinceIds[i]); - while (stateNoProvince.length) { - // add new province - const provinceId = provinces.length; - const burgCell = stateNoProvince.find(i => cells.burg[i]); - const center = burgCell ? burgCell : stateNoProvince[0]; - const burg = burgCell ? cells.burg[burgCell] : 0; - provinceIds[center] = provinceId; - - // expand province - const cost = []; - cost[center] = 1; - queue.push({e: center, p: 0}, 0); - while (queue.length) { - const {e, p} = queue.pop(); - - cells.c[e].forEach(nextCellId => { - if (provinceIds[nextCellId]) return; - const land = cells.h[nextCellId] >= 20; - if (cells.state[nextCellId] && cells.state[nextCellId] !== s.i) return; - const ter = land ? (cells.state[nextCellId] === s.i ? 3 : 20) : cells.t[nextCellId] ? 10 : 30; - const totalCost = p + ter; - - if (totalCost > max) return; - if (!cost[nextCellId] || totalCost < cost[nextCellId]) { - if (land && cells.state[nextCellId] === s.i) provinceIds[nextCellId] = provinceId; // assign province to a cell - cost[nextCellId] = totalCost; - queue.push({e: nextCellId, p: totalCost}, totalCost); - } - }); - } - - // generate "wild" province name - const c = cells.culture[center]; - const f = pack.features[cells.f[center]]; - const color = getMixedColor(s.color); - - const provCells = stateNoProvince.filter(i => provinceIds[i] === provinceId); - const singleIsle = provCells.length === f.cells && !provCells.find(i => cells.f[i] !== f.i); - const isleGroup = !singleIsle && !provCells.find(i => pack.features[cells.f[i]].group !== "isle"); - const colony = !singleIsle && !isleGroup && P(0.5) && !isPassable(s.center, center); - - const name = (() => { - const colonyName = colony && P(0.8) && getColonyName(); - if (colonyName) return colonyName; - if (burgCell && P(0.5)) return burgs[burg].name; - return Names.getState(Names.getCultureShort(c), c); - })(); - - const formName = (() => { - if (singleIsle) return "Island"; - if (isleGroup) return "Islands"; - if (colony) return "Colony"; - return rw(forms["Wild"]); - })(); - - const fullName = name + " " + formName; - - const dominion = colony ? P(0.95) : singleIsle || isleGroup ? P(0.7) : P(0.3); - const kinship = dominion ? 0 : 0.4; - const type = Burgs.getType(center, burgs[burg]?.port); - const coa = COA.generate(s.coa, kinship, dominion, type); - coa.shield = COA.getShield(c, s.i); - - provinces.push({i: provinceId, state: s.i, center, burg, name, formName, fullName, color, coa}); - s.provinces.push(provinceId); - - // check if there is a land way within the same state between two cells - function isPassable(from, to) { - if (cells.f[from] !== cells.f[to]) return false; // on different islands - const passableQueue = [from], - used = new Uint8Array(cells.i.length), - state = cells.state[from]; - while (passableQueue.length) { - const current = passableQueue.pop(); - if (current === to) return true; // way is found - cells.c[current].forEach(c => { - if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) return; - passableQueue.push(c); - used[c] = 1; - }); - } - return false; // way is not found - } - - // re-check - stateNoProvince = noProvince.filter(i => cells.state[i] === s.i && !provinceIds[i]); - } - }); - - cells.province = provinceIds; - pack.provinces = provinces; - - TIME && console.timeEnd("generateProvinces"); - }; - - // calculate pole of inaccessibility for each province - const getPoles = () => { - const getType = cellId => pack.cells.province[cellId]; - const poles = getPolesOfInaccessibility(pack, getType); - - pack.provinces.forEach(province => { - if (!province.i || province.removed) return; - province.pole = poles[province.i] || [0, 0]; - }); - }; - - return {generate, getPoles}; -})(); diff --git a/src/index.html b/src/index.html index f4b605b2..3f46d62c 100644 --- a/src/index.html +++ b/src/index.html @@ -8494,7 +8494,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index a3dbe219..f8fa62ef 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -10,3 +10,4 @@ import "./biomes"; import "./cultures-generator"; import "./routes-generator"; import "./states-generator"; +import "./provinces-generator"; diff --git a/src/modules/provinces-generator.ts b/src/modules/provinces-generator.ts new file mode 100644 index 00000000..68d46f33 --- /dev/null +++ b/src/modules/provinces-generator.ts @@ -0,0 +1,393 @@ +import Alea from "alea"; +import { max } from "d3"; +import { + byId, + gauss, + generateSeed, + getMixedColor, + getPolesOfInaccessibility, + P, + rand, + rw, +} from "../utils"; + +declare global { + var Provinces: ProvinceModule; +} + +export interface Province { + i: number; + removed?: boolean; + state: number; + lock?: boolean; + center: number; + burg: number; + name: string; + formName: string; + fullName: string; + color: string; + coa: any; + pole?: [number, number]; +} + +class ProvinceModule { + forms: Record> = { + Monarchy: { + County: 22, + Earldom: 6, + Shire: 2, + Landgrave: 2, + Margrave: 2, + Barony: 2, + Captaincy: 1, + Seneschalty: 1, + }, + Republic: { + Province: 6, + Department: 2, + Governorate: 2, + District: 1, + Canton: 1, + Prefecture: 1, + }, + Theocracy: { Parish: 3, Deanery: 1 }, + Union: { + Province: 1, + State: 1, + Canton: 1, + Republic: 1, + County: 1, + Council: 1, + }, + Anarchy: { Council: 1, Commune: 1, Community: 1, Tribe: 1 }, + Wild: { + Territory: 10, + Land: 5, + Region: 2, + Tribe: 1, + Clan: 1, + Dependency: 1, + Area: 1, + }, + }; + + generate(regenerate = false, regenerateLockedStates = false) { + TIME && console.time("generateProvinces"); + const localSeed = regenerate ? generateSeed() : seed; + Math.random = Alea(localSeed); + + const { cells, states, burgs } = pack; + const provinces: Province[] = [0 as unknown as Province]; // 0 index is reserved for "no province" + const provinceIds = new Uint16Array(cells.i.length); + + const isProvinceLocked = (province: Province) => + province.lock || + (!regenerateLockedStates && states[province.state]?.lock); + const isProvinceCellLocked = (cell: number) => + provinceIds[cell] && isProvinceLocked(provinces[provinceIds[cell]]); + + if (regenerate) { + pack.provinces.forEach((province) => { + if (!province.i || province.removed || !isProvinceLocked(province)) + return; + + const newId = provinces.length; + for (const i of cells.i) { + if (cells.province[i] === province.i) provinceIds[i] = newId; + } + + province.i = newId; + provinces.push(province); + }); + } + + const provincesRatio = (byId("provincesRatio") as HTMLInputElement) + .valueAsNumber; + const maxGrowth = + provincesRatio === 100 + ? 1000 + : gauss(20, 5, 5, 100) * provincesRatio ** 0.5; // max growth + + // generate provinces for selected burgs + states.forEach((s) => { + s.provinces = []; + if (!s.i || s.removed) return; + if (provinces.length) + s.provinces = provinces.filter((p) => p.state === s.i).map((p) => p.i); // locked provinces ids + if (s.lock && !regenerateLockedStates) return; // don't regenerate provinces of a locked state + + const stateBurgs = burgs + .filter((b) => b.state === s.i && !b.removed && !provinceIds[b.cell]) // burgs in this state without province assigned + .sort( + (a, b) => b.population! * gauss(1, 0.2, 0.5, 1.5, 3) - a.population!, + ) // biggest population first + .sort((a, b) => b.capital! - a.capital!); // capitals first + if (stateBurgs.length < 2) return; // at least 2 provinces are required + + const provincesNumber = Math.max( + Math.ceil((stateBurgs.length * provincesRatio) / 100), + 2, + ); + const form = Object.assign({}, this.forms[s.form!]); + + for (let i = 0; i < provincesNumber; i++) { + const provinceId = provinces.length; + const center = stateBurgs[i].cell; + const burg = stateBurgs[i]; + const c = stateBurgs[i].culture!; + const nameByBurg = P(0.5); + const name = nameByBurg + ? stateBurgs[i].name! + : Names.getState(Names.getCultureShort(c), c); + const formName = rw(form); + form[formName] += 10; + const fullName = `${name} ${formName}`; + const color = getMixedColor(s.color!); + const kinship = nameByBurg ? 0.8 : 0.4; + const type = Burgs.getType(center, burg.port); + const coa = COA.generate(stateBurgs[i].coa, kinship, null, type); + coa.shield = COA.getShield(c, s.i); + + s.provinces.push(provinceId); + provinces.push({ + i: provinceId, + state: s.i, + center, + burg: burg.i!, + name, + formName, + fullName, + color, + coa, + }); + } + }); + + // expand generated provinces + const queue = new FlatQueue(); + const cost: number[] = []; + + provinces.forEach((p) => { + if (!p.i || p.removed || isProvinceLocked(p)) return; + provinceIds[p.center] = p.i; + queue.push({ e: p.center, province: p.i, state: p.state, p: 0 }, 0); + cost[p.center] = 1; + }); + + while (queue.length) { + const { e, p, province, state } = queue.pop(); + + cells.c[e].forEach((e) => { + if (isProvinceCellLocked(e)) return; // do not overwrite cell of locked provinces + + const land = cells.h[e] >= 20; + if (!land && !cells.t[e]) return; // cannot pass deep ocean + if (land && cells.state[e] !== state) return; + const evevation = + cells.h[e] >= 70 + ? 100 + : cells.h[e] >= 50 + ? 30 + : cells.h[e] >= 20 + ? 10 + : 100; + const totalCost = p + evevation; + + if (totalCost > maxGrowth) return; + if (!cost[e] || totalCost < cost[e]) { + if (land) provinceIds[e] = province; // assign province to a cell + cost[e] = totalCost; + queue.push({ e, province, state, p: totalCost }, totalCost); + } + }); + } + + // justify provinces shapes a bit + for (const i of cells.i) { + if (cells.burg[i]) continue; // do not overwrite burgs + if (isProvinceCellLocked(i)) continue; // do not overwrite cell of locked provinces + + const neibs = cells.c[i] + .filter( + (c) => cells.state[c] === cells.state[i] && !isProvinceCellLocked(c), + ) + .map((c) => provinceIds[c]); + const adversaries = neibs.filter((c) => c !== provinceIds[i]); + if (adversaries.length < 2) continue; + + const buddies = neibs.filter((c) => c === provinceIds[i]).length; + if (buddies > 2) continue; + + const competitors = adversaries.map((p) => + adversaries.reduce((s, v) => (v === p ? s + 1 : s), 0), + ); + const maxBuddies = max(competitors) as number; + if (buddies >= maxBuddies) continue; + + provinceIds[i] = adversaries[competitors.indexOf(maxBuddies)]; + } + + // add "wild" provinces if some cells don't have a province assigned + const noProvince = Array.from(cells.i).filter( + (i) => cells.state[i] && !provinceIds[i], + ); // cells without province assigned + states.forEach((s) => { + if (!s.i || s.removed) return; + if (s.lock && !regenerateLockedStates) return; + if (!s.provinces?.length) return; + + const coreProvinceNames = s.provinces.map((p) => provinces[p]?.name); + const colonyNamePool = [s.name, ...coreProvinceNames].filter( + (name) => name && !/new/i.test(name), + ); + const getColonyName = () => { + if (colonyNamePool.length < 1) return null; + + const index = rand(colonyNamePool.length - 1); + const spliced = colonyNamePool.splice(index, 1); + return spliced[0] ? `New ${spliced[0]}` : null; + }; + + let stateNoProvince = noProvince.filter( + (i) => cells.state[i] === s.i && !provinceIds[i], + ); + while (stateNoProvince.length) { + // add new province + const provinceId = provinces.length; + const burgCell = stateNoProvince.find((i) => cells.burg[i]); + const center = burgCell ? burgCell : stateNoProvince[0]; + const burg = burgCell ? cells.burg[burgCell] : 0; + provinceIds[center] = provinceId; + + // expand province + const cost: number[] = []; + cost[center] = 1; + queue.push({ e: center, p: 0 }, 0); + while (queue.length) { + const { e, p } = queue.pop(); + + cells.c[e].forEach((nextCellId) => { + if (provinceIds[nextCellId]) return; + const land = cells.h[nextCellId] >= 20; + if (cells.state[nextCellId] && cells.state[nextCellId] !== s.i) + return; + const ter = land + ? cells.state[nextCellId] === s.i + ? 3 + : 20 + : cells.t[nextCellId] + ? 10 + : 30; + const totalCost = p + ter; + + if (totalCost > maxGrowth) return; + if (!cost[nextCellId] || totalCost < cost[nextCellId]) { + if (land && cells.state[nextCellId] === s.i) + provinceIds[nextCellId] = provinceId; // assign province to a cell + cost[nextCellId] = totalCost; + queue.push({ e: nextCellId, p: totalCost }, totalCost); + } + }); + } + + // generate "wild" province name + const c = cells.culture[center]; + const f = pack.features[cells.f[center]]; + const color = getMixedColor(s.color!); + + const provCells = stateNoProvince.filter( + (i) => provinceIds[i] === provinceId, + ); + const singleIsle = + provCells.length === f.cells && + !provCells.find((i) => cells.f[i] !== f.i); + const isleGroup = + !singleIsle && + !provCells.find((i) => pack.features[cells.f[i]].group !== "isle"); + const colony = + !singleIsle && !isleGroup && P(0.5) && !isPassable(s.center, center); + + const name = (() => { + const colonyName = colony && P(0.8) && getColonyName(); + if (colonyName) return colonyName; + if (burgCell && P(0.5)) return burgs[burg].name; + return Names.getState(Names.getCultureShort(c), c); + })(); + + const formName = (() => { + if (singleIsle) return "Island"; + if (isleGroup) return "Islands"; + if (colony) return "Colony"; + return rw(this.forms["Wild"]); + })(); + + const fullName = `${name} ${formName}`; + + const dominion = colony + ? P(0.95) + : singleIsle || isleGroup + ? P(0.7) + : P(0.3); + const kinship = dominion ? 0 : 0.4; + const type = Burgs.getType(center, burgs[burg]?.port); + const coa = COA.generate(s.coa, kinship, dominion, type); + coa.shield = COA.getShield(c, s.i); + + provinces.push({ + i: provinceId, + state: s.i, + center, + burg, + name: name!, + formName, + fullName, + color, + coa, + }); + s.provinces.push(provinceId); + + // check if there is a land way within the same state between two cells + function isPassable(from: number, to: number) { + if (cells.f[from] !== cells.f[to]) return false; // on different islands + const passableQueue = [from], + used = new Uint8Array(cells.i.length), + state = cells.state[from]; + while (passableQueue.length) { + const current = passableQueue.pop() as number; + if (current === to) return true; // way is found + cells.c[current].forEach((c) => { + if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) + return; + passableQueue.push(c); + used[c] = 1; + }); + } + return false; // way is not found + } + + // re-check + stateNoProvince = noProvince.filter( + (i) => cells.state[i] === s.i && !provinceIds[i], + ); + } + }); + + cells.province = provinceIds; + pack.provinces = provinces; + + TIME && console.timeEnd("generateProvinces"); + } + + // calculate pole of inaccessibility for each province + getPoles() { + const getType = (cellId: number) => pack.cells.province[cellId]; + const poles = getPolesOfInaccessibility(pack, getType); + + pack.provinces.forEach((province) => { + if (!province.i || province.removed) return; + province.pole = poles[province.i] || [0, 0]; + }); + } +} + +window.Provinces = new ProvinceModule(); diff --git a/src/modules/states-generator.ts b/src/modules/states-generator.ts index cb13dd76..d577c092 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; + provinces?: number[]; } class StatesModule { diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index d193ead5..33a31bd7 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -1,6 +1,7 @@ import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; +import type { Province } from "../modules/provinces-generator"; import type { River } from "../modules/river-generator"; import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; @@ -39,6 +40,7 @@ export interface PackedGraph { religion: TypedArray; // cell religion id state: number[]; // cell state id area: TypedArray; // cell area + province: TypedArray; // cell province id routes: Record>; }; vertices: { @@ -56,4 +58,5 @@ export interface PackedGraph { cultures: Culture[]; routes: Route[]; religions: any[]; + provinces: Province[]; } diff --git a/tests/e2e/layers.spec.ts-snapshots/borders.html b/tests/e2e/layers.spec.ts-snapshots/borders.html index 6e5c5003..47d6122a 100644 --- a/tests/e2e/layers.spec.ts-snapshots/borders.html +++ b/tests/e2e/layers.spec.ts-snapshots/borders.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From e8b0b19ff0753f7f1d24f3e3ae72497af68828fb Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Sun, 1 Feb 2026 22:18:05 +0100 Subject: [PATCH 19/24] feat: show total land percentage in biomes editor footer (#1301) * feat: show total land percentage in biomes editor footer * feat: update version to 1.112.0 in versioning.js and biomes-editor.js --- public/modules/ui/biomes-editor.js | 6 ++++++ public/versioning.js | 2 +- src/index.html | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/public/modules/ui/biomes-editor.js b/public/modules/ui/biomes-editor.js index 8c50993d..125aa0da 100644 --- a/public/modules/ui/biomes-editor.js +++ b/public/modules/ui/biomes-editor.js @@ -136,11 +136,13 @@ function editBiomes() { body.innerHTML = lines; // update footer + const totalMapArea = getArea(d3.sum(pack.cells.area)); biomesFooterBiomes.innerHTML = body.querySelectorAll(":scope > div").length; biomesFooterCells.innerHTML = pack.cells.h.filter(h => h >= 20).length; biomesFooterArea.innerHTML = si(totalArea) + unit; biomesFooterPopulation.innerHTML = si(totalPopulation); biomesFooterArea.dataset.area = totalArea; + biomesFooterArea.dataset.mapArea = totalMapArea; biomesFooterPopulation.dataset.population = totalPopulation; // add listeners @@ -255,6 +257,7 @@ function editBiomes() { body.dataset.type = "percentage"; const totalCells = +biomesFooterCells.innerHTML; const totalArea = +biomesFooterArea.dataset.area; + const totalMapArea = +biomesFooterArea.dataset.mapArea; const totalPopulation = +biomesFooterPopulation.dataset.population; body.querySelectorAll(":scope> div").forEach(function (el) { @@ -262,6 +265,9 @@ function editBiomes() { el.querySelector(".biomeArea").innerHTML = rn((+el.dataset.area / totalArea) * 100) + "%"; el.querySelector(".biomePopulation").innerHTML = rn((+el.dataset.population / totalPopulation) * 100) + "%"; }); + + // update footer to show land percentage of total map + biomesFooterArea.innerHTML = rn((totalArea / totalMapArea) * 100) + "%"; } else { body.dataset.type = "absolute"; biomesEditorAddLines(); diff --git a/public/versioning.js b/public/versioning.js index 8069c818..fc81870d 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.111.0"; +const VERSION = "1.112.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index 3f46d62c..b1c83815 100644 --- a/src/index.html +++ b/src/index.html @@ -8518,7 +8518,7 @@ - + From 3ba83385084f89fdd7c848d0658e32f890d5bd50 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Mon, 2 Feb 2026 11:32:08 +0100 Subject: [PATCH 20/24] refactor: migrate renderers to ts (#1296) * refactor: migrate renderers to ts * fix: copilot review --- 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 | 102 ++++ .../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 | 2 + src/types/global.ts | 32 +- src/utils/graphUtils.ts | 2 +- 31 files changed, 2094 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 b1c83815..641f259b 100644 --- a/src/index.html +++ b/src/index.html @@ -8490,6 +8490,7 @@ + @@ -8560,19 +8561,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 d577c092..dec2445b 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[]; provinces?: number[]; } 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..a0e82b59 --- /dev/null +++ b/src/renderers/draw-features.ts @@ -0,0 +1,102 @@ +import { curveBasisClosed, line, select } from "d3"; +import type { PackedGraphFeature } from "../modules/features"; +import { clipPoly, round } from "../utils"; + +declare global { + var drawFeatures: () => void; + var simplify: ( + points: [number, number][], + tolerance: number, + highestQuality?: boolean, + ) => [number, number][]; +} + +interface FeaturesHtml { + paths: string[]; + landMask: string[]; + waterMask: string[]; + coastline: { [key: string]: string[] }; + lakes: { [key: string]: string[] }; +} + +const featuresRenderer = (): void => { + TIME && console.time("drawFeatures"); + + const html: FeaturesHtml = { + paths: [], + landMask: [], + waterMask: [''], + 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, 1); + + 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 33a31bd7..5e003af1 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -58,5 +58,7 @@ export interface PackedGraph { cultures: Culture[]; routes: Route[]; religions: any[]; + ice: any[]; + markers: any[]; provinces: Province[]; } diff --git a/src/types/global.ts b/src/types/global.ts index d0e7fe70..2272196d 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,27 @@ 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 defs: Selection; + var coastline: Selection; + var lakes: 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 viewbox: Selection; var routes: Selection; var biomesData: { @@ -44,13 +63,17 @@ declare global { }; var COA: any; var notes: any[]; + var style: { + burgLabels: { [key: string]: { [key: string]: string } }; + burgIcons: { [key: string]: { [key: string]: string } }; + anchors: { [key: string]: { [key: string]: string } }; + [key: string]: any; + }; var layerIsOn: (layerId: string) => boolean; var drawRoute: (route: any) => void; - var drawBurgIcon: (burg: any) => void; - var drawBurgLabel: (burg: any) => void; - var removeBurgIcon: (burg: any) => void; - var removeBurgLabel: (burg: any) => void; + var invokeActiveZooming: () => void; + var COArenderer: { trigger: (id: string, coa: any) => void }; var FlatQueue: any; var tip: ( @@ -61,4 +84,5 @@ declare global { var locked: (settingId: string) => boolean; var unlock: (settingId: string) => void; var $: (selector: any) => any; + var scale: number; } 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); From 844fc15891e139e17a3b5da9f8bd86a9df86edb1 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Mon, 2 Feb 2026 14:53:59 +0100 Subject: [PATCH 21/24] refactor: migrate religions (#1299) * refactor: migrate religions * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/modules/religions-generator.js | 921 ------------------- src/index.html | 1 - src/modules/index.ts | 1 + src/modules/religions-generator.ts | 1168 +++++++++++++++++++++++++ src/types/global.ts | 1 + 5 files changed, 1170 insertions(+), 922 deletions(-) delete mode 100644 public/modules/religions-generator.js create mode 100644 src/modules/religions-generator.ts diff --git a/public/modules/religions-generator.js b/public/modules/religions-generator.js deleted file mode 100644 index 527a187c..00000000 --- a/public/modules/religions-generator.js +++ /dev/null @@ -1,921 +0,0 @@ -"use strict"; - -window.Religions = (function () { - // name generation approach and relative chance to be selected - const approach = { - Number: 1, - Being: 3, - Adjective: 5, - "Color + Animal": 5, - "Adjective + Animal": 5, - "Adjective + Being": 5, - "Adjective + Genitive": 1, - "Color + Being": 3, - "Color + Genitive": 3, - "Being + of + Genitive": 2, - "Being + of the + Genitive": 1, - "Animal + of + Genitive": 1, - "Adjective + Being + of + Genitive": 2, - "Adjective + Animal + of + Genitive": 2 - }; - - // turn weighted array into simple array - const approaches = []; - for (const a in approach) { - for (let j = 0; j < approach[a]; j++) { - approaches.push(a); - } - } - - const base = { - number: ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve"], - being: [ - "Ancestor", - "Ancient", - "Avatar", - "Brother", - "Champion", - "Chief", - "Council", - "Creator", - "Deity", - "Divine One", - "Elder", - "Enlightened Being", - "Father", - "Forebear", - "Forefather", - "Giver", - "God", - "Goddess", - "Guardian", - "Guide", - "Hierach", - "Lady", - "Lord", - "Maker", - "Master", - "Mother", - "Numen", - "Oracle", - "Overlord", - "Protector", - "Reaper", - "Ruler", - "Sage", - "Seer", - "Sister", - "Spirit", - "Supreme Being", - "Transcendent", - "Virgin" - ], - animal: [ - "Antelope", - "Ape", - "Badger", - "Basilisk", - "Bear", - "Beaver", - "Bison", - "Boar", - "Buffalo", - "Camel", - "Cat", - "Centaur", - "Cerberus", - "Chimera", - "Cobra", - "Cockatrice", - "Crane", - "Crocodile", - "Crow", - "Cyclope", - "Deer", - "Dog", - "Direwolf", - "Drake", - "Dragon", - "Eagle", - "Elephant", - "Elk", - "Falcon", - "Fox", - "Goat", - "Goose", - "Gorgon", - "Gryphon", - "Hare", - "Hawk", - "Heron", - "Hippogriff", - "Horse", - "Hound", - "Hyena", - "Ibis", - "Jackal", - "Jaguar", - "Kitsune", - "Kraken", - "Lark", - "Leopard", - "Lion", - "Manticore", - "Mantis", - "Marten", - "Minotaur", - "Moose", - "Mule", - "Narwhal", - "Owl", - "Ox", - "Panther", - "Pegasus", - "Phoenix", - "Python", - "Rat", - "Raven", - "Roc", - "Rook", - "Scorpion", - "Serpent", - "Shark", - "Sheep", - "Snake", - "Sphinx", - "Spider", - "Swan", - "Tiger", - "Turtle", - "Unicorn", - "Viper", - "Vulture", - "Walrus", - "Wolf", - "Wolverine", - "Worm", - "Wyvern", - "Yeti" - ], - adjective: [ - "Aggressive", - "Almighty", - "Ancient", - "Beautiful", - "Benevolent", - "Big", - "Blind", - "Blond", - "Bloody", - "Brave", - "Broken", - "Brutal", - "Burning", - "Calm", - "Celestial", - "Cheerful", - "Crazy", - "Cruel", - "Dead", - "Deadly", - "Devastating", - "Distant", - "Disturbing", - "Divine", - "Dying", - "Eternal", - "Ethernal", - "Empyreal", - "Enigmatic", - "Enlightened", - "Evil", - "Explicit", - "Fair", - "Far", - "Fat", - "Fatal", - "Favorable", - "Flying", - "Friendly", - "Frozen", - "Giant", - "Good", - "Grateful", - "Great", - "Happy", - "High", - "Holy", - "Honest", - "Huge", - "Hungry", - "Illustrious", - "Immutable", - "Ineffable", - "Infallible", - "Inherent", - "Last", - "Latter", - "Lost", - "Loud", - "Lucky", - "Mad", - "Magical", - "Main", - "Major", - "Marine", - "Mythical", - "Mystical", - "Naval", - "New", - "Noble", - "Old", - "Otherworldly", - "Patient", - "Peaceful", - "Pregnant", - "Prime", - "Proud", - "Pure", - "Radiant", - "Resplendent", - "Sacred", - "Sacrosanct", - "Sad", - "Scary", - "Secret", - "Selected", - "Serene", - "Severe", - "Silent", - "Sleeping", - "Slumbering", - "Sovereign", - "Strong", - "Sunny", - "Superior", - "Supernatural", - "Sustainable", - "Transcendent", - "Transcendental", - "Troubled", - "Unearthly", - "Unfathomable", - "Unhappy", - "Unknown", - "Unseen", - "Waking", - "Wild", - "Wise", - "Worried", - "Young" - ], - genitive: [ - "Cold", - "Day", - "Death", - "Doom", - "Fate", - "Fire", - "Fog", - "Frost", - "Gates", - "Heaven", - "Home", - "Ice", - "Justice", - "Life", - "Light", - "Lightning", - "Love", - "Nature", - "Night", - "Pain", - "Snow", - "Springs", - "Summer", - "Thunder", - "Time", - "Victory", - "War", - "Winter" - ], - theGenitive: [ - "Abyss", - "Blood", - "Dawn", - "Earth", - "East", - "Eclipse", - "Fall", - "Harvest", - "Moon", - "North", - "Peak", - "Rainbow", - "Sea", - "Sky", - "South", - "Stars", - "Storm", - "Sun", - "Tree", - "Underworld", - "West", - "Wild", - "Word", - "World" - ], - color: [ - "Amber", - "Black", - "Blue", - "Bright", - "Bronze", - "Brown", - "Coral", - "Crimson", - "Dark", - "Emerald", - "Golden", - "Green", - "Grey", - "Indigo", - "Lavender", - "Light", - "Magenta", - "Maroon", - "Orange", - "Pink", - "Plum", - "Purple", - "Red", - "Ruby", - "Sapphire", - "Teal", - "Turquoise", - "White", - "Yellow" - ] - }; - - const forms = { - Folk: { - Shamanism: 4, - Animism: 4, - Polytheism: 4, - "Ancestor Worship": 2, - "Nature Worship": 1, - Totemism: 1 - }, - Organized: { - Polytheism: 7, - Monotheism: 7, - Dualism: 3, - Pantheism: 2, - "Non-theism": 2 - }, - Cult: { - Cult: 5, - "Dark Cult": 5, - Sect: 1 - }, - Heresy: { - Heresy: 1 - } - }; - - const namingMethods = { - Folk: { - "Culture + type": 1 - }, - - Organized: { - "Random + type": 3, - "Random + ism": 1, - "Supreme + ism": 5, - "Faith of + Supreme": 5, - "Place + ism": 1, - "Culture + ism": 2, - "Place + ian + type": 6, - "Culture + type": 4 - }, - - Cult: { - "Burg + ian + type": 2, - "Random + ian + type": 1, - "Type + of the + meaning": 2 - }, - - Heresy: { - "Burg + ian + type": 3, - "Random + ism": 3, - "Random + ian + type": 2, - "Type + of the + meaning": 1 - } - }; - - const types = { - Shamanism: {Beliefs: 3, Shamanism: 2, Druidism: 1, Spirits: 1}, - Animism: {Spirits: 3, Beliefs: 1}, - Polytheism: {Deities: 3, Faith: 1, Gods: 1, Pantheon: 1}, - "Ancestor Worship": {Beliefs: 1, Forefathers: 2, Ancestors: 2}, - "Nature Worship": {Beliefs: 3, Druids: 1}, - Totemism: {Beliefs: 2, Totems: 2, Idols: 1}, - - Monotheism: {Religion: 2, Church: 3, Faith: 1}, - Dualism: {Religion: 3, Faith: 1, Cult: 1}, - Pantheism: {Religion: 1, Faith: 1}, - "Non-theism": {Beliefs: 3, Spirits: 1}, - - Cult: {Cult: 4, Sect: 2, Arcanum: 1, Order: 1, Worship: 1}, - "Dark Cult": {Cult: 2, Blasphemy: 1, Circle: 1, Coven: 1, Idols: 1, Occultism: 1}, - Sect: {Sect: 3, Society: 1}, - - Heresy: { - Heresy: 3, - Sect: 2, - Apostates: 1, - Brotherhood: 1, - Circle: 1, - Dissent: 1, - Dissenters: 1, - Iconoclasm: 1, - Schism: 1, - Society: 1 - } - }; - - const expansionismMap = { - Folk: () => 0, - Organized: () => gauss(5, 3, 0, 10, 1), - Cult: () => gauss(0.5, 0.5, 0, 5, 1), - Heresy: () => gauss(1, 0.5, 0, 5, 1) - }; - - function generate() { - TIME && console.time("generateReligions"); - const lockedReligions = pack.religions?.filter(r => r.i && r.lock && !r.removed) || []; - - const folkReligions = generateFolkReligions(); - const organizedReligions = generateOrganizedReligions(+religionsNumber.value, lockedReligions); - - const namedReligions = specifyReligions([...folkReligions, ...organizedReligions]); - const indexedReligions = combineReligions(namedReligions, lockedReligions); - const religionIds = expandReligions(indexedReligions); - const religions = defineOrigins(religionIds, indexedReligions); - - pack.religions = religions; - pack.cells.religion = religionIds; - - checkCenters(); - - TIME && console.timeEnd("generateReligions"); - } - - function generateFolkReligions() { - return pack.cultures - .filter(c => c.i && !c.removed) - .map(culture => ({type: "Folk", form: rw(forms.Folk), culture: culture.i, center: culture.center})); - } - - function generateOrganizedReligions(desiredReligionNumber, lockedReligions) { - const cells = pack.cells; - const lockedReligionCount = lockedReligions.filter(({type}) => type !== "Folk").length || 0; - const requiredReligionsNumber = desiredReligionNumber - lockedReligionCount; - if (requiredReligionsNumber < 1) return []; - - const candidateCells = getCandidateCells(); - const religionCores = placeReligions(); - - const cultsCount = Math.floor((rand(1, 4) / 10) * religionCores.length); // 10-40% - const heresiesCount = Math.floor((rand(0, 3) / 10) * religionCores.length); // 0-30% - const organizedCount = religionCores.length - cultsCount - heresiesCount; - - const getType = index => { - if (index < organizedCount) return "Organized"; - if (index < organizedCount + cultsCount) return "Cult"; - return "Heresy"; - }; - - return religionCores.map((cellId, index) => { - const type = getType(index); - const form = rw(forms[type]); - const cultureId = cells.culture[cellId]; - - return {type, form, culture: cultureId, center: cellId}; - }); - - function placeReligions() { - const religionCells = []; - const religionsTree = d3.quadtree(); - - // pre-populate with locked centers - lockedReligions.forEach(({center}) => religionsTree.add(cells.p[center])); - - // min distance between religion inceptions - const spacing = (graphWidth + graphHeight) / 2 / desiredReligionNumber; - - for (const cellId of candidateCells) { - const [x, y] = cells.p[cellId]; - - if (religionsTree.find(x, y, spacing) === undefined) { - religionCells.push(cellId); - religionsTree.add([x, y]); - - if (religionCells.length === requiredReligionsNumber) return religionCells; - } - } - - WARN && console.warn(`Placed only ${religionCells.length} of ${requiredReligionsNumber} religions`); - return religionCells; - } - - function getCandidateCells() { - const validBurgs = pack.burgs.filter(b => b.i && !b.removed); - - if (validBurgs.length >= requiredReligionsNumber) - return validBurgs.sort((a, b) => b.population - a.population).map(burg => burg.cell); - return cells.i.filter(i => cells.s[i] > 2).sort((a, b) => cells.s[b] - cells.s[a]); - } - } - - function specifyReligions(newReligions) { - const {cells, cultures} = pack; - - const rawReligions = newReligions.map(({type, form, culture: cultureId, center}) => { - const supreme = getDeityName(cultureId); - const deity = form === "Non-theism" || form === "Animism" ? null : supreme; - - const stateId = cells.state[center]; - - let [name, expansion] = generateReligionName(type, form, supreme, center); - if (expansion === "state" && !stateId) expansion = "global"; - - const expansionism = expansionismMap[type](); - const color = getReligionColor(cultures[cultureId], type); - - return {name, type, form, culture: cultureId, center, deity, expansion, expansionism, color}; - }); - - return rawReligions; - - function getReligionColor(culture, type) { - if (!culture.i) return getRandomColor(); - - if (type === "Folk") return culture.color; - if (type === "Heresy") return getMixedColor(culture.color, 0.35, 0.2); - if (type === "Cult") return getMixedColor(culture.color, 0.5, 0); - return getMixedColor(culture.color, 0.25, 0.4); - } - } - - // indexes, conditionally renames, and abbreviates religions - function combineReligions(namedReligions, lockedReligions) { - const indexedReligions = [{name: "No religion", i: 0}]; - - const {lockedReligionQueue, highestLockedIndex, codes, numberLockedFolk} = parseLockedReligions(); - const maxIndex = Math.max( - highestLockedIndex, - namedReligions.length + lockedReligions.length + 1 - numberLockedFolk - ); - - for (let index = 1, progress = 0; index < maxIndex; index = indexedReligions.length) { - // place locked religion back at its old index - if (index === lockedReligionQueue[0]?.i) { - const nextReligion = lockedReligionQueue.shift(); - indexedReligions.push(nextReligion); - continue; - } - - // slot the new religions - if (progress < namedReligions.length) { - const nextReligion = namedReligions[progress]; - progress++; - - if ( - nextReligion.type === "Folk" && - lockedReligions.some(({type, culture}) => type === "Folk" && culture === nextReligion.culture) - ) - continue; // when there is a locked Folk religion for this culture discard duplicate - - const newName = renameOld(nextReligion); - const code = abbreviate(newName, codes); - codes.push(code); - indexedReligions.push({...nextReligion, i: index, name: newName, code}); - continue; - } - - indexedReligions.push({i: index, type: "Folk", culture: 0, name: "Removed religion", removed: true}); - } - return indexedReligions; - - function parseLockedReligions() { - // copy and sort the locked religions list - const lockedReligionQueue = lockedReligions - .map(religion => { - // and filter their origins to locked religions - let newOrigin = religion.origins.filter(n => lockedReligions.some(({i: index}) => index === n)); - if (newOrigin === []) newOrigin = [0]; - return {...religion, origins: newOrigin}; - }) - .sort((a, b) => a.i - b.i); - - const highestLockedIndex = Math.max(...lockedReligions.map(r => r.i)); - const codes = lockedReligions.length > 0 ? lockedReligions.map(r => r.code) : []; - const numberLockedFolk = lockedReligions.filter(({type}) => type === "Folk").length; - - return {lockedReligionQueue, highestLockedIndex, codes, numberLockedFolk}; - } - - // prepend 'Old' to names of folk religions which have organized competitors - function renameOld({name, type, culture: cultureId}) { - if (type !== "Folk") return name; - - const haveOrganized = - namedReligions.some( - ({type, culture, expansion}) => culture === cultureId && type === "Organized" && expansion === "culture" - ) || - lockedReligions.some( - ({type, culture, expansion}) => culture === cultureId && type === "Organized" && expansion === "culture" - ); - if (haveOrganized && name.slice(0, 3) !== "Old") return `Old ${name}`; - return name; - } - } - - // finally generate and stores origins trees - function defineOrigins(religionIds, indexedReligions) { - const religionOriginsParamsMap = { - Organized: {clusterSize: 100, maxReligions: 2}, - Cult: {clusterSize: 50, maxReligions: 3}, - Heresy: {clusterSize: 50, maxReligions: 4} - }; - - const origins = indexedReligions.map(({i, type, culture: cultureId, expansion, center}) => { - if (i === 0) return null; // no religion - if (type === "Folk") return [0]; // folk religions originate from its parent culture only - - const folkReligion = indexedReligions.find(({culture, type}) => type === "Folk" && culture === cultureId); - const isFolkBased = folkReligion && cultureId && expansion === "culture" && each(2)(center); - if (isFolkBased) return [folkReligion.i]; - - const {clusterSize, maxReligions} = religionOriginsParamsMap[type]; - const fallbackOrigin = folkReligion?.i || 0; - return getReligionsInRadius(pack.cells.c, center, religionIds, i, clusterSize, maxReligions, fallbackOrigin); - }); - - return indexedReligions.map((religion, index) => ({...religion, origins: origins[index]})); - } - - function getReligionsInRadius(neighbors, center, religionIds, religionId, clusterSize, maxReligions, fallbackOrigin) { - const foundReligions = new Set(); - const queue = [center]; - const checked = {}; - - for (let size = 0; queue.length && size < clusterSize; size++) { - const cellId = queue.shift(); - checked[cellId] = true; - - for (const neibId of neighbors[cellId]) { - if (checked[neibId]) continue; - checked[neibId] = true; - - const neibReligion = religionIds[neibId]; - if (neibReligion && neibReligion < religionId) foundReligions.add(neibReligion); - if (foundReligions.size >= maxReligions) return [...foundReligions]; - queue.push(neibId); - } - } - - return foundReligions.size ? [...foundReligions] : [fallbackOrigin]; - } - - // growth algorithm to assign cells to religions - function expandReligions(religions) { - const {cells, routes} = pack; - const religionIds = spreadFolkReligions(religions); - - const queue = new FlatQueue(); - const cost = []; - - // limit cost for organized religions growth - const maxExpansionCost = (cells.i.length / 20) * byId("growthRate").valueAsNumber; - - religions - .filter(r => r.i && !r.lock && r.type !== "Folk" && !r.removed) - .forEach(r => { - religionIds[r.center] = r.i; - queue.push({e: r.center, p: 0, r: r.i, s: cells.state[r.center]}, 0); - cost[r.center] = 1; - }); - - const religionsMap = new Map(religions.map(r => [r.i, r])); - - while (queue.length) { - const {e: cellId, p, r, s: state} = queue.pop(); - const {culture, expansion, expansionism} = religionsMap.get(r); - - cells.c[cellId].forEach(nextCell => { - if (expansion === "culture" && culture !== cells.culture[nextCell]) return; - if (expansion === "state" && state !== cells.state[nextCell]) return; - if (religionsMap.get(religionIds[nextCell])?.lock) return; - - const cultureCost = culture !== cells.culture[nextCell] ? 10 : 0; - const stateCost = state !== cells.state[nextCell] ? 10 : 0; - const passageCost = getPassageCost(cellId, nextCell); - - const cellCost = cultureCost + stateCost + passageCost; - const totalCost = p + 10 + cellCost / expansionism; - if (totalCost > maxExpansionCost) return; - - if (!cost[nextCell] || totalCost < cost[nextCell]) { - if (cells.culture[nextCell]) religionIds[nextCell] = r; // assign religion to cell - cost[nextCell] = totalCost; - - queue.push({e: nextCell, p: totalCost, r, s: state}, totalCost); - } - }); - } - - return religionIds; - - function getPassageCost(cellId, nextCellId) { - const route = Routes.getRoute(cellId, nextCellId); - if (isWater(cellId)) return route ? 50 : 500; - - const biomePassageCost = biomesData.cost[cells.biome[nextCellId]]; - - if (route) { - if (route.group === "roads") return 1; - return biomePassageCost / 3; // trails and other routes - } - - return biomePassageCost; - } - } - - // folk religions initially get all cells of their culture, and locked religions are retained - function spreadFolkReligions(religions) { - const cells = pack.cells; - const hasPrior = cells.religion && true; - const religionIds = new Uint16Array(cells.i.length); - - const folkReligions = religions.filter(religion => religion.type === "Folk" && !religion.removed); - const cultureToReligionMap = new Map(folkReligions.map(({i, culture}) => [culture, i])); - - for (const cellId of cells.i) { - const oldId = (hasPrior && cells.religion[cellId]) || 0; - if (oldId && religions[oldId]?.lock && !religions[oldId]?.removed) { - religionIds[cellId] = oldId; - continue; - } - const cultureId = cells.culture[cellId]; - religionIds[cellId] = cultureToReligionMap.get(cultureId) || 0; - } - - return religionIds; - } - - function checkCenters() { - const cells = pack.cells; - pack.religions.forEach(r => { - if (!r.i) return; - // move religion center if it's not within religion area after expansion - if (cells.religion[r.center] === r.i) return; // in area - const firstCell = cells.i.find(i => cells.religion[i] === r.i); - const cultureHome = pack.cultures[r.culture]?.center; - if (firstCell) r.center = firstCell; // move center, othervise it's an extinct religion - else if (r.type === "Folk" && cultureHome) r.center = cultureHome; // reset extinct culture centers - }); - } - - function recalculate() { - const newReligionIds = expandReligions(pack.religions); - pack.cells.religion = newReligionIds; - - checkCenters(); - } - - const add = function (center) { - const {cells, cultures, religions} = pack; - const religionId = cells.religion[center]; - const i = religions.length; - - const cultureId = cells.culture[center]; - const missingFolk = - cultureId !== 0 && - !religions.some(({type, culture, removed}) => type === "Folk" && culture === cultureId && !removed); - const color = missingFolk ? cultures[cultureId].color : getMixedColor(religions[religionId].color, 0.3, 0); - - const type = missingFolk - ? "Folk" - : religions[religionId].type === "Organized" - ? rw({Organized: 4, Cult: 1, Heresy: 2}) - : rw({Organized: 5, Cult: 2}); - const form = rw(forms[type]); - const deity = - type === "Heresy" - ? religions[religionId].deity - : form === "Non-theism" || form === "Animism" - ? null - : getDeityName(cultureId); - - const [name, expansion] = generateReligionName(type, form, deity, center); - - const formName = type === "Heresy" ? religions[religionId].form : form; - const code = abbreviate( - name, - religions.map(r => r.code) - ); - const influences = getReligionsInRadius(cells.c, center, cells.religion, i, 25, 3, 0); - const origins = type === "Folk" ? [0] : influences; - - religions.push({ - i, - name, - color, - culture: cultureId, - type, - form: formName, - deity, - expansion, - expansionism: expansionismMap[type](), - center, - cells: 0, - area: 0, - rural: 0, - urban: 0, - origins, - code - }); - cells.religion[center] = i; - }; - - // get supreme deity name - const getDeityName = function (culture) { - if (culture === undefined) { - ERROR && console.error("Please define a culture"); - return; - } - const meaning = generateMeaning(); - const cultureName = Names.getCulture(culture, null, null, "", 0.8); - return cultureName + ", The " + meaning; - }; - - function generateMeaning() { - const a = ra(approaches); // select generation approach - if (a === "Number") return ra(base.number); - if (a === "Being") return ra(base.being); - if (a === "Adjective") return ra(base.adjective); - if (a === "Color + Animal") return `${ra(base.color)} ${ra(base.animal)}`; - if (a === "Adjective + Animal") return `${ra(base.adjective)} ${ra(base.animal)}`; - if (a === "Adjective + Being") return `${ra(base.adjective)} ${ra(base.being)}`; - if (a === "Adjective + Genitive") return `${ra(base.adjective)} ${ra(base.genitive)}`; - if (a === "Color + Being") return `${ra(base.color)} ${ra(base.being)}`; - if (a === "Color + Genitive") return `${ra(base.color)} ${ra(base.genitive)}`; - if (a === "Being + of + Genitive") return `${ra(base.being)} of ${ra(base.genitive)}`; - if (a === "Being + of the + Genitive") return `${ra(base.being)} of the ${ra(base.theGenitive)}`; - if (a === "Animal + of + Genitive") return `${ra(base.animal)} of ${ra(base.genitive)}`; - if (a === "Adjective + Being + of + Genitive") - return `${ra(base.adjective)} ${ra(base.being)} of ${ra(base.genitive)}`; - if (a === "Adjective + Animal + of + Genitive") - return `${ra(base.adjective)} ${ra(base.animal)} of ${ra(base.genitive)}`; - - ERROR && console.error("Unkown generation approach"); - } - - function generateReligionName(variety, form, deity, center) { - const {cells, cultures, burgs, states} = pack; - - const random = () => Names.getCulture(cells.culture[center], null, null, "", 0); - const type = rw(types[form]); - const supreme = deity.split(/[ ,]+/)[0]; - const culture = cultures[cells.culture[center]].name; - - const place = adj => { - const burgId = cells.burg[center]; - const stateId = cells.state[center]; - - const base = burgId ? burgs[burgId].name : states[stateId].name; - let name = trimVowels(base.split(/[ ,]+/)[0]); - return adj ? getAdjective(name) : name; - }; - - const m = rw(namingMethods[variety]); - if (m === "Random + type") return [random() + " " + type, "global"]; - if (m === "Random + ism") return [trimVowels(random()) + "ism", "global"]; - if (m === "Supreme + ism" && deity) return [trimVowels(supreme) + "ism", "global"]; - if (m === "Faith of + Supreme" && deity) - return [ra(["Faith", "Way", "Path", "Word", "Witnesses"]) + " of " + supreme, "global"]; - if (m === "Place + ism") return [place() + "ism", "state"]; - if (m === "Culture + ism") return [trimVowels(culture) + "ism", "culture"]; - if (m === "Place + ian + type") return [place("adj") + " " + type, "state"]; - if (m === "Culture + type") return [culture + " " + type, "culture"]; - if (m === "Burg + ian + type") return [`${place("adj")} ${type}`, "global"]; - if (m === "Random + ian + type") return [`${getAdjective(random())} ${type}`, "global"]; - if (m === "Type + of the + meaning") return [`${type} of the ${generateMeaning()}`, "global"]; - return [trimVowels(random()) + "ism", "global"]; // else - } - - return {generate, add, getDeityName, recalculate}; -})(); diff --git a/src/index.html b/src/index.html index 641f259b..98549419 100644 --- a/src/index.html +++ b/src/index.html @@ -8495,7 +8495,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index f8fa62ef..aca8bc37 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -10,4 +10,5 @@ import "./biomes"; import "./cultures-generator"; import "./routes-generator"; import "./states-generator"; +import "./religions-generator"; import "./provinces-generator"; diff --git a/src/modules/religions-generator.ts b/src/modules/religions-generator.ts new file mode 100644 index 00000000..30397afd --- /dev/null +++ b/src/modules/religions-generator.ts @@ -0,0 +1,1168 @@ +import { quadtree } from "d3"; +import { + abbreviate, + byId, + each, + gauss, + getAdjective, + getMixedColor, + getRandomColor, + isWater, + ra, + rand, + rw, + trimVowels, +} from "../utils"; + +declare global { + var Religions: ReligionsModule; +} + +interface ReligionBase { + type: "Folk" | "Organized" | "Cult" | "Heresy"; + form: string; + culture: number; + center: number; +} + +interface NamedReligion extends ReligionBase { + name: string; + deity: string | null; + expansion: string; + expansionism: number; + color: string; +} + +export interface Religion extends NamedReligion { + i: number; + code?: string; + origins?: number[] | null; + lock?: boolean; + removed?: boolean; + cells?: number; + area?: number; + rural?: number; + urban?: number; +} + +// name generation approach and relative chance to be selected +const approach: Record = { + Number: 1, + Being: 3, + Adjective: 5, + "Color + Animal": 5, + "Adjective + Animal": 5, + "Adjective + Being": 5, + "Adjective + Genitive": 1, + "Color + Being": 3, + "Color + Genitive": 3, + "Being + of + Genitive": 2, + "Being + of the + Genitive": 1, + "Animal + of + Genitive": 1, + "Adjective + Being + of + Genitive": 2, + "Adjective + Animal + of + Genitive": 2, +}; + +// turn weighted array into simple array +const approaches: string[] = []; +for (const a in approach) { + for (let j = 0; j < approach[a]; j++) { + approaches.push(a); + } +} + +const base = { + number: [ + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + ], + being: [ + "Ancestor", + "Ancient", + "Avatar", + "Brother", + "Champion", + "Chief", + "Council", + "Creator", + "Deity", + "Divine One", + "Elder", + "Enlightened Being", + "Father", + "Forebear", + "Forefather", + "Giver", + "God", + "Goddess", + "Guardian", + "Guide", + "Hierarch", + "Lady", + "Lord", + "Maker", + "Master", + "Mother", + "Numen", + "Oracle", + "Overlord", + "Protector", + "Reaper", + "Ruler", + "Sage", + "Seer", + "Sister", + "Spirit", + "Supreme Being", + "Transcendent", + "Virgin", + ], + animal: [ + "Antelope", + "Ape", + "Badger", + "Basilisk", + "Bear", + "Beaver", + "Bison", + "Boar", + "Buffalo", + "Camel", + "Cat", + "Centaur", + "Cerberus", + "Chimera", + "Cobra", + "Cockatrice", + "Crane", + "Crocodile", + "Crow", + "Cyclope", + "Deer", + "Dog", + "Direwolf", + "Drake", + "Dragon", + "Eagle", + "Elephant", + "Elk", + "Falcon", + "Fox", + "Goat", + "Goose", + "Gorgon", + "Gryphon", + "Hare", + "Hawk", + "Heron", + "Hippogriff", + "Horse", + "Hound", + "Hyena", + "Ibis", + "Jackal", + "Jaguar", + "Kitsune", + "Kraken", + "Lark", + "Leopard", + "Lion", + "Manticore", + "Mantis", + "Marten", + "Minotaur", + "Moose", + "Mule", + "Narwhal", + "Owl", + "Ox", + "Panther", + "Pegasus", + "Phoenix", + "Python", + "Rat", + "Raven", + "Roc", + "Rook", + "Scorpion", + "Serpent", + "Shark", + "Sheep", + "Snake", + "Sphinx", + "Spider", + "Swan", + "Tiger", + "Turtle", + "Unicorn", + "Viper", + "Vulture", + "Walrus", + "Wolf", + "Wolverine", + "Worm", + "Wyvern", + "Yeti", + ], + adjective: [ + "Aggressive", + "Almighty", + "Ancient", + "Beautiful", + "Benevolent", + "Big", + "Blind", + "Blond", + "Bloody", + "Brave", + "Broken", + "Brutal", + "Burning", + "Calm", + "Celestial", + "Cheerful", + "Crazy", + "Cruel", + "Dead", + "Deadly", + "Devastating", + "Distant", + "Disturbing", + "Divine", + "Dying", + "Eternal", + "Ethernal", + "Empyreal", + "Enigmatic", + "Enlightened", + "Evil", + "Explicit", + "Fair", + "Far", + "Fat", + "Fatal", + "Favorable", + "Flying", + "Friendly", + "Frozen", + "Giant", + "Good", + "Grateful", + "Great", + "Happy", + "High", + "Holy", + "Honest", + "Huge", + "Hungry", + "Illustrious", + "Immutable", + "Ineffable", + "Infallible", + "Inherent", + "Last", + "Latter", + "Lost", + "Loud", + "Lucky", + "Mad", + "Magical", + "Main", + "Major", + "Marine", + "Mythical", + "Mystical", + "Naval", + "New", + "Noble", + "Old", + "Otherworldly", + "Patient", + "Peaceful", + "Pregnant", + "Prime", + "Proud", + "Pure", + "Radiant", + "Resplendent", + "Sacred", + "Sacrosanct", + "Sad", + "Scary", + "Secret", + "Selected", + "Serene", + "Severe", + "Silent", + "Sleeping", + "Slumbering", + "Sovereign", + "Strong", + "Sunny", + "Superior", + "Supernatural", + "Sustainable", + "Transcendent", + "Transcendental", + "Troubled", + "Unearthly", + "Unfathomable", + "Unhappy", + "Unknown", + "Unseen", + "Waking", + "Wild", + "Wise", + "Worried", + "Young", + ], + genitive: [ + "Cold", + "Day", + "Death", + "Doom", + "Fate", + "Fire", + "Fog", + "Frost", + "Gates", + "Heaven", + "Home", + "Ice", + "Justice", + "Life", + "Light", + "Lightning", + "Love", + "Nature", + "Night", + "Pain", + "Snow", + "Springs", + "Summer", + "Thunder", + "Time", + "Victory", + "War", + "Winter", + ], + theGenitive: [ + "Abyss", + "Blood", + "Dawn", + "Earth", + "East", + "Eclipse", + "Fall", + "Harvest", + "Moon", + "North", + "Peak", + "Rainbow", + "Sea", + "Sky", + "South", + "Stars", + "Storm", + "Sun", + "Tree", + "Underworld", + "West", + "Wild", + "Word", + "World", + ], + color: [ + "Amber", + "Black", + "Blue", + "Bright", + "Bronze", + "Brown", + "Coral", + "Crimson", + "Dark", + "Emerald", + "Golden", + "Green", + "Grey", + "Indigo", + "Lavender", + "Light", + "Magenta", + "Maroon", + "Orange", + "Pink", + "Plum", + "Purple", + "Red", + "Ruby", + "Sapphire", + "Teal", + "Turquoise", + "White", + "Yellow", + ], +}; + +const forms: Record> = { + Folk: { + Shamanism: 4, + Animism: 4, + Polytheism: 4, + "Ancestor Worship": 2, + "Nature Worship": 1, + Totemism: 1, + }, + Organized: { + Polytheism: 7, + Monotheism: 7, + Dualism: 3, + Pantheism: 2, + "Non-theism": 2, + }, + Cult: { + Cult: 5, + "Dark Cult": 5, + Sect: 1, + }, + Heresy: { + Heresy: 1, + }, +}; + +const namingMethods: Record> = { + Folk: { + "Culture + type": 1, + }, + + Organized: { + "Random + type": 3, + "Random + ism": 1, + "Supreme + ism": 5, + "Faith of + Supreme": 5, + "Place + ism": 1, + "Culture + ism": 2, + "Place + ian + type": 6, + "Culture + type": 4, + }, + + Cult: { + "Burg + ian + type": 2, + "Random + ian + type": 1, + "Type + of the + meaning": 2, + }, + + Heresy: { + "Burg + ian + type": 3, + "Random + ism": 3, + "Random + ian + type": 2, + "Type + of the + meaning": 1, + }, +}; + +const types: Record> = { + Shamanism: { Beliefs: 3, Shamanism: 2, Druidism: 1, Spirits: 1 }, + Animism: { Spirits: 3, Beliefs: 1 }, + Polytheism: { Deities: 3, Faith: 1, Gods: 1, Pantheon: 1 }, + "Ancestor Worship": { Beliefs: 1, Forefathers: 2, Ancestors: 2 }, + "Nature Worship": { Beliefs: 3, Druids: 1 }, + Totemism: { Beliefs: 2, Totems: 2, Idols: 1 }, + + Monotheism: { Religion: 2, Church: 3, Faith: 1 }, + Dualism: { Religion: 3, Faith: 1, Cult: 1 }, + Pantheism: { Religion: 1, Faith: 1 }, + "Non-theism": { Beliefs: 3, Spirits: 1 }, + + Cult: { Cult: 4, Sect: 2, Arcanum: 1, Order: 1, Worship: 1 }, + "Dark Cult": { + Cult: 2, + Blasphemy: 1, + Circle: 1, + Coven: 1, + Idols: 1, + Occultism: 1, + }, + Sect: { Sect: 3, Society: 1 }, + + Heresy: { + Heresy: 3, + Sect: 2, + Apostates: 1, + Brotherhood: 1, + Circle: 1, + Dissent: 1, + Dissenters: 1, + Iconoclasm: 1, + Schism: 1, + Society: 1, + }, +}; + +const expansionismMap: Record number> = { + Folk: () => 0, + Organized: () => gauss(5, 3, 0, 10, 1), + Cult: () => gauss(0.5, 0.5, 0, 5, 1), + Heresy: () => gauss(1, 0.5, 0, 5, 1), +}; + +class ReligionsModule { + generate() { + TIME && console.time("generateReligions"); + const lockedReligions = + pack.religions?.filter((r) => r.i && r.lock && !r.removed) || []; + + const folkReligions = this.generateFolkReligions(); + const organizedReligions = this.generateOrganizedReligions( + +religionsNumber.value, + lockedReligions, + ); + + const namedReligions = this.specifyReligions([ + ...folkReligions, + ...organizedReligions, + ]); + const indexedReligions = this.combineReligions( + namedReligions, + lockedReligions, + ); + const religionIds = this.expandReligions(indexedReligions); + const religions = this.defineOrigins(religionIds, indexedReligions); + + pack.religions = religions; + pack.cells.religion = religionIds; + + this.checkCenters(); + + TIME && console.timeEnd("generateReligions"); + } + + private generateFolkReligions(): ReligionBase[] { + return pack.cultures + .filter((c) => c.i && !c.removed) + .map((culture) => ({ + type: "Folk" as const, + form: rw(forms.Folk), + culture: culture.i, + center: culture.center!, + })); + } + + private generateOrganizedReligions( + desiredReligionNumber: number, + lockedReligions: Religion[], + ): ReligionBase[] { + const cells = pack.cells; + const lockedReligionCount = + lockedReligions.filter(({ type }) => type !== "Folk").length || 0; + const requiredReligionsNumber = desiredReligionNumber - lockedReligionCount; + if (requiredReligionsNumber < 1) return []; + + const candidateCells = getCandidateCells(); + const religionCores = placeReligions(); + + const cultsCount = Math.floor((rand(1, 4) / 10) * religionCores.length); // 10-40% + const heresiesCount = Math.floor((rand(0, 3) / 10) * religionCores.length); // 0-30% + const organizedCount = religionCores.length - cultsCount - heresiesCount; + + const getType = (index: number): "Organized" | "Cult" | "Heresy" => { + if (index < organizedCount) return "Organized"; + if (index < organizedCount + cultsCount) return "Cult"; + return "Heresy"; + }; + + return religionCores.map((cellId, index) => { + const type = getType(index); + const form = rw(forms[type]); + const cultureId = cells.culture[cellId]; + + return { type, form, culture: cultureId, center: cellId }; + }); + + function placeReligions(): number[] { + const religionCells: number[] = []; + const religionsTree = quadtree<[number, number]>(); + + // pre-populate with locked centers + for (const { center } of lockedReligions) { + religionsTree.add(cells.p[center]); + } + + // min distance between religion inceptions + const spacing = (graphWidth + graphHeight) / 2 / desiredReligionNumber; + + for (const cellId of candidateCells) { + const [x, y] = cells.p[cellId]; + + if (religionsTree.find(x, y, spacing) === undefined) { + religionCells.push(cellId); + religionsTree.add([x, y]); + + if (religionCells.length === requiredReligionsNumber) + return religionCells; + } + } + + WARN && + console.warn( + `Placed only ${religionCells.length} of ${requiredReligionsNumber} religions`, + ); + return religionCells; + } + + function getCandidateCells(): number[] { + const validBurgs = pack.burgs.filter((b) => b.i && !b.removed); + + if (validBurgs.length >= requiredReligionsNumber) + return validBurgs + .sort((a, b) => b.population! - a.population!) + .map((burg) => burg.cell); + return cells.i + .filter((i) => cells.s[i] > 2) + .sort((a, b) => cells.s[b] - cells.s[a]); + } + } + + private specifyReligions(newReligions: ReligionBase[]): NamedReligion[] { + const { cells, cultures } = pack; + + const rawReligions = newReligions.map( + ({ type, form, culture: cultureId, center }) => { + const supreme = this.getDeityName(cultureId); + const deity: string | null = + form === "Non-theism" || form === "Animism" + ? null + : (supreme ?? null); + + const stateId = cells.state[center]; + + let [name, expansion] = this.generateReligionName( + type, + form, + supreme!, + center, + ); + if (expansion === "state" && !stateId) expansion = "global"; + + const expansionism = expansionismMap[type](); + const color = getReligionColor(cultures[cultureId], type); + + return { + name, + type, + form, + culture: cultureId, + center, + deity, + expansion, + expansionism, + color, + }; + }, + ); + + return rawReligions; + + function getReligionColor( + culture: (typeof pack.cultures)[number], + type: string, + ): string { + if (!culture.i) return getRandomColor(); + + if (type === "Folk") return culture.color!; + if (type === "Heresy") return getMixedColor(culture.color!, 0.35, 0.2); + if (type === "Cult") return getMixedColor(culture.color!, 0.5, 0); + return getMixedColor(culture.color!, 0.25, 0.4); + } + } + + // indexes, conditionally renames, and abbreviates religions + private combineReligions( + namedReligions: NamedReligion[], + lockedReligions: Religion[], + ): Religion[] { + const indexedReligions: Religion[] = [ + { name: "No religion", i: 0 } as Religion, + ]; + + const { lockedReligionQueue, highestLockedIndex, codes, numberLockedFolk } = + parseLockedReligions(); + const maxIndex = Math.max( + highestLockedIndex, + namedReligions.length + lockedReligions.length + 1 - numberLockedFolk, + ); + + for ( + let index = 1, progress = 0; + index < maxIndex; + index = indexedReligions.length + ) { + // place locked religion back at its old index + if (index === lockedReligionQueue[0]?.i) { + const nextReligion = lockedReligionQueue.shift()!; + indexedReligions.push(nextReligion); + continue; + } + + // slot the new religions + if (progress < namedReligions.length) { + const nextReligion = namedReligions[progress]; + progress++; + + if ( + nextReligion.type === "Folk" && + lockedReligions.some( + ({ type, culture }) => + type === "Folk" && culture === nextReligion.culture, + ) + ) + continue; // when there is a locked Folk religion for this culture discard duplicate + + const newName = renameOld(nextReligion); + const code = abbreviate(newName, codes); + codes.push(code); + indexedReligions.push({ + ...nextReligion, + i: index, + name: newName, + code, + }); + continue; + } + + indexedReligions.push({ + i: index, + type: "Folk", + culture: 0, + name: "Removed religion", + removed: true, + } as Religion); + } + return indexedReligions; + + function parseLockedReligions() { + // copy and sort the locked religions list + const lockedReligionQueue = lockedReligions + .map((religion) => { + // and filter their origins to locked religions + let newOrigin = religion.origins!.filter((n) => + lockedReligions.some(({ i: index }) => index === n), + ); + if (newOrigin.length === 0) newOrigin = [0]; + return { ...religion, origins: newOrigin }; + }) + .sort((a, b) => a.i - b.i); + + const highestLockedIndex = Math.max( + ...lockedReligions.map((r) => r.i), + 0, + ); + const codes = + lockedReligions.length > 0 ? lockedReligions.map((r) => r.code!) : []; + const numberLockedFolk = lockedReligions.filter( + ({ type }) => type === "Folk", + ).length; + + return { + lockedReligionQueue, + highestLockedIndex, + codes, + numberLockedFolk, + }; + } + + // prepend 'Old' to names of folk religions which have organized competitors + function renameOld({ + name, + type, + culture: cultureId, + }: NamedReligion): string { + if (type !== "Folk") return name; + + const haveOrganized = + namedReligions.some( + ({ type, culture, expansion }) => + culture === cultureId && + type === "Organized" && + expansion === "culture", + ) || + lockedReligions.some( + ({ type, culture, expansion }) => + culture === cultureId && + type === "Organized" && + expansion === "culture", + ); + if (haveOrganized && name.slice(0, 3) !== "Old") return `Old ${name}`; + return name; + } + } + + // finally generate and stores origins trees + private defineOrigins( + religionIds: Uint16Array, + indexedReligions: Religion[], + ): Religion[] { + const religionOriginsParamsMap: Record< + string, + { clusterSize: number; maxReligions: number } + > = { + Organized: { clusterSize: 100, maxReligions: 2 }, + Cult: { clusterSize: 50, maxReligions: 3 }, + Heresy: { clusterSize: 50, maxReligions: 4 }, + }; + + const origins = indexedReligions.map( + ({ i, type, culture: cultureId, expansion, center }) => { + if (i === 0) return null; // no religion + if (type === "Folk") return [0]; // folk religions originate from its parent culture only + + const folkReligion = indexedReligions.find( + ({ culture, type }) => type === "Folk" && culture === cultureId, + ); + const isFolkBased = + folkReligion && + cultureId && + expansion === "culture" && + each(2)(center); + if (isFolkBased) return [folkReligion.i]; + + const { clusterSize, maxReligions } = religionOriginsParamsMap[type]; + const fallbackOrigin = folkReligion?.i || 0; + return this.getReligionsInRadius( + pack.cells.c, + center, + religionIds, + i, + clusterSize, + maxReligions, + fallbackOrigin, + ); + }, + ); + + return indexedReligions.map((religion, index) => ({ + ...religion, + origins: origins[index], + })); + } + + private getReligionsInRadius( + neighbors: number[][], + center: number, + religionIds: Uint16Array, + religionId: number, + clusterSize: number, + maxReligions: number, + fallbackOrigin: number, + ): number[] { + const foundReligions = new Set(); + const queue = [center]; + const checked: Record = {}; + + for (let size = 0; queue.length && size < clusterSize; size++) { + const cellId = queue.shift()!; + checked[cellId] = true; + + for (const neibId of neighbors[cellId]) { + if (checked[neibId]) continue; + checked[neibId] = true; + + const neibReligion = religionIds[neibId]; + if (neibReligion && neibReligion < religionId) + foundReligions.add(neibReligion); + if (foundReligions.size >= maxReligions) return [...foundReligions]; + queue.push(neibId); + } + } + + return foundReligions.size ? [...foundReligions] : [fallbackOrigin]; + } + + // growth algorithm to assign cells to religions + private expandReligions(religions: Religion[]): Uint16Array { + const { cells } = pack; + const religionIds = this.spreadFolkReligions(religions); + + const queue = new FlatQueue(); + const cost: number[] = []; + + // limit cost for organized religions growth + const maxExpansionCost = + (cells.i.length / 20) * + (byId("growthRate") as HTMLInputElement).valueAsNumber; + + religions + .filter((r) => r.i && !r.lock && r.type !== "Folk" && !r.removed) + .forEach((r) => { + religionIds[r.center] = r.i; + queue.push({ e: r.center, p: 0, r: r.i, s: cells.state[r.center] }, 0); + cost[r.center] = 1; + }); + + const religionsMap = new Map(religions.map((r) => [r.i, r])); + + while (queue.length) { + const { e: cellId, p, r, s: state } = queue.pop(); + const religion = religionsMap.get(r)!; + const { culture, expansion, expansionism } = religion; + + cells.c[cellId].forEach((nextCell) => { + if (expansion === "culture" && culture !== cells.culture[nextCell]) + return; + if (expansion === "state" && state !== cells.state[nextCell]) return; + if (religionsMap.get(religionIds[nextCell])?.lock) return; + + const cultureCost = culture !== cells.culture[nextCell] ? 10 : 0; + const stateCost = state !== cells.state[nextCell] ? 10 : 0; + const passageCost = getPassageCost(cellId, nextCell); + + const cellCost = cultureCost + stateCost + passageCost; + const totalCost = p + 10 + cellCost / expansionism; + if (totalCost > maxExpansionCost) return; + + if (!cost[nextCell] || totalCost < cost[nextCell]) { + if (cells.culture[nextCell]) religionIds[nextCell] = r; // assign religion to cell + cost[nextCell] = totalCost; + + queue.push({ e: nextCell, p: totalCost, r, s: state }, totalCost); + } + }); + } + + return religionIds; + + function getPassageCost(cellId: number, nextCellId: number): number { + const route = Routes.getRoute(cellId, nextCellId); + if (isWater(cellId, pack)) return route ? 50 : 500; + + const biomePassageCost = biomesData.cost[cells.biome[nextCellId]]; + + if (route) { + if (route.group === "roads") return 1; + return biomePassageCost / 3; // trails and other routes + } + + return biomePassageCost; + } + } + + // folk religions initially get all cells of their culture, and locked religions are retained + private spreadFolkReligions(religions: Religion[]): Uint16Array { + const cells = pack.cells; + const hasPrior = cells.religion && true; + const religionIds = new Uint16Array(cells.i.length); + + const folkReligions = religions.filter( + (religion) => religion.type === "Folk" && !religion.removed, + ); + const cultureToReligionMap = new Map( + folkReligions.map(({ i, culture }) => [culture, i]), + ); + + for (const cellId of cells.i) { + const oldId = (hasPrior && cells.religion[cellId]) || 0; + if (oldId && religions[oldId]?.lock && !religions[oldId]?.removed) { + religionIds[cellId] = oldId; + continue; + } + const cultureId = cells.culture[cellId]; + religionIds[cellId] = cultureToReligionMap.get(cultureId) || 0; + } + + return religionIds; + } + + private checkCenters() { + const cells = pack.cells; + pack.religions.forEach((r) => { + if (!r.i) return; + // move religion center if it's not within religion area after expansion + if (cells.religion[r.center] === r.i) return; // in area + const firstCell = cells.i.find((i) => cells.religion[i] === r.i); + const cultureHome = pack.cultures[r.culture]?.center; + if (firstCell) + r.center = firstCell; // move center, otherwise it's an extinct religion + else if (r.type === "Folk" && cultureHome) r.center = cultureHome; // reset extinct culture centers + }); + } + + recalculate() { + const newReligionIds = this.expandReligions(pack.religions); + pack.cells.religion = newReligionIds; + + this.checkCenters(); + } + + add(center: number) { + const { cells, cultures, religions } = pack; + const religionId = cells.religion[center]; + const i = religions.length; + + const cultureId = cells.culture[center]; + const missingFolk = + cultureId !== 0 && + !religions.some( + ({ type, culture, removed }) => + type === "Folk" && culture === cultureId && !removed, + ); + const color = missingFolk + ? cultures[cultureId].color! + : getMixedColor(religions[religionId].color!, 0.3, 0); + + const type: "Folk" | "Organized" | "Cult" | "Heresy" = missingFolk + ? "Folk" + : religions[religionId].type === "Organized" + ? (rw({ Organized: 4, Cult: 1, Heresy: 2 }) as + | "Organized" + | "Cult" + | "Heresy") + : (rw({ Organized: 5, Cult: 2 }) as "Organized" | "Cult"); + const form = rw(forms[type]); + const deity = + type === "Heresy" + ? religions[religionId].deity + : form === "Non-theism" || form === "Animism" + ? null + : this.getDeityName(cultureId); + + const [name, expansion] = this.generateReligionName( + type, + form, + deity!, + center, + ); + + const formName = type === "Heresy" ? religions[religionId].form : form; + const code = abbreviate( + name, + religions.map((r) => r.code!), + ); + const influences = this.getReligionsInRadius( + cells.c, + center, + cells.religion as Uint16Array, + i, + 25, + 3, + 0, + ); + const origins = type === "Folk" ? [0] : influences; + + religions.push({ + i, + name, + color, + culture: cultureId, + type, + form: formName, + deity, + expansion, + expansionism: expansionismMap[type](), + center, + cells: 0, + area: 0, + rural: 0, + urban: 0, + origins, + code, + }); + cells.religion[center] = i; + } + + // get supreme deity name + getDeityName(culture: number): string | undefined { + if (culture === undefined) { + ERROR && console.error("Please define a culture"); + return; + } + const meaning = this.generateMeaning(); + const cultureName = Names.getCulture(culture); + return `${cultureName}, The ${meaning}`; + } + + private generateReligionName( + variety: string, + form: string, + deity: string, + center: number, + ): [string, string] { + const { cells, cultures, burgs, states } = pack; + + const random = () => Names.getCulture(cells.culture[center]); + const type = rw(types[form]); + const supreme = deity.split(/[ ,]+/)[0]; + const culture = cultures[cells.culture[center]].name; + + const place = (adj?: boolean): string => { + const burgId = cells.burg[center]; + const stateId = cells.state[center]; + + const base = burgId ? burgs[burgId].name! : states[stateId].name; + const name = trimVowels(base.split(/[ ,]+/)[0]); + return adj ? getAdjective(name) : name; + }; + + const m = rw(namingMethods[variety]); + if (m === "Random + type") return [`${random()} ${type}`, "global"]; + if (m === "Random + ism") return [`${trimVowels(random())}ism`, "global"]; + if (m === "Supreme + ism" && deity) + return [`${trimVowels(supreme)}ism`, "global"]; + if (m === "Faith of + Supreme" && deity) + return [ + `${ra(["Faith", "Way", "Path", "Word", "Witnesses"])} of ${supreme}`, + "global", + ]; + if (m === "Place + ism") return [`${place()}ism`, "state"]; + if (m === "Culture + ism") return [`${trimVowels(culture!)}ism`, "culture"]; + if (m === "Place + ian + type") return [`${place(true)} ${type}`, "state"]; + if (m === "Culture + type") return [`${culture} ${type}`, "culture"]; + if (m === "Burg + ian + type") return [`${place(true)} ${type}`, "global"]; + if (m === "Random + ian + type") + return [`${getAdjective(random())} ${type}`, "global"]; + if (m === "Type + of the + meaning") + return [`${type} of the ${this.generateMeaning()}`, "global"]; + return [`${trimVowels(random())}ism`, "global"]; // else + } + + private generateMeaning(): string { + const a = ra(approaches); // select generation approach + if (a === "Number") return ra(base.number); + if (a === "Being") return ra(base.being); + if (a === "Adjective") return ra(base.adjective); + if (a === "Color + Animal") return `${ra(base.color)} ${ra(base.animal)}`; + if (a === "Adjective + Animal") + return `${ra(base.adjective)} ${ra(base.animal)}`; + if (a === "Adjective + Being") + return `${ra(base.adjective)} ${ra(base.being)}`; + if (a === "Adjective + Genitive") + return `${ra(base.adjective)} ${ra(base.genitive)}`; + if (a === "Color + Being") return `${ra(base.color)} ${ra(base.being)}`; + if (a === "Color + Genitive") + return `${ra(base.color)} ${ra(base.genitive)}`; + if (a === "Being + of + Genitive") + return `${ra(base.being)} of ${ra(base.genitive)}`; + if (a === "Being + of the + Genitive") + return `${ra(base.being)} of the ${ra(base.theGenitive)}`; + if (a === "Animal + of + Genitive") + return `${ra(base.animal)} of ${ra(base.genitive)}`; + if (a === "Adjective + Being + of + Genitive") + return `${ra(base.adjective)} ${ra(base.being)} of ${ra(base.genitive)}`; + if (a === "Adjective + Animal + of + Genitive") + return `${ra(base.adjective)} ${ra(base.animal)} of ${ra(base.genitive)}`; + + ERROR && console.error("Unknown generation approach"); + return ra(base.being); + } +} + +window.Religions = new ReligionsModule(); diff --git a/src/types/global.ts b/src/types/global.ts index 2272196d..cb92d793 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -28,6 +28,7 @@ declare global { var heightExponentInput: HTMLInputElement; var alertMessage: HTMLElement; var mapName: HTMLInputElement; + var religionsNumber: HTMLInputElement; var distanceUnitInput: HTMLInputElement; var rivers: Selection; From b73557d624c445c56dd322884410cf3b310a5f54 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Tue, 3 Feb 2026 16:46:19 +0100 Subject: [PATCH 22/24] fix: include ice generation in resampling process (#1302) * feat: include ice generation in resampling process * chore: update version to 1.112.1 in versioning.js and resample.js script reference --- public/modules/resample.js | 1 + public/versioning.js | 2 +- src/index.html | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/public/modules/resample.js b/public/modules/resample.js index 819214b1..b64dde1f 100644 --- a/public/modules/resample.js +++ b/public/modules/resample.js @@ -28,6 +28,7 @@ window.Resample = (function () { reGraph(); Features.markupPack(); + Ice.generate() createDefaultRuler(); restoreCellData(parentMap, inverse, scale); diff --git a/public/versioning.js b/public/versioning.js index fc81870d..fd2a67a2 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.112.0"; +const VERSION = "1.112.1"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index 98549419..cc360142 100644 --- a/src/index.html +++ b/src/index.html @@ -8499,7 +8499,7 @@ - + From 86fc62da039144d07d37864b5b17dcdafd08f0bc Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Tue, 3 Feb 2026 16:59:08 +0100 Subject: [PATCH 23/24] fix: rename feature path functions and update global declarations (#1303) * fix: rename feature path functions and update global declarations * chore: lint --- src/renderers/draw-features.ts | 6 ++++-- src/renderers/draw-markers.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/renderers/draw-features.ts b/src/renderers/draw-features.ts index a0e82b59..5a6801d8 100644 --- a/src/renderers/draw-features.ts +++ b/src/renderers/draw-features.ts @@ -9,6 +9,7 @@ declare global { tolerance: number, highestQuality?: boolean, ) => [number, number][]; + var getFeaturePath: (feature: PackedGraphFeature) => string; } interface FeaturesHtml { @@ -34,7 +35,7 @@ const featuresRenderer = (): void => { if (!feature || feature.type === "ocean") continue; html.paths.push( - ``, + ``, ); if (feature.type === "lake") { @@ -81,7 +82,7 @@ const featuresRenderer = (): void => { TIME && console.timeEnd("drawFeatures"); }; -function getFeaturePath(feature: PackedGraphFeature): string { +function featurePathRenderer(feature: PackedGraphFeature): string { const points: [number, number][] = feature.vertices.map( (vertex: number) => pack.vertices.p[vertex], ); @@ -100,3 +101,4 @@ function getFeaturePath(feature: PackedGraphFeature): string { } window.drawFeatures = featuresRenderer; +window.getFeaturePath = featurePathRenderer; diff --git a/src/renderers/draw-markers.ts b/src/renderers/draw-markers.ts index 27ce3136..38cdebc4 100644 --- a/src/renderers/draw-markers.ts +++ b/src/renderers/draw-markers.ts @@ -17,6 +17,7 @@ interface Marker { declare global { var drawMarkers: () => void; + var drawMarker: (marker: Marker, rescale?: number) => string; } type PinShapeFunction = (fill: string, stroke: string) => string; @@ -56,7 +57,7 @@ const getPin = (shape = "bubble", fill = "#fff", stroke = "#000"): string => { return shapeFunction(fill, stroke); }; -function drawMarker(marker: Marker, rescale = 1): string { +function markerRenderer(marker: Marker, rescale = 1): string { const { i, icon, @@ -94,10 +95,11 @@ const markersRenderer = (): void => { const markersData: Marker[] = pinned ? pack.markers.filter((m: Marker) => m.pinned) : pack.markers; - const html = markersData.map((marker) => drawMarker(marker, rescale)); + const html = markersData.map((marker) => markerRenderer(marker, rescale)); markers.html(html.join("")); TIME && console.timeEnd("drawMarkers"); }; window.drawMarkers = markersRenderer; +window.drawMarker = markerRenderer; From 8ba29b2561a1e2f0c9ce8c96df5a1a7a786e08e8 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Tue, 3 Feb 2026 17:22:25 +0100 Subject: [PATCH 24/24] refactor: migrate zones (#1300) * refactor: migrate zones * refactor: remove duplicate markers property from PackedGraph interface --- public/modules/zones-generator.js | 454 -------------------- src/index.html | 1 - src/modules/index.ts | 1 + src/modules/zones-generator.ts | 668 ++++++++++++++++++++++++++++++ src/types/PackedGraph.ts | 4 +- 5 files changed, 672 insertions(+), 456 deletions(-) delete mode 100644 public/modules/zones-generator.js create mode 100644 src/modules/zones-generator.ts diff --git a/public/modules/zones-generator.js b/public/modules/zones-generator.js deleted file mode 100644 index 641a0784..00000000 --- a/public/modules/zones-generator.js +++ /dev/null @@ -1,454 +0,0 @@ -"use strict"; - -window.Zones = (function () { - const config = { - invasion: {quantity: 2, generate: addInvasion}, // invasion of enemy lands - rebels: {quantity: 1.5, generate: addRebels}, // rebels along a state border - proselytism: {quantity: 1.6, generate: addProselytism}, // proselitism of organized religion - crusade: {quantity: 1.6, generate: addCrusade}, // crusade on heresy lands - disease: {quantity: 1.4, generate: addDisease}, // disease starting in a random city - disaster: {quantity: 1, generate: addDisaster}, // disaster starting in a random city - eruption: {quantity: 1, generate: addEruption}, // eruption aroung volcano - avalanche: {quantity: 0.8, generate: addAvalanche}, // avalanche impacting highland road - fault: {quantity: 1, generate: addFault}, // fault line in elevated areas - flood: {quantity: 1, generate: addFlood}, // flood on river banks - tsunami: {quantity: 1, generate: addTsunami} // tsunami starting near coast - }; - - const generate = function (globalModifier = 1) { - TIME && console.time("generateZones"); - - const usedCells = new Uint8Array(pack.cells.i.length); - pack.zones = []; - - Object.values(config).forEach(type => { - const expectedNumber = type.quantity * globalModifier; - let number = gauss(expectedNumber, expectedNumber / 2, 0, 100); - while (number--) type.generate(usedCells); - }); - - TIME && console.timeEnd("generateZones"); - }; - - function addInvasion(usedCells) { - const {cells, states} = pack; - - const ongoingConflicts = states - .filter(s => s.i && !s.removed && s.campaigns) - .map(s => s.campaigns) - .flat() - .filter(c => !c.end); - if (!ongoingConflicts.length) return; - const {defender, attacker} = ra(ongoingConflicts); - - const borderCells = cells.i.filter(cellId => { - if (usedCells[cellId]) return false; - if (cells.state[cellId] !== defender) return false; - return cells.c[cellId].some(c => cells.state[c] === attacker); - }); - - const startCell = ra(borderCells); - if (startCell === undefined) return; - - const invasionCells = []; - const queue = [startCell]; - const maxCells = rand(5, 30); - - while (queue.length) { - const cellId = P(0.4) ? queue.shift() : queue.pop(); - invasionCells.push(cellId); - if (invasionCells.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId]) return; - if (cells.state[neibCellId] !== defender) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const subtype = rw({ - Invasion: 5, - Occupation: 4, - Conquest: 3, - Incursion: 2, - Intervention: 2, - Assault: 1, - Foray: 1, - Intrusion: 1, - Irruption: 1, - Offensive: 1, - Pillaging: 1, - Plunder: 1, - Raid: 1, - Skirmishes: 1 - }); - const name = getAdjective(states[attacker].name) + " " + subtype; - - pack.zones.push({i: pack.zones.length, name, type: "Invasion", cells: invasionCells, color: "url(#hatch1)"}); - } - - function addRebels(usedCells) { - const {cells, states} = pack; - - const state = ra(states.filter(s => s.i && !s.removed && s.neighbors.some(Boolean))); - if (!state) return; - - const neibStateId = ra(state.neighbors.filter(n => n && !states[n].removed)); - if (!neibStateId) return; - - const cellsArray = []; - const queue = []; - const borderCellId = cells.i.find( - i => cells.state[i] === state.i && cells.c[i].some(c => cells.state[c] === neibStateId) - ); - if (borderCellId) queue.push(borderCellId); - const maxCells = rand(10, 30); - - while (queue.length) { - const cellId = queue.shift(); - cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId]) return; - if (cells.state[neibCellId] !== state.i) return; - usedCells[neibCellId] = 1; - if (neibCellId % 4 !== 0 && !cells.c[neibCellId].some(c => cells.state[c] === neibStateId)) return; - queue.push(neibCellId); - }); - } - - const rebels = rw({ - Rebels: 5, - Insurrection: 2, - Mutineers: 1, - Insurgents: 1, - Rebellion: 1, - Renegades: 1, - Revolters: 1, - Revolutionaries: 1, - Rioters: 1, - Separatists: 1, - Secessionists: 1, - Conspiracy: 1 - }); - - const name = getAdjective(states[neibStateId].name) + " " + rebels; - pack.zones.push({i: pack.zones.length, name, type: "Rebels", cells: cellsArray, color: "url(#hatch3)"}); - } - - function addProselytism(usedCells) { - const {cells, religions} = pack; - - const organizedReligions = religions.filter(r => r.i && !r.removed && r.type === "Organized"); - const religion = ra(organizedReligions); - if (!religion) return; - - const targetBorderCells = cells.i.filter( - i => - cells.h[i] < 20 && - cells.pop[i] && - cells.religion[i] !== religion.i && - cells.c[i].some(c => cells.religion[c] === religion.i) - ); - const startCell = ra(targetBorderCells); - if (!startCell) return; - - const targetReligionId = cells.religion[startCell]; - const proselytismCells = []; - const queue = [startCell]; - const maxCells = rand(10, 30); - - while (queue.length) { - const cellId = queue.shift(); - proselytismCells.push(cellId); - if (proselytismCells.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId]) return; - if (cells.religion[neibCellId] !== targetReligionId) return; - if (cells.h[neibCellId] < 20 || !cells.pop[i]) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = `${getAdjective(religion.name.split(" ")[0])} Proselytism`; - pack.zones.push({i: pack.zones.length, name, type: "Proselytism", cells: proselytismCells, color: "url(#hatch6)"}); - } - - function addCrusade(usedCells) { - const {cells, religions} = pack; - - const heresies = religions.filter(r => !r.removed && r.type === "Heresy"); - if (!heresies.length) return; - - const heresy = ra(heresies); - const crusadeCells = cells.i.filter(i => !usedCells[i] && cells.religion[i] === heresy.i); - if (!crusadeCells.length) return; - crusadeCells.forEach(i => (usedCells[i] = 1)); - - const name = getAdjective(heresy.name.split(" ")[0]) + " Crusade"; - pack.zones.push({ - i: pack.zones.length, - name, - type: "Crusade", - cells: Array.from(crusadeCells), - color: "url(#hatch6)" - }); - } - - function addDisease(usedCells) { - const {cells, burgs} = pack; - - const burg = ra(burgs.filter(b => !usedCells[b.cell] && b.i && !b.removed)); // random burg - if (!burg) return; - - const cellsArray = []; - const cost = []; - const maxCells = rand(20, 40); - - const queue = new FlatQueue(); - queue.push({e: burg.cell, p: 0}, 0); - - while (queue.length) { - const next = queue.pop(); - if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); - usedCells[next.e] = 1; - - cells.c[next.e].forEach(nextCellId => { - const c = Routes.getRoute(next.e, nextCellId) ? 5 : 100; - const p = next.p + c; - if (p > maxCells) return; - - if (!cost[nextCellId] || p < cost[nextCellId]) { - cost[nextCellId] = p; - queue.push({e: nextCellId, p}, p); - } - }); - } - - // prettier-ignore - const name = `${(() => { - const model = rw({color: 2, animal: 1, adjective: 1}); - if (model === "color") return ra(["Amber", "Azure", "Black", "Blue", "Brown", "Crimson", "Emerald", "Golden", "Green", "Grey", "Orange", "Pink", "Purple", "Red", "Ruby", "Scarlet", "Silver", "Violet", "White", "Yellow"]); - if (model === "animal") return ra(["Ape", "Bear", "Bird", "Boar", "Cat", "Cow", "Deer", "Dog", "Fox", "Goat", "Horse", "Lion", "Pig", "Rat", "Raven", "Sheep", "Spider", "Tiger", "Viper", "Wolf", "Worm", "Wyrm"]); - if (model === "adjective") return ra(["Blind", "Bloody", "Brutal", "Burning", "Deadly", "Fatal", "Furious", "Great", "Grim", "Horrible", "Invisible", "Lethal", "Loud", "Mortal", "Savage", "Severe", "Silent", "Unknown", "Venomous", "Vicious"]); - })()} ${rw({Fever: 5, Plague: 3, Cough: 3, Flu: 2, Pox: 2, Cholera: 2, Typhoid: 2, Leprosy: 1, Smallpox: 1, Pestilence: 1, Consumption: 1, Malaria: 1, Dropsy: 1})}`; - - pack.zones.push({i: pack.zones.length, name, type: "Disease", cells: cellsArray, color: "url(#hatch12)"}); - } - - function addDisaster(usedCells) { - const {cells, burgs} = pack; - - const burg = ra(burgs.filter(b => !usedCells[b.cell] && b.i && !b.removed)); - if (!burg) return; - usedCells[burg.cell] = 1; - - const cellsArray = []; - const cost = []; - const maxCells = rand(5, 25); - - const queue = new FlatQueue(); - queue.push({e: burg.cell, p: 0}, 0); - - while (queue.length) { - const next = queue.pop(); - if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); - usedCells[next.e] = 1; - - cells.c[next.e].forEach(function (e) { - const c = rand(1, 10); - const p = next.p + c; - if (p > maxCells) return; - - if (!cost[e] || p < cost[e]) { - cost[e] = p; - queue.push({e, p}, p); - } - }); - } - - const type = rw({ - Famine: 5, - Drought: 3, - Earthquake: 3, - Dearth: 1, - Tornadoes: 1, - Wildfires: 1, - Storms: 1, - Blight: 1 - }); - const name = getAdjective(burg.name) + " " + type; - pack.zones.push({i: pack.zones.length, name, type: "Disaster", cells: cellsArray, color: "url(#hatch5)"}); - } - - function addEruption(usedCells) { - const {cells, markers} = pack; - - const volcanoe = markers.find(m => m.type === "volcanoes" && !usedCells[m.cell]); - if (!volcanoe) return; - usedCells[volcanoe.cell] = 1; - - const note = notes.find(n => n.id === "marker" + volcanoe.i); - if (note) note.legend = note.legend.replace("Active volcano", "Erupting volcano"); - const name = note ? note.name.replace(" Volcano", "") + " Eruption" : "Volcano Eruption"; - - const cellsArray = []; - const queue = [volcanoe.cell]; - const maxCells = rand(10, 30); - - while (queue.length) { - const cellId = P(0.5) ? queue.shift() : queue.pop(); - cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId] || cells.h[neibCellId] < 20) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - pack.zones.push({i: pack.zones.length, name, type: "Eruption", cells: cellsArray, color: "url(#hatch7)"}); - } - - function addAvalanche(usedCells) { - const {cells} = pack; - - const routeCells = cells.i.filter(i => !usedCells[i] && Routes.isConnected(i) && cells.h[i] >= 70); - if (!routeCells.length) return; - - const startCell = ra(routeCells); - usedCells[startCell] = 1; - - const cellsArray = []; - const queue = [startCell]; - const maxCells = rand(3, 15); - - while (queue.length) { - const cellId = P(0.3) ? queue.shift() : queue.pop(); - cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId] || cells.h[neibCellId] < 65) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Avalanche"; - pack.zones.push({i: pack.zones.length, name, type: "Avalanche", cells: cellsArray, color: "url(#hatch5)"}); - } - - function addFault(usedCells) { - const cells = pack.cells; - - const elevatedCells = cells.i.filter(i => !usedCells[i] && cells.h[i] > 50 && cells.h[i] < 70); - if (!elevatedCells.length) return; - - const startCell = ra(elevatedCells); - usedCells[startCell] = 1; - - const cellsArray = []; - const queue = [startCell]; - const maxCells = rand(3, 15); - - while (queue.length) { - const cellId = queue.pop(); - if (cells.h[cellId] >= 20) cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId] || cells.r[neibCellId]) return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Fault"; - pack.zones.push({i: pack.zones.length, name, type: "Fault", cells: cellsArray, color: "url(#hatch2)"}); - } - - function addFlood(usedCells) { - const cells = pack.cells; - - const fl = cells.fl.filter(Boolean); - const meanFlux = d3.mean(fl); - const maxFlux = d3.max(fl); - const fluxThreshold = (maxFlux - meanFlux) / 2 + meanFlux; - - const bigRiverCells = cells.i.filter( - i => !usedCells[i] && cells.h[i] < 50 && cells.r[i] && cells.fl[i] > fluxThreshold && cells.burg[i] - ); - if (!bigRiverCells.length) return; - - const startCell = ra(bigRiverCells); - usedCells[startCell] = 1; - - const riverId = cells.r[startCell]; - const cellsArray = []; - const queue = [startCell]; - const maxCells = rand(5, 30); - - while (queue.length) { - const cellId = queue.pop(); - cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if ( - usedCells[neibCellId] || - cells.h[neibCellId] < 20 || - cells.r[neibCellId] !== riverId || - cells.h[neibCellId] > 50 || - cells.fl[neibCellId] < meanFlux - ) - return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = getAdjective(pack.burgs[cells.burg[startCell]].name) + " Flood"; - pack.zones.push({i: pack.zones.length, name, type: "Flood", cells: cellsArray, color: "url(#hatch13)"}); - } - - function addTsunami(usedCells) { - const {cells, features} = pack; - - const coastalCells = cells.i.filter( - i => !usedCells[i] && cells.t[i] === -1 && features[cells.f[i]].type !== "lake" - ); - if (!coastalCells.length) return; - - const startCell = ra(coastalCells); - usedCells[startCell] = 1; - - const cellsArray = []; - const queue = [startCell]; - const maxCells = rand(10, 30); - - while (queue.length) { - const cellId = queue.shift(); - if (cells.t[cellId] === 1) cellsArray.push(cellId); - if (cellsArray.length >= maxCells) break; - - cells.c[cellId].forEach(neibCellId => { - if (usedCells[neibCellId]) return; - if (cells.t[neibCellId] > 2) return; - if (pack.features[cells.f[neibCellId]].type === "lake") return; - usedCells[neibCellId] = 1; - queue.push(neibCellId); - }); - } - - const name = getAdjective(Names.getCultureShort(cells.culture[startCell])) + " Tsunami"; - pack.zones.push({i: pack.zones.length, name, type: "Tsunami", cells: cellsArray, color: "url(#hatch13)"}); - } - - return {generate}; -})(); diff --git a/src/index.html b/src/index.html index cc360142..6173e519 100644 --- a/src/index.html +++ b/src/index.html @@ -8497,7 +8497,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index aca8bc37..a9ebf2b8 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -10,5 +10,6 @@ import "./biomes"; import "./cultures-generator"; import "./routes-generator"; import "./states-generator"; +import "./zones-generator"; import "./religions-generator"; import "./provinces-generator"; diff --git a/src/modules/zones-generator.ts b/src/modules/zones-generator.ts new file mode 100644 index 00000000..bef9ad9b --- /dev/null +++ b/src/modules/zones-generator.ts @@ -0,0 +1,668 @@ +import { max, mean } from "d3"; +import { gauss, getAdjective, P, ra, rand, rw } from "../utils"; + +declare global { + var Zones: ZonesModule; +} + +export interface Zone { + i: number; + name: string; + type: string; + cells: number[]; + color: string; +} + +type ZoneGenerator = (usedCells: Uint8Array) => void; + +interface ZoneConfig { + quantity: number; + generate: ZoneGenerator; +} + +class ZonesModule { + private config: Record; + + constructor() { + this.config = { + invasion: { quantity: 2, generate: (u) => this.addInvasion(u) }, + rebels: { quantity: 1.5, generate: (u) => this.addRebels(u) }, + proselytism: { quantity: 1.6, generate: (u) => this.addProselytism(u) }, + crusade: { quantity: 1.6, generate: (u) => this.addCrusade(u) }, + disease: { quantity: 1.4, generate: (u) => this.addDisease(u) }, + disaster: { quantity: 1, generate: (u) => this.addDisaster(u) }, + eruption: { quantity: 1, generate: (u) => this.addEruption(u) }, + avalanche: { quantity: 0.8, generate: (u) => this.addAvalanche(u) }, + fault: { quantity: 1, generate: (u) => this.addFault(u) }, + flood: { quantity: 1, generate: (u) => this.addFlood(u) }, + tsunami: { quantity: 1, generate: (u) => this.addTsunami(u) }, + }; + } + + generate(globalModifier = 1) { + TIME && console.time("generateZones"); + + const usedCells = new Uint8Array(pack.cells.i.length); + pack.zones = []; + + Object.values(this.config).forEach((type) => { + const expectedNumber = type.quantity * globalModifier; + let number = gauss(expectedNumber, expectedNumber / 2, 0, 100); + while (number--) type.generate(usedCells); + }); + + TIME && console.timeEnd("generateZones"); + } + + private addInvasion(usedCells: Uint8Array) { + const { cells, states } = pack; + + const ongoingConflicts = states + .filter((s) => s.i && !s.removed && s.campaigns) + .flatMap((s) => s.campaigns!) + .filter((c) => !c.end); + if (!ongoingConflicts.length) return; + const { defender, attacker } = ra(ongoingConflicts); + + const borderCells = cells.i.filter((cellId) => { + if (usedCells[cellId]) return false; + if (cells.state[cellId] !== defender) return false; + return cells.c[cellId].some((c) => cells.state[c] === attacker); + }); + + const startCell = ra(borderCells); + if (startCell === undefined) return; + + const invasionCells: number[] = []; + const queue = [startCell]; + const maxCells = rand(5, 30); + + while (queue.length) { + const cellId = P(0.4) ? queue.shift()! : queue.pop()!; + invasionCells.push(cellId); + if (invasionCells.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId]) return; + if (cells.state[neibCellId] !== defender) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const subtype = rw({ + Invasion: 5, + Occupation: 4, + Conquest: 3, + Incursion: 2, + Intervention: 2, + Assault: 1, + Foray: 1, + Intrusion: 1, + Irruption: 1, + Offensive: 1, + Pillaging: 1, + Plunder: 1, + Raid: 1, + Skirmishes: 1, + }); + const name = `${getAdjective(states[attacker].name)} ${subtype}`; + + pack.zones.push({ + i: pack.zones.length, + name, + type: "Invasion", + cells: invasionCells, + color: "url(#hatch1)", + }); + } + + private addRebels(usedCells: Uint8Array) { + const { cells, states } = pack; + + const state = ra( + states.filter((s) => s.i && !s.removed && s.neighbors?.some(Boolean)), + ); + if (!state) return; + + const neibStateId = ra( + state.neighbors!.filter((n: number) => n && !states[n].removed), + ); + if (!neibStateId) return; + + const cellsArray: number[] = []; + const queue: number[] = []; + const borderCellId = cells.i.find( + (i) => + cells.state[i] === state.i && + cells.c[i].some((c) => cells.state[c] === neibStateId), + ); + if (borderCellId) queue.push(borderCellId); + const maxCells = rand(10, 30); + + while (queue.length) { + const cellId = queue.shift()!; + cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId]) return; + if (cells.state[neibCellId] !== state.i) return; + usedCells[neibCellId] = 1; + if ( + neibCellId % 4 !== 0 && + !cells.c[neibCellId].some((c) => cells.state[c] === neibStateId) + ) + return; + queue.push(neibCellId); + }); + } + + const rebels = rw({ + Rebels: 5, + Insurrection: 2, + Mutineers: 1, + Insurgents: 1, + Rebellion: 1, + Renegades: 1, + Revolters: 1, + Revolutionaries: 1, + Rioters: 1, + Separatists: 1, + Secessionists: 1, + Conspiracy: 1, + }); + + const name = `${getAdjective(states[neibStateId].name)} ${rebels}`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Rebels", + cells: cellsArray, + color: "url(#hatch3)", + }); + } + + private addProselytism(usedCells: Uint8Array) { + const { cells, religions } = pack; + + const organizedReligions = religions.filter( + (r) => r.i && !r.removed && r.type === "Organized", + ); + const religion = ra(organizedReligions); + if (!religion) return; + + const targetBorderCells = cells.i.filter( + (i) => + cells.h[i] >= 20 && + cells.pop[i] && + cells.religion[i] !== religion.i && + cells.c[i].some((c) => cells.religion[c] === religion.i), + ); + const startCell = ra(targetBorderCells); + if (!startCell) return; + + const targetReligionId = cells.religion[startCell]; + const proselytismCells: number[] = []; + const queue = [startCell]; + const maxCells = rand(10, 30); + + while (queue.length) { + const cellId = queue.shift()!; + proselytismCells.push(cellId); + if (proselytismCells.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId]) return; + if (cells.religion[neibCellId] !== targetReligionId) return; + if (cells.h[neibCellId] < 20 || !cells.pop[neibCellId]) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(religion.name.split(" ")[0])} Proselytism`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Proselytism", + cells: proselytismCells, + color: "url(#hatch6)", + }); + } + + private addCrusade(usedCells: Uint8Array) { + const { cells, religions } = pack; + + const heresies = religions.filter((r) => !r.removed && r.type === "Heresy"); + if (!heresies.length) return; + + const heresy = ra(heresies); + const crusadeCells = cells.i.filter( + (i) => !usedCells[i] && cells.religion[i] === heresy.i, + ); + if (!crusadeCells.length) return; + for (const i of crusadeCells) { + usedCells[i] = 1; + } + + const name = `${getAdjective(heresy.name.split(" ")[0])} Crusade`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Crusade", + cells: Array.from(crusadeCells), + color: "url(#hatch6)", + }); + } + + private addDisease(usedCells: Uint8Array) { + const { cells, burgs } = pack; + + const burg = ra( + burgs.filter((b) => !usedCells[b.cell] && b.i && !b.removed), + ); + if (!burg) return; + + const cellsArray: number[] = []; + const cost: number[] = []; + const maxCells = rand(20, 40); + + const queue = new FlatQueue(); + queue.push({ e: burg.cell, p: 0 }, 0); + + while (queue.length) { + const next = queue.pop(); + if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); + usedCells[next.e] = 1; + + cells.c[next.e].forEach((nextCellId) => { + const c = Routes.getRoute(next.e, nextCellId) ? 5 : 100; + const p = next.p + c; + if (p > maxCells) return; + + if (!cost[nextCellId] || p < cost[nextCellId]) { + cost[nextCellId] = p; + queue.push({ e: nextCellId, p }, p); + } + }); + } + + const colorName = this.getDiseaseName("color"); + const animalName = this.getDiseaseName("animal"); + const adjectiveName = this.getDiseaseName("adjective"); + + const model = rw({ color: 2, animal: 1, adjective: 1 }); + const prefix = + model === "color" + ? colorName + : model === "animal" + ? animalName + : adjectiveName; + + const disease = rw({ + Fever: 5, + Plague: 3, + Cough: 3, + Flu: 2, + Pox: 2, + Cholera: 2, + Typhoid: 2, + Leprosy: 1, + Smallpox: 1, + Pestilence: 1, + Consumption: 1, + Malaria: 1, + Dropsy: 1, + }); + const name = `${prefix} ${disease}`; + + pack.zones.push({ + i: pack.zones.length, + name, + type: "Disease", + cells: cellsArray, + color: "url(#hatch12)", + }); + } + + private getDiseaseName(model: "color" | "animal" | "adjective"): string { + if (model === "color") + return ra([ + "Amber", + "Azure", + "Black", + "Blue", + "Brown", + "Crimson", + "Emerald", + "Golden", + "Green", + "Grey", + "Orange", + "Pink", + "Purple", + "Red", + "Ruby", + "Scarlet", + "Silver", + "Violet", + "White", + "Yellow", + ]); + if (model === "animal") + return ra([ + "Ape", + "Bear", + "Bird", + "Boar", + "Cat", + "Cow", + "Deer", + "Dog", + "Fox", + "Goat", + "Horse", + "Lion", + "Pig", + "Rat", + "Raven", + "Sheep", + "Spider", + "Tiger", + "Viper", + "Wolf", + "Worm", + "Wyrm", + ]); + return ra([ + "Blind", + "Bloody", + "Brutal", + "Burning", + "Deadly", + "Fatal", + "Furious", + "Great", + "Grim", + "Horrible", + "Invisible", + "Lethal", + "Loud", + "Mortal", + "Savage", + "Severe", + "Silent", + "Unknown", + "Venomous", + "Vicious", + ]); + } + + private addDisaster(usedCells: Uint8Array) { + const { cells, burgs } = pack; + + const burg = ra( + burgs.filter((b) => !usedCells[b.cell] && b.i && !b.removed), + ); + if (!burg) return; + usedCells[burg.cell] = 1; + + const cellsArray: number[] = []; + const cost: number[] = []; + const maxCells = rand(5, 25); + + const queue = new FlatQueue(); + queue.push({ e: burg.cell, p: 0 }, 0); + + while (queue.length) { + const next = queue.pop(); + if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); + usedCells[next.e] = 1; + + cells.c[next.e].forEach((e) => { + const c = rand(1, 10); + const p = next.p + c; + if (p > maxCells) return; + + if (!cost[e] || p < cost[e]) { + cost[e] = p; + queue.push({ e, p }, p); + } + }); + } + + const type = rw({ + Famine: 5, + Drought: 3, + Earthquake: 3, + Dearth: 1, + Tornadoes: 1, + Wildfires: 1, + Storms: 1, + Blight: 1, + }); + const name = `${getAdjective(burg.name!)} ${type}`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Disaster", + cells: cellsArray, + color: "url(#hatch5)", + }); + } + + private addEruption(usedCells: Uint8Array) { + const { cells, markers } = pack; + + const volcanoe = markers.find( + (m) => m.type === "volcanoes" && !usedCells[m.cell], + ); + if (!volcanoe) return; + usedCells[volcanoe.cell] = 1; + + const note = notes.find((n) => n.id === `marker${volcanoe.i}`); + if (note) + note.legend = note.legend.replace("Active volcano", "Erupting volcano"); + const name = note + ? `${note.name.replace(" Volcano", "")} Eruption` + : "Volcano Eruption"; + + const cellsArray: number[] = []; + const queue = [volcanoe.cell]; + const maxCells = rand(10, 30); + + while (queue.length) { + const cellId = P(0.5) ? queue.shift()! : queue.pop()!; + cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId] || cells.h[neibCellId] < 20) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + pack.zones.push({ + i: pack.zones.length, + name, + type: "Eruption", + cells: cellsArray, + color: "url(#hatch7)", + }); + } + + private addAvalanche(usedCells: Uint8Array) { + const { cells } = pack; + + const routeCells = cells.i.filter( + (i) => !usedCells[i] && Routes.isConnected(i) && cells.h[i] >= 70, + ); + if (!routeCells.length) return; + + const startCell = ra(routeCells); + usedCells[startCell] = 1; + + const cellsArray: number[] = []; + const queue = [startCell]; + const maxCells = rand(3, 15); + + while (queue.length) { + const cellId = P(0.3) ? queue.shift()! : queue.pop()!; + cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId] || cells.h[neibCellId] < 65) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(Names.getCultureShort(cells.culture[startCell]))} Avalanche`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Avalanche", + cells: cellsArray, + color: "url(#hatch5)", + }); + } + + private addFault(usedCells: Uint8Array) { + const cells = pack.cells; + + const elevatedCells = cells.i.filter( + (i) => !usedCells[i] && cells.h[i] > 50 && cells.h[i] < 70, + ); + if (!elevatedCells.length) return; + + const startCell = ra(elevatedCells); + usedCells[startCell] = 1; + + const cellsArray: number[] = []; + const queue = [startCell]; + const maxCells = rand(3, 15); + + while (queue.length) { + const cellId = queue.pop()!; + if (cells.h[cellId] >= 20) cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId] || cells.r[neibCellId]) return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(Names.getCultureShort(cells.culture[startCell]))} Fault`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Fault", + cells: cellsArray, + color: "url(#hatch2)", + }); + } + + private addFlood(usedCells: Uint8Array) { + const cells = pack.cells; + + const fl = cells.fl.filter(Boolean); + const meanFlux = mean(fl) ?? 0; + const maxFlux = max(fl) ?? 0; + const fluxThreshold = (maxFlux - meanFlux) / 2 + meanFlux; + + const bigRiverCells = cells.i.filter( + (i) => + !usedCells[i] && + cells.h[i] < 50 && + cells.r[i] && + cells.fl[i] > fluxThreshold && + cells.burg[i], + ); + if (!bigRiverCells.length) return; + + const startCell = ra(bigRiverCells); + usedCells[startCell] = 1; + + const riverId = cells.r[startCell]; + const cellsArray: number[] = []; + const queue = [startCell]; + const maxCells = rand(5, 30); + + while (queue.length) { + const cellId = queue.pop()!; + cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if ( + usedCells[neibCellId] || + cells.h[neibCellId] < 20 || + cells.r[neibCellId] !== riverId || + cells.h[neibCellId] > 50 || + cells.fl[neibCellId] < meanFlux + ) + return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(pack.burgs[cells.burg[startCell]].name!)} Flood`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Flood", + cells: cellsArray, + color: "url(#hatch13)", + }); + } + + private addTsunami(usedCells: Uint8Array) { + const { cells, features } = pack; + + const coastalCells = cells.i.filter( + (i) => + !usedCells[i] && + cells.t[i] === -1 && + features[cells.f[i]].type !== "lake", + ); + if (!coastalCells.length) return; + + const startCell = ra(coastalCells); + usedCells[startCell] = 1; + + const cellsArray: number[] = []; + const queue = [startCell]; + const maxCells = rand(10, 30); + + while (queue.length) { + const cellId = queue.shift()!; + if (cells.t[cellId] === 1) cellsArray.push(cellId); + if (cellsArray.length >= maxCells) break; + + cells.c[cellId].forEach((neibCellId) => { + if (usedCells[neibCellId]) return; + if (cells.t[neibCellId] > 2) return; + if (pack.features[cells.f[neibCellId]].type === "lake") return; + usedCells[neibCellId] = 1; + queue.push(neibCellId); + }); + } + + const name = `${getAdjective(Names.getCultureShort(cells.culture[startCell]))} Tsunami`; + pack.zones.push({ + i: pack.zones.length, + name, + type: "Tsunami", + cells: cellsArray, + color: "url(#hatch13)", + }); + } +} + +window.Zones = new ZonesModule(); diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 5e003af1..b8749f0a 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -5,6 +5,7 @@ import type { Province } from "../modules/provinces-generator"; import type { River } from "../modules/river-generator"; import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; +import type { Zone } from "../modules/zones-generator"; type TypedArray = | Uint8Array @@ -58,7 +59,8 @@ export interface PackedGraph { cultures: Culture[]; routes: Route[]; religions: any[]; - ice: any[]; + zones: Zone[]; markers: any[]; + ice: any[]; provinces: Province[]; }