From 3215b6f0d2ee09b6aa1154421ab3bc6ae2b6fdf6 Mon Sep 17 00:00:00 2001 From: max Date: Thu, 21 Jul 2022 00:23:37 +0300 Subject: [PATCH] refactor: rivers generation --- index.html | 2 +- .../index.ts => config/constants.ts} | 0 src/layers/renderers/drawRivers.ts | 4 +- src/modules/lakes.ts | 61 +- src/modules/markup.ts | 4 +- src/modules/river-generator.ts | 620 ------------------ src/modules/rivers.js | 210 ++++++ src/modules/ui/tools.js | 4 +- src/scripts/generation/generation.ts | 4 +- src/scripts/generation/pack/lakes.ts | 72 ++ src/scripts/generation/{ => pack}/pack.ts | 21 +- src/scripts/generation/pack/rivers.ts | 425 ++++++++++++ src/scripts/tooltips.ts | 2 +- src/types/overrides.d.ts | 2 +- src/types/pack/feature.d.ts | 3 +- src/types/pack/pack.d.ts | 6 +- src/utils/arrayUtils.ts | 2 +- vite.config.js | 1 - 18 files changed, 739 insertions(+), 704 deletions(-) rename src/{constants/index.ts => config/constants.ts} (100%) delete mode 100644 src/modules/river-generator.ts create mode 100644 src/modules/rivers.js create mode 100644 src/scripts/generation/pack/lakes.ts rename src/scripts/generation/{ => pack}/pack.ts (90%) create mode 100644 src/scripts/generation/pack/rivers.ts diff --git a/index.html b/index.html index a46d059b..b8d31638 100644 --- a/index.html +++ b/index.html @@ -7641,7 +7641,7 @@ - + diff --git a/src/constants/index.ts b/src/config/constants.ts similarity index 100% rename from src/constants/index.ts rename to src/config/constants.ts diff --git a/src/layers/renderers/drawRivers.ts b/src/layers/renderers/drawRivers.ts index 860418bf..43ea0b1f 100644 --- a/src/layers/renderers/drawRivers.ts +++ b/src/layers/renderers/drawRivers.ts @@ -1,3 +1,5 @@ +import {pick} from "utils/functionUtils"; + export function drawRivers(pack: IPack) { rivers.selectAll("*").remove(); @@ -12,7 +14,7 @@ export function drawRivers(pack: IPack) { points = undefined; } - const meanderedPoints = addMeandering(pack, cells, points); + const meanderedPoints = addMeandering(pick(pack.cells, "fl", "conf", "h", "p"), cells, points); const path = getRiverPath(meanderedPoints, widthFactor, sourceWidth); return ``; }); diff --git a/src/modules/lakes.ts b/src/modules/lakes.ts index 2f2f69af..ea419169 100644 --- a/src/modules/lakes.ts +++ b/src/modules/lakes.ts @@ -1,70 +1,13 @@ -// @ts-nocheckd +// @ts-nocheck import * as d3 from "d3"; import {TIME} from "config/logging"; -import {rn} from "utils/numberUtils"; import {aleaPRNG} from "scripts/aleaPRNG"; import {getInputNumber, getInputValue} from "utils/nodeUtils"; import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation"; import {byId} from "utils/shorthands"; -import {getRealHeight} from "utils/unitUtils"; window.Lakes = (function () { - const setClimateData = function ( - heights: Uint8Array, - lakes: IPackFeatureLake[], - gridReference: IPack["cells"]["g"], - precipitation: IGrid["cells"]["prec"], - temperature: IGrid["cells"]["temp"] - ) { - const lakeOutCells = new Uint16Array(gridReference.length); - - for (const lake of lakes) { - const {firstCell, shoreline} = lake; - - // default flux: sum of precipitation around lake - lake.flux = shoreline.reduce((acc, cellId) => acc + precipitation[gridReference[cellId]], 0); - - // temperature and evaporation to detect closed lakes - lake.temp = - lake.cells < 6 - ? temperature[gridReference[firstCell]] - : rn(d3.mean(shoreline.map(cellId => temperature[gridReference[cellId]]))!, 1); - - const height = getRealHeight(lake.height); // height in meters - const evaporation = ((700 * (lake.temp + 0.006 * height)) / 50 + 75) / (80 - lake.temp); // based on Penman formula, [1-11] - lake.evaporation = rn(evaporation * lake.cells); - - // no outlet for lakes in depressed areas - // if (lake.closed) continue; - - // lake outlet cell - const outCell = shoreline[d3.scan(shoreline, (a, b) => heights[a] - heights[b])!]; - lake.outCell = outCell; - lakeOutCells[lake.outCell] = lake.i; - } - - return lakeOutCells; - }; - - const cleanupLakeData = function (pack: IPack) { - 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 defineGroup = function (pack: IPack) { for (const feature of pack.features) { if (feature && feature.type === "lake") { @@ -220,8 +163,6 @@ window.Lakes = (function () { } return { - setClimateData, - cleanupLakeData, defineGroup, generateName, getName, diff --git a/src/modules/markup.ts b/src/modules/markup.ts index b9c79bba..f7c01d87 100644 --- a/src/modules/markup.ts +++ b/src/modules/markup.ts @@ -2,7 +2,7 @@ import * as d3 from "d3"; import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation"; import {TIME} from "config/logging"; -import {INT8_MAX} from "constants"; +import {INT8_MAX} from "config/constants"; import {aleaPRNG} from "scripts/aleaPRNG"; import {getFeatureVertices} from "scripts/connectVertices"; import {createTypedArray, unique} from "utils/arrayUtils"; @@ -254,7 +254,7 @@ function addFeature({ function getLakeElevation() { const MIN_ELEVATION_DELTA = 0.1; const minShoreHeight = d3.min(shoreline.map(cellId => heights[cellId])) || MIN_LAND_HEIGHT; - return minShoreHeight - MIN_ELEVATION_DELTA; + return rn(minShoreHeight - MIN_ELEVATION_DELTA, 2); } const feature: IPackFeatureLake = { diff --git a/src/modules/river-generator.ts b/src/modules/river-generator.ts deleted file mode 100644 index 97669393..00000000 --- a/src/modules/river-generator.ts +++ /dev/null @@ -1,620 +0,0 @@ -import * as d3 from "d3"; - -import {TIME, WARN} from "config/logging"; -import {last} from "utils/arrayUtils"; -import {rn} from "utils/numberUtils"; -import {round} from "utils/stringUtils"; -import {rw, each} from "utils/probabilityUtils"; -import {aleaPRNG} from "scripts/aleaPRNG"; -import {DISTANCE_FIELD, MAX_HEIGHT, MIN_LAND_HEIGHT} from "config/generation"; -import {getInputNumber} from "utils/nodeUtils"; -import {pick} from "utils/functionUtils"; -import {byId} from "utils/shorthands"; - -const {Lakes} = window; -const {LAND_COAST} = DISTANCE_FIELD; - -window.Rivers = (function () { - const generate = function ( - precipitation: IGrid["cells"]["prec"], - temperature: IGrid["cells"]["temp"], - cells: Pick, - features: TPackFeatures, - allowErosion = true - ) { - TIME && console.time("generateRivers"); - - Math.random = aleaPRNG(seed); - - const riversData = {}; // rivers data - const riverParents = {}; - - const cellsNumber = cells.i.length; - const riverIds = new Uint16Array(cellsNumber); - const confluence = new Uint8Array(cellsNumber); - - let nextRiverId = 1; // starts with 1 - - const gradientHeights = alterHeights({h: cells.h, c: cells.c, t: cells.t}); - const [currentCellHeights, currentLakeHeights] = resolveDepressions( - pick(cells, "i", "c", "b", "f"), - features, - gradientHeights - ); - - const flux = drainWater(); - defineRivers(); - - calculateConfluenceFlux(); - Lakes.cleanupLakeData(pack); - - if (allowErosion) { - cells.h = Uint8Array.from(currentCellHeights); // mutate heightmap - downcutRivers(); // downcut river beds - } - - TIME && console.timeEnd("generateRivers"); - - function drainWater() { - const MIN_FLUX_TO_FORM_RIVER = 30; - const points = Number(byId("pointsInput")?.dataset.cells); - const cellsNumberModifier = (points / 10000) ** 0.25; - - const land = cells.i.filter(i => currentCellHeights[i] >= MIN_LAND_HEIGHT); - land.sort((a, b) => currentCellHeights[b] - currentCellHeights[a]); - - const flux = new Uint16Array(cellsNumber); - - const lakes = features.filter(feature => feature && feature.type === "lake") as IPackFeatureLake[]; - const lakeOutCells = Lakes.setClimateData(currentCellHeights, lakes, cells.g, precipitation, temperature); - - land.forEach(cellId => { - flux[cellId] += precipitation[cells.g[cellId]] / cellsNumberModifier; - - // create lake outlet if lake is not in deep depression and flux > evaporation - const openLakes = lakeOutCells[cellId] - ? lakes.filter(({outCell, flux = 0, evaporation = 0}) => cellId === outCell && flux > evaporation) - : []; - - for (const lake of openLakes) { - const lakeCell = cells.c[cellId].find(c => currentCellHeights[c] < MIN_LAND_HEIGHT && cells.f[c] === lake.i); - flux[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet - - // allow chain lakes to retain identity - if (riverIds[lakeCell] !== lake.river) { - const sameRiver = cells.c[lakeCell].some(c => riverIds[c] === lake.river); - - if (sameRiver) { - riverIds[lakeCell] = lake.river; - addCellToRiver(lakeCell, lake.river); - } else { - riverIds[lakeCell] = nextRiverId; - addCellToRiver(lakeCell, nextRiverId); - nextRiverId++; - } - } - - lake.outlet = riverIds[lakeCell]; - flowDown(cellId, flux[lakeCell], lake.outlet); - } - - // assign all tributary rivers to outlet basin - const outlet = openLakes[0]?.outlet; - for (const lake of openLakes) { - if (!Array.isArray(lake.inlets)) continue; - for (const inlet of lake.inlets) { - riverParents[inlet] = outlet; - } - } - - // near-border cell: pour water out of the screen - if (cells.b[cellId] && riverIds[cellId]) return addCellToRiver(-1, riverIds[cellId]); - - // downhill cell (make sure it's not in the source lake) - let min = null; - if (lakeOutCells[cellId]) { - const filtered = cells.c[cellId].filter(c => !openLakes.map(lake => lake.i).includes(cells.f[c])); - min = filtered.sort((a, b) => alteredHeights[a] - alteredHeights[b])[0]; - } else if (cells.haven[cellId]) { - min = cells.haven[cellId]; - } else { - min = cells.c[cellId].sort((a, b) => alteredHeights[a] - alteredHeights[b])[0]; - } - - // cells is depressed - if (alteredHeights[cellId] <= alteredHeights[min]) return; - - // debug - // .append("line") - // .attr("x1", pack.cells.p[i][0]) - // .attr("y1", pack.cells.p[i][1]) - // .attr("x2", pack.cells.p[min][0]) - // .attr("y2", pack.cells.p[min][1]) - // .attr("stroke", "#333") - // .attr("stroke-width", 0.2); - - if (flux[cellId] < MIN_FLUX_TO_FORM_RIVER) { - // flux is too small to operate as a river - if (alteredHeights[min] >= 20) flux[min] += flux[cellId]; - return; - } - - // proclaim a new river - if (!riverIds[cellId]) { - riverIds[cellId] = nextRiverId; - addCellToRiver(cellId, nextRiverId); - nextRiverId++; - } - - flowDown(min, flux[cellId], riverIds[cellId]); - }); - - return flux; - } - - function addCellToRiver(cellId: number, riverId: number) { - if (!riversData[riverId]) riversData[riverId] = [cellId]; - else riversData[riverId].push(cellId); - } - - function flowDown(toCell, fromFlux, river) { - const toFlux = flux[toCell] - confluence[toCell]; - const toRiver = riverIds[toCell]; - - if (toRiver) { - // downhill cell already has river assigned - if (fromFlux > toFlux) { - confluence[toCell] += flux[toCell]; // mark confluence - if (alteredHeights[toCell] >= 20) riverParents[toRiver] = river; // min river is a tributary of current river - riverIds[toCell] = river; // re-assign river if downhill part has less flux - } else { - confluence[toCell] += fromFlux; // mark confluence - if (alteredHeights[toCell] >= 20) riverParents[river] = toRiver; // current river is a tributary of min river - } - } else riverIds[toCell] = river; // assign the river to the downhill cell - - if (alteredHeights[toCell] < 20) { - // pour water to the water body - const waterBody = features[cells.f[toCell]]; - if (waterBody.type === "lake") { - if (!waterBody.river || fromFlux > waterBody.enteringFlux) { - waterBody.river = river; - waterBody.enteringFlux = fromFlux; - } - waterBody.flux = waterBody.flux + fromFlux; - if (!waterBody.inlets) waterBody.inlets = [river]; - else waterBody.inlets.push(river); - } - } else { - // propagate flux and add next river segment - flux[toCell] += fromFlux; - } - - addCellToRiver(toCell, river); - } - - function defineRivers() { - // re-initialize rivers and confluence arrays - riverIds = new Uint16Array(cellsNumber); - confluence = new Uint16Array(cellsNumber); - pack.rivers = []; - - const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); - const mainStemWidthFactor = defaultWidthFactor * 1.2; - - for (const key in riversData) { - const riverCells = riversData[key]; - if (riverCells.length < 3) continue; // exclude tiny rivers - - const riverId = +key; - for (const cell of riverCells) { - if (cell < 0 || cells.h[cell] < 20) continue; - - // mark real confluences and assign river to cells - if (riverIds[cell]) confluence[cell] = 1; - else riverIds[cell] = riverId; - } - - const source = riverCells[0]; - const mouth = riverCells[riverCells.length - 2]; - const parent = riverParents[key] || 0; - - const widthFactor = !parent || parent === riverId ? mainStemWidthFactor : defaultWidthFactor; - const meanderedPoints = addMeandering(pack, riverCells); - const discharge = flux[mouth]; // m3 in second - const length = getApproximateLength(meanderedPoints); - const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, 0)); - - pack.rivers.push({ - i: riverId, - source, - mouth, - discharge, - length, - width, - widthFactor, - sourceWidth: 0, - parent, - cells: riverCells - }); - } - } - - function downcutRivers() { - const MAX_DOWNCUT = 5; - - for (const i of pack.cells.i) { - if (cells.h[i] < 35) continue; // don't donwcut lowlands - if (!flux[i]) continue; - - const higherCells = cells.c[i].filter(c => cells.h[c] > cells.h[i]); - const higherFlux = higherCells.reduce((acc, c) => acc + flux[c], 0) / higherCells.length; - if (!higherFlux) continue; - - const downcut = Math.floor(flux[i] / higherFlux); - if (downcut) cells.h[i] -= Math.min(downcut, MAX_DOWNCUT); - } - } - - function calculateConfluenceFlux() { - for (const i of cells.i) { - if (!confluence[i]) continue; - - const sortedInflux = cells.c[i] - .filter(c => riverIds[c] && alteredHeights[c] > alteredHeights[i]) - .map(c => flux[c]) - .sort((a, b) => b - a); - confluence[i] = sortedInflux.reduce((acc, flux, index) => (index ? acc + flux : acc), 0); - } - } - }; - - // add distance to water value to land cells to make map less depressed - const alterHeights = ({h, c, t}: Pick) => { - return Array.from(h).map((height, index) => { - if (height < MIN_LAND_HEIGHT || t[index] < LAND_COAST) return height; - const mean = d3.mean(c[index].map(c => t[c])) || 0; - return height + t[index] / 100 + mean / 10000; - }); - }; - - // depression filling algorithm (for a correct water flux modeling) - const resolveDepressions = function ( - cells: Pick, - features: TPackFeatures, - heights: number[] - ): [number[], Dict] { - const MAX_INTERATIONS = getInputNumber("resolveDepressionsStepsOutput"); - const checkLakeMaxIteration = MAX_INTERATIONS * 0.85; - const elevateLakeMaxIteration = MAX_INTERATIONS * 0.75; - - const ELEVATION_LIMIT = getInputNumber("lakeElevationLimitOutput"); - - const LAND_ELEVATION_INCREMENT = 0.1; - const LAKE_ELEVATION_INCREMENT = 0.2; - - const lakes = features.filter(feature => feature && feature.type === "lake") as IPackFeatureLake[]; - lakes.sort((a, b) => a.height - b.height); // lowest lakes go first - - const currentCellHeights = Array.from(heights); - const currentLakeHeights = Object.fromEntries(lakes.map(({i, height}) => [i, height])); - - const getHeight = (i: number) => currentLakeHeights[cells.f[i]] || currentCellHeights[i]; - const getMinHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(getHeight)); - - const drainableLakes = checkLakesDrainability(); - - const landCells = cells.i.filter(i => heights[i] >= MIN_LAND_HEIGHT && !cells.b[i]); - landCells.sort((a, b) => heights[a] - heights[b]); // lowest cells go first - - const depressions: number[] = []; - - for (let iteration = 0; iteration && depressions.at(-1) && iteration < MAX_INTERATIONS; iteration++) { - let depressionsLeft = 0; - - // elevate potentially drainable lakes - if (iteration < checkLakeMaxIteration) { - for (const lake of lakes) { - if (drainableLakes[lake.i] !== true) continue; - - const minShoreHeight = getMinHeight(lake.shoreline); - if (minShoreHeight >= MAX_HEIGHT || lake.height > minShoreHeight) continue; - - if (iteration > elevateLakeMaxIteration) { - for (const shoreCellId of lake.shoreline) { - // reset heights - currentCellHeights[shoreCellId] = heights[shoreCellId]; - currentLakeHeights[lake.i] = lake.height; - } - - drainableLakes[lake.i] = false; - continue; - } - - currentLakeHeights[lake.i] = minShoreHeight + LAKE_ELEVATION_INCREMENT; - depressionsLeft++; - } - } - - for (const cellId of landCells) { - const minHeight = getMinHeight(cells.c[cellId]); - if (minHeight >= MAX_HEIGHT || currentCellHeights[cellId] > minHeight) continue; - - currentCellHeights[cellId] = minHeight + LAND_ELEVATION_INCREMENT; - depressionsLeft++; - } - - depressions.push(depressionsLeft); - - // check depression resolving progress - if (depressions.length > 5) { - const depressionsInitial = depressions.at(0) || 0; - const depressiosRecently = depressions.at(-6) || 0; - - const isProgressingOverall = depressionsInitial < depressionsLeft; - if (!isProgressingOverall) return [heights, Object.fromEntries(lakes.map(({i, height}) => [i, height]))]; - - const isProgressingRecently = depressiosRecently < depressionsLeft; - if (!isProgressingRecently) return [currentCellHeights, currentLakeHeights]; - } - } - - // define lakes that potentially can be open (drained into another water body) - function checkLakesDrainability() { - const canBeDrained: Dict = {}; // all false by default - const drainAllLakes = ELEVATION_LIMIT === MAX_HEIGHT - MIN_LAND_HEIGHT; - - for (const lake of lakes) { - if (drainAllLakes) { - canBeDrained[lake.i] = true; - continue; - } - - canBeDrained[lake.i] = false; - const minShoreHeight = getMinHeight(lake.shoreline); - const minHeightShoreCell = - lake.shoreline.find(cellId => heights[cellId] === minShoreHeight) || lake.shoreline[0]; - - const queue = [minHeightShoreCell]; - const checked = []; - checked[minHeightShoreCell] = true; - const breakableHeight = lake.height + ELEVATION_LIMIT; - - loopCellsAroundLake: while (queue.length) { - const cellId = queue.pop()!; - - for (const neibCellId of cells.c[cellId]) { - if (checked[neibCellId]) continue; - if (heights[neibCellId] >= breakableHeight) continue; - - if (heights[neibCellId] < MIN_LAND_HEIGHT) { - const waterFeatureMet = features[cells.f[neibCellId]]; - const isOceanMet = waterFeatureMet && waterFeatureMet.type === "ocean"; - const isLakeMet = waterFeatureMet && waterFeatureMet.type === "lake"; - - if (isOceanMet || (isLakeMet && lake.height > waterFeatureMet.height)) { - canBeDrained[lake.i] = true; - break loopCellsAroundLake; - } - } - - checked[neibCellId] = true; - queue.push(neibCellId); - } - } - } - - return canBeDrained; - } - - depressions && WARN && console.warn(`Unresolved depressions: ${depressions}. Edit heightmap to fix`); - - return [currentCellHeights, currentLakeHeights]; - }; - - // add points at 1/3 and 2/3 of a line between adjacents river cells - const addMeandering = (pack, riverCells, riverPoints = null, meandering = 0.5) => { - const {fl, conf, h} = pack.cells; - const meandered = []; - const lastStep = riverCells.length - 1; - const points = getRiverPoints(pack, riverCells, riverPoints); - let step = h[riverCells[0]] < 20 ? 1 : 10; - - let fluxPrev = 0; - const getFlux = (step, flux) => (step === lastStep ? fluxPrev : flux); - - for (let i = 0; i <= lastStep; i++, step++) { - const cell = riverCells[i]; - const isLastCell = i === lastStep; - - const [x1, y1] = points[i]; - const flux1 = getFlux(i, fl[cell]); - fluxPrev = flux1; - - meandered.push([x1, y1, flux1]); - if (isLastCell) break; - - const nextCell = riverCells[i + 1]; - const [x2, y2] = points[i + 1]; - - if (nextCell === -1) { - meandered.push([x2, y2, fluxPrev]); - break; - } - - const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells - if (dist2 <= 25 && riverCells.length >= 6) continue; - - const flux2 = getFlux(i + 1, fl[nextCell]); - const keepInitialFlux = conf[nextCell] || flux1 === flux2; - - 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; - - if (step < 10 && (dist2 > 64 || (dist2 > 36 && riverCells.length < 5))) { - // if dist2 is big or river is small add extra points at 1/3 and 2/3 of segment - const p1x = (x1 * 2 + x2) / 3 + -sinMeander; - const p1y = (y1 * 2 + y2) / 3 + cosMeander; - const p2x = (x1 + x2 * 2) / 3 + sinMeander / 2; - const p2y = (y1 + y2 * 2) / 3 - cosMeander / 2; - const [p1fl, p2fl] = keepInitialFlux ? [flux1, flux1] : [(flux1 * 2 + flux2) / 3, (flux1 + flux2 * 2) / 3]; - meandered.push([p1x, p1y, p1fl], [p2x, p2y, p2fl]); - } else if (dist2 > 25 || riverCells.length < 6) { - // if dist is medium or river is small add 1 extra middlepoint - const p1x = (x1 + x2) / 2 + -sinMeander; - const p1y = (y1 + y2) / 2 + cosMeander; - const p1fl = keepInitialFlux ? flux1 : (flux1 + flux2) / 2; - meandered.push([p1x, p1y, p1fl]); - } - } - - return meandered; - }; - - const getRiverPoints = (pack, riverCells, riverPoints) => { - if (riverPoints) return riverPoints; - - const {p} = pack.cells; - return riverCells.map((cell, i) => { - if (cell === -1) return getBorderPoint(pack, riverCells[i - 1]); - return p[cell]; - }); - }; - - const getBorderPoint = (pack, i) => { - const [x, y] = pack.cells.p[i]; - const min = Math.min(y, graphHeight - y, x, graphWidth - x); - if (min === y) return [x, 0]; - else if (min === graphHeight - y) return [x, graphHeight]; - else if (min === x) return [0, y]; - return [graphWidth, y]; - }; - - const FLUX_FACTOR = 500; - const MAX_FLUX_WIDTH = 2; - const LENGTH_FACTOR = 200; - const STEP_WIDTH = 1 / LENGTH_FACTOR; - const LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / LENGTH_FACTOR); - const MAX_PROGRESSION = last(LENGTH_PROGRESSION); - - const getOffset = (flux, pointNumber, widthFactor, startingWidth = 0) => { - const fluxWidth = Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH); - const lengthWidth = pointNumber * STEP_WIDTH + (LENGTH_PROGRESSION[pointNumber] || MAX_PROGRESSION); - return widthFactor * (lengthWidth + fluxWidth) + startingWidth; - }; - - const lineGen = d3.line().curve(d3.curveBasis); - - // build polygon from a list of points and calculated offset (width) - const getRiverPath = function (points, widthFactor, startingWidth = 0) { - const riverPointsLeft = []; - const riverPointsRight = []; - - for (let p = 0; p < points.length; p++) { - const [x0, y0] = points[p - 1] || points[p]; - const [x1, y1, flux] = points[p]; - const [x2, y2] = points[p + 1] || points[p]; - - const offset = getOffset(flux, p, widthFactor, startingWidth); - const angle = Math.atan2(y0 - y2, x0 - x2); - const sinOffset = Math.sin(angle) * offset; - const cosOffset = Math.cos(angle) * offset; - - riverPointsLeft.push([x1 - sinOffset, y1 + cosOffset]); - riverPointsRight.push([x1 + sinOffset, y1 - cosOffset]); - } - - const right = lineGen(riverPointsRight.reverse()); - let left = lineGen(riverPointsLeft); - left = left.substring(left.indexOf("C")); - - return round(right + left, 1); - }; - - const specify = function () { - 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); - } - }; - - const getName = function (cell) { - 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) { - const threshold = Math.ceil(pack.rivers.length * 0.15); - smallLength = pack.rivers.map(r => r.length || 0).sort((a, b) => a - b)[threshold]; - } - - const isSmall = length < smallLength; - const isFork = each(3)(i) && parent && parent !== i; - return rw(riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]); - }; - - const getApproximateLength = points => { - 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: number) => rn((offset / 1.5) ** 1.8, 2); // mouth width in km - - // remove river and all its tributaries - const remove = function (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()); - 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)); - }; - - const getBasin = function (riverId: number) { - const parent = pack.rivers.find(river => river.i === riverId)?.parent; - if (!parent || riverId === parent) return riverId; - return getBasin(parent); - }; - - return { - generate, - alterHeights, - resolveDepressions, - addMeandering, - getRiverPath, - specify, - getName, - getType, - getBasin, - getWidth, - getOffset, - getApproximateLength, - getRiverPoints, - remove - }; -})(); diff --git a/src/modules/rivers.js b/src/modules/rivers.js new file mode 100644 index 00000000..b0148789 --- /dev/null +++ b/src/modules/rivers.js @@ -0,0 +1,210 @@ +import * as d3 from "d3"; + +import {last} from "utils/arrayUtils"; +import {rn} from "utils/numberUtils"; +import {round} from "utils/stringUtils"; +import {rw, each} from "utils/probabilityUtils"; +import {MIN_LAND_HEIGHT} from "config/generation"; + +window.Rivers = (function () { + // add points at 1/3 and 2/3 of a line between adjacents river cells + const addMeandering = ({fl, conf, h, p}, riverCells, riverPoints = null, meandering = 0.5) => { + const meandered = []; + const lastStep = riverCells.length - 1; + const points = getRiverPoints(p, riverCells, riverPoints); + let step = h[riverCells[0]] < MIN_LAND_HEIGHT ? 1 : 10; + + let fluxPrev = 0; + const getFlux = (step, flux) => (step === lastStep ? fluxPrev : flux); + + for (let i = 0; i <= lastStep; i++, step++) { + const cell = riverCells[i]; + const isLastCell = i === lastStep; + + const [x1, y1] = points[i]; + const flux1 = getFlux(i, fl[cell]); + fluxPrev = flux1; + + meandered.push([x1, y1, flux1]); + if (isLastCell) break; + + const nextCell = riverCells[i + 1]; + const [x2, y2] = points[i + 1]; + + if (nextCell === -1) { + meandered.push([x2, y2, fluxPrev]); + break; + } + + const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells + if (dist2 <= 25 && riverCells.length >= 6) continue; + + const flux2 = getFlux(i + 1, fl[nextCell]); + const keepInitialFlux = conf[nextCell] || flux1 === flux2; + + 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; + + if (step < 10 && (dist2 > 64 || (dist2 > 36 && riverCells.length < 5))) { + // if dist2 is big or river is small add extra points at 1/3 and 2/3 of segment + const p1x = (x1 * 2 + x2) / 3 + -sinMeander; + const p1y = (y1 * 2 + y2) / 3 + cosMeander; + const p2x = (x1 + x2 * 2) / 3 + sinMeander / 2; + const p2y = (y1 + y2 * 2) / 3 - cosMeander / 2; + const [p1fl, p2fl] = keepInitialFlux ? [flux1, flux1] : [(flux1 * 2 + flux2) / 3, (flux1 + flux2 * 2) / 3]; + meandered.push([p1x, p1y, p1fl], [p2x, p2y, p2fl]); + } else if (dist2 > 25 || riverCells.length < 6) { + // if dist is medium or river is small add 1 extra middlepoint + const p1x = (x1 + x2) / 2 + -sinMeander; + const p1y = (y1 + y2) / 2 + cosMeander; + const p1fl = keepInitialFlux ? flux1 : (flux1 + flux2) / 2; + meandered.push([p1x, p1y, p1fl]); + } + } + + return meandered; + }; + + const getRiverPoints = (points, riverCells, riverPoints) => { + if (riverPoints) return riverPoints; + + return riverCells.map((cell, i) => { + if (cell === -1) return getBorderPoint(points, riverCells[i - 1]); + return points[cell]; + }); + }; + + const getBorderPoint = (points, i) => { + const [x, y] = points[i]; + const min = Math.min(y, graphHeight - y, x, graphWidth - x); + if (min === y) return [x, 0]; + else if (min === graphHeight - y) return [x, graphHeight]; + else if (min === x) return [0, y]; + return [graphWidth, y]; + }; + + const FLUX_FACTOR = 500; + const MAX_FLUX_WIDTH = 2; + const LENGTH_FACTOR = 200; + const STEP_WIDTH = 1 / LENGTH_FACTOR; + const LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / LENGTH_FACTOR); + const MAX_PROGRESSION = last(LENGTH_PROGRESSION); + + const getOffset = (flux, pointNumber, widthFactor, startingWidth = 0) => { + const fluxWidth = Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH); + const lengthWidth = pointNumber * STEP_WIDTH + (LENGTH_PROGRESSION[pointNumber] || MAX_PROGRESSION); + return widthFactor * (lengthWidth + fluxWidth) + startingWidth; + }; + + const lineGen = d3.line().curve(d3.curveBasis); + + // build polygon from a list of points and calculated offset (width) + const getRiverPath = function (points, widthFactor, startingWidth = 0) { + const riverPointsLeft = []; + const riverPointsRight = []; + + for (let p = 0; p < points.length; p++) { + const [x0, y0] = points[p - 1] || points[p]; + const [x1, y1, flux] = points[p]; + const [x2, y2] = points[p + 1] || points[p]; + + const offset = getOffset(flux, p, widthFactor, startingWidth); + const angle = Math.atan2(y0 - y2, x0 - x2); + const sinOffset = Math.sin(angle) * offset; + const cosOffset = Math.cos(angle) * offset; + + riverPointsLeft.push([x1 - sinOffset, y1 + cosOffset]); + riverPointsRight.push([x1 + sinOffset, y1 - cosOffset]); + } + + const right = lineGen(riverPointsRight.reverse()); + let left = lineGen(riverPointsLeft); + left = left.substring(left.indexOf("C")); + + return round(right + left, 1); + }; + + const specify = function () { + 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); + } + }; + + const getName = function (cell) { + 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) { + const threshold = Math.ceil(pack.rivers.length * 0.15); + smallLength = pack.rivers.map(r => r.length || 0).sort((a, b) => a - b)[threshold]; + } + + const isSmall = length < smallLength; + const isFork = each(3)(i) && parent && parent !== i; + return rw(riverTypes[isFork ? "fork" : "main"][isSmall ? "small" : "big"]); + }; + + const getApproximateLength = points => { + 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 + + // remove river and all its tributaries + const remove = function (id) { + 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()); + 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)); + }; + + const getBasin = function (riverId) { + const parent = pack.rivers.find(river => river.i === riverId)?.parent; + if (!parent || riverId === parent) return riverId; + return getBasin(parent); + }; + + return { + addMeandering, + getRiverPath, + specify, + getName, + getType, + getBasin, + getWidth, + getOffset, + getApproximateLength, + getRiverPoints, + remove + }; +})(); diff --git a/src/modules/ui/tools.js b/src/modules/ui/tools.js index eff5f287..867fcd82 100644 --- a/src/modules/ui/tools.js +++ b/src/modules/ui/tools.js @@ -570,7 +570,7 @@ function addRiverOnClick() { if (cells.b[i]) return; const { - alterHeights, + applyDistanceField, resolveDepressions, addMeandering, getRiverPath, @@ -588,7 +588,7 @@ function addRiverOnClick() { const initialFlux = grid.cells.prec[cells.g[i]]; cells.fl[i] = initialFlux; - const h = alterHeights(pacl.cells); + const h = applyDistanceField(pacl.cells); resolveDepressions(pack, h); while (i) { diff --git a/src/scripts/generation/generation.ts b/src/scripts/generation/generation.ts index e5d64387..00699cc0 100644 --- a/src/scripts/generation/generation.ts +++ b/src/scripts/generation/generation.ts @@ -26,7 +26,7 @@ import {generateSeed} from "utils/probabilityUtils"; import {byId} from "utils/shorthands"; import {showStatistics} from "../statistics"; import {createGrid} from "./grid"; -import {createPack} from "./pack"; +import {createPack} from "./pack/pack"; import {getInputValue, setInputValue} from "utils/nodeUtils"; // import {Ruler} from "modules/measurers"; @@ -68,7 +68,7 @@ async function generate(options?: IGenerationOptions) { renderLayer("rivers", pack); WARN && console.warn(`TOTAL: ${rn((performance.now() - timeStart) / 1000, 2)}s`); - showStatistics(); + // showStatistics(); INFO && console.groupEnd(); } catch (error) { showGenerationError(error as Error); diff --git a/src/scripts/generation/pack/lakes.ts b/src/scripts/generation/pack/lakes.ts new file mode 100644 index 00000000..333ea114 --- /dev/null +++ b/src/scripts/generation/pack/lakes.ts @@ -0,0 +1,72 @@ +// @ts-nocheckd +import * as d3 from "d3"; + +import {rn} from "utils/numberUtils"; +import {getRealHeight} from "utils/unitUtils"; + +export interface ILakeClimateData extends IPackFeatureLake { + flux: number; + temp: number; + evaporation: number; + outCell: number | undefined; + river?: number; + enteringFlux?: number; +} + +export const getClimateData = function ( + lakes: IPackFeatureLake[], + heights: number[], + drainableLakes: Dict, + gridReference: IPack["cells"]["g"], + precipitation: IGrid["cells"]["prec"], + temperature: IGrid["cells"]["temp"] +): ILakeClimateData[] { + const lakeData = lakes.map(lake => { + const {shoreline} = lake; + + // default flux: sum of precipitation around lake + const flux = shoreline.reduce((acc, cellId) => acc + precipitation[gridReference[cellId]], 0); + + // temperature and evaporation to detect closed lakes + const temp = rn(d3.mean(shoreline.map(cellId => temperature[gridReference[cellId]]))!, 1); + + const height = getRealHeight(lake.height); // height in meters + const cellEvaporation = ((700 * (temp + 0.006 * height)) / 50 + 75) / (80 - temp); // based on Penman formula, [1-11] + const evaporation = rn(cellEvaporation * lake.cells); + + const outCell = + flux > evaporation && drainableLakes[lake.i] + ? shoreline[d3.scan(shoreline, (a, b) => heights[a] - heights[b])!] + : undefined; + + return {...lake, flux, temp, evaporation, outCell}; + }); + + return lakeData; +}; + +export const mergeLakeData = function ( + features: TPackFeatures, + lakeData: ILakeClimateData[], + rivers: Pick[] +) { + const updatedFeatures = features.map(feature => { + if (!feature) return 0; + if (feature.type !== "lake") return feature; + + const lake = lakeData.find(lake => lake.i === feature.i); + if (!lake) return feature; + + const {flux, temp, evaporation} = lake; + const inlets = lake.inlets?.filter(inlet => rivers.find(river => river.i === inlet)); + const outlet = rivers.find(river => river.i === lake.outlet)?.i; + + const lakeFeature: IPackFeatureLake = {...feature, flux, temp, evaporation, inlets, outlet}; + if (!inlets || !inlets.length) delete lakeFeature.inlets; + if (!outlet) delete lakeFeature.outlet; + + return lakeFeature; + }); + + return updatedFeatures as TPackFeatures; +}; diff --git a/src/scripts/generation/pack.ts b/src/scripts/generation/pack/pack.ts similarity index 90% rename from src/scripts/generation/pack.ts rename to src/scripts/generation/pack/pack.ts index 1bde2e60..be15c068 100644 --- a/src/scripts/generation/pack.ts +++ b/src/scripts/generation/pack/pack.ts @@ -9,14 +9,15 @@ import {drawScaleBar} from "modules/measurers"; import {addZones} from "modules/zones"; import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation"; import {TIME} from "config/logging"; -import {UINT16_MAX} from "constants"; +import {UINT16_MAX} from "config/constants"; import {calculateVoronoi} from "scripts/generation/graph"; import {createTypedArray} from "utils/arrayUtils"; import {pick} from "utils/functionUtils"; import {rn} from "utils/numberUtils"; +import {generateRivers} from "./rivers"; const {LAND_COAST, WATER_COAST, DEEPER_WATER} = DISTANCE_FIELD; -const {Lakes, OceanLayers, Rivers, Biomes, Cultures, BurgsAndStates, Religions, Military, Markers, Names} = window; +// const {Lakes, OceanLayers, Biomes, Cultures, BurgsAndStates, Religions, Military, Markers, Names} = window; export function createPack(grid: IGrid): IPack { const {vertices, cells} = repackGrid(grid); @@ -24,12 +25,11 @@ export function createPack(grid: IGrid): IPack { const markup = markupPackFeatures(grid, vertices, pick(cells, "v", "c", "b", "p", "h")); const {features, featureIds, distanceField, haven, harbor} = markup; - Rivers.generate( + const {heights, flux, r, conf, rivers, mergedFeatures} = generateRivers( grid.cells.prec, grid.cells.temp, - pick({...cells, f: featureIds, t: distanceField, haven}, "i", "c", "b", "g", "t", "h", "f", "haven"), - features, - true + pick({...cells, f: featureIds, t: distanceField, haven}, "i", "c", "b", "g", "t", "h", "f", "haven", "p"), + features ); // Lakes.defineGroup(newPack); @@ -66,12 +66,17 @@ export function createPack(grid: IGrid): IPack { vertices, cells: { ...cells, + h: new Uint8Array(heights), f: featureIds, t: distanceField, haven, - harbor + harbor, + fl: flux, + r, + conf }, - features + features: mergedFeatures, + rivers }; return pack; diff --git a/src/scripts/generation/pack/rivers.ts b/src/scripts/generation/pack/rivers.ts new file mode 100644 index 00000000..4d6132bd --- /dev/null +++ b/src/scripts/generation/pack/rivers.ts @@ -0,0 +1,425 @@ +import * as d3 from "d3"; + +import {TIME, WARN} from "config/logging"; +import {rn} from "utils/numberUtils"; +import {aleaPRNG} from "scripts/aleaPRNG"; +import {DISTANCE_FIELD, MAX_HEIGHT, MIN_LAND_HEIGHT} from "config/generation"; +import {getInputNumber} from "utils/nodeUtils"; +import {pick} from "utils/functionUtils"; +import {byId} from "utils/shorthands"; +import {mergeLakeData, getClimateData, ILakeClimateData} from "./lakes"; + +const {Rivers} = window; +const {LAND_COAST} = DISTANCE_FIELD; + +export function generateRivers( + precipitation: IGrid["cells"]["prec"], + temperature: IGrid["cells"]["temp"], + cells: Pick, + features: TPackFeatures +) { + TIME && console.time("generateRivers"); + + Math.random = aleaPRNG(seed); + + const riversData: {[river: string]: number[]} = {}; + const riverParents: {[river: string]: number} = {}; + + const cellsNumber = cells.i.length; + + let nextRiverId = 1; // starts with 1 + + const gradientHeights = applyDistanceField({h: cells.h, c: cells.c, t: cells.t}); + const [currentCellHeights, drainableLakes] = resolveDepressions( + pick(cells, "i", "c", "b", "f"), + features, + gradientHeights + ); + + const points = Number(byId("pointsInput")?.dataset.cells); + const cellsNumberModifier = (points / 10000) ** 0.25; + + const {flux, lakeData} = drainWater(); + const {r, conf, rivers} = defineRivers(); + const heights = downcutRivers(currentCellHeights); + + const mergedFeatures = mergeLakeData(features, lakeData, rivers); + + TIME && console.timeEnd("generateRivers"); + + return {heights, flux, r, conf, rivers, mergedFeatures}; + + function drainWater() { + const MIN_FLUX_TO_FORM_RIVER = 30; + + const riverIds = new Uint16Array(cellsNumber); + const confluence = new Uint8Array(cellsNumber); + const flux = new Uint16Array(cellsNumber); + + const lakes = features.filter(feature => feature && feature.type === "lake") as IPackFeatureLake[]; + + const lakeData: ILakeClimateData[] = getClimateData( + lakes, + currentCellHeights, + drainableLakes, + cells.g, + precipitation, + temperature + ); + const openLakes = lakeData.filter(lake => lake.outCell !== undefined); + + const land = cells.i.filter(i => currentCellHeights[i] >= MIN_LAND_HEIGHT); + land.sort((a, b) => currentCellHeights[b] - currentCellHeights[a]); + + land.forEach(cellId => { + flux[cellId] += precipitation[cells.g[cellId]] / cellsNumberModifier; + + const lakesDrainingToCell = openLakes.filter(lake => lake.outCell === cellId); + for (const lake of lakesDrainingToCell) { + const lakeCell = cells.c[cellId].find(c => currentCellHeights[c] < MIN_LAND_HEIGHT && cells.f[c] === lake.i); + if (!lakeCell) continue; + + flux[lakeCell] += Math.max(lake.flux - lake.evaporation, 0); // not evaporated lake water drains to outlet + + // allow to chain lakes to keep river identity + if (riverIds[lakeCell] !== lake.river) { + const sameRiver = cells.c[lakeCell].some(c => riverIds[c] === lake.river); + + if (lake.river && sameRiver) { + riverIds[lakeCell] = lake.river; + addCellToRiver(lakeCell, lake.river); + } else { + riverIds[lakeCell] = nextRiverId; + addCellToRiver(lakeCell, nextRiverId); + nextRiverId++; + } + } + + lake.outlet = riverIds[lakeCell]; + flowDown(cellId, flux[lakeCell], lake.outlet); + } + + if (lakesDrainingToCell.length && lakesDrainingToCell[0].outlet) { + // assign all tributary rivers to outlet basin + const outlet = lakesDrainingToCell[0].outlet; + for (const lakeDrainingToCell of lakesDrainingToCell) { + if (!Array.isArray(lakeDrainingToCell.inlets)) continue; + for (const inlet of lakeDrainingToCell.inlets) { + riverParents[inlet] = outlet; + } + } + } + + // near-border cell: pour water out of the screen + if (cells.b[cellId] && riverIds[cellId]) return addCellToRiver(-1, riverIds[cellId]); + + // downhill cell (make sure it's not in the source lake) + let min = null; + if (lakesDrainingToCell.length) { + const filtered = cells.c[cellId].filter(c => !lakesDrainingToCell.map(lake => lake.i).includes(cells.f[c])); + min = filtered.sort((a, b) => currentCellHeights[a] - currentCellHeights[b])[0]; + } else if (cells.haven[cellId]) { + min = cells.haven[cellId]; + } else { + min = cells.c[cellId].sort((a, b) => currentCellHeights[a] - currentCellHeights[b])[0]; + } + + // cells is depressed + if (currentCellHeights[cellId] <= currentCellHeights[min]) return; + + debug + .append("line") + .attr("x1", cells.p[cellId][0]) + .attr("y1", cells.p[cellId][1]) + .attr("x2", cells.p[min][0]) + .attr("y2", cells.p[min][1]) + .attr("stroke", "#333") + .attr("stroke-width", 0.1); + + if (flux[cellId] < MIN_FLUX_TO_FORM_RIVER) { + // flux is too small to operate as a river + if (currentCellHeights[min] >= MIN_LAND_HEIGHT) flux[min] += flux[cellId]; + return; + } + + // create a new river + if (!riverIds[cellId]) { + riverIds[cellId] = nextRiverId; + addCellToRiver(cellId, nextRiverId); + nextRiverId++; + } + + flowDown(min, flux[cellId], riverIds[cellId]); + }); + + return {flux, lakeData}; + + function flowDown(toCell: number, fromFlux: number, riverId: number) { + const toFlux = flux[toCell] - confluence[toCell]; + const toRiver = riverIds[toCell]; + + if (toRiver) { + // downhill cell already has river assigned + if (fromFlux > toFlux) { + confluence[toCell] += flux[toCell]; // mark confluence + if (currentCellHeights[toCell] >= MIN_LAND_HEIGHT) { + // min river is a tributary of current river + riverParents[toRiver] = riverId; + } + riverIds[toCell] = riverId; // re-assign river if downhill part has less flux + } else { + confluence[toCell] += fromFlux; // mark confluence + if (currentCellHeights[toCell] >= MIN_LAND_HEIGHT) { + // current river is a tributary of min river + riverParents[riverId] = toRiver; + } + } + } else riverIds[toCell] = riverId; // assign the river to the downhill cell + + if (currentCellHeights[toCell] < MIN_LAND_HEIGHT) { + // pour water to the water body + const lake = lakeData.find(lake => lake.i === cells.f[toCell]); + + if (lake) { + if (!lake.river || fromFlux > (lake.enteringFlux || 0)) { + lake.river = riverId; + lake.enteringFlux = fromFlux; + } + lake.flux = lake.flux + fromFlux; + if (lake.inlets) lake.inlets.push(riverId); + else lake.inlets = [riverId]; + } + } else { + // propagate flux and add next river segment + flux[toCell] += fromFlux; + } + + addCellToRiver(toCell, riverId); + } + + function addCellToRiver(cellId: number, riverId: number) { + if (riversData[riverId]) riversData[riverId].push(cellId); + else riversData[riverId] = [cellId]; + } + } + + function defineRivers() { + const r = new Uint16Array(cellsNumber); + const conf = new Uint16Array(cellsNumber); + const rivers: Omit[] = []; + + const defaultWidthFactor = rn(1 / cellsNumberModifier, 2); + const mainStemWidthFactor = defaultWidthFactor * 1.2; + + for (const key in riversData) { + const riverId = +key; + const riverCells = riversData[key]; + if (riverCells.length < 3) continue; // exclude tiny rivers + + for (const cell of riverCells) { + if (cell < 0 || cells.h[cell] < MIN_LAND_HEIGHT) continue; + + // mark confluences and assign river to cells + if (r[cell]) conf[cell] = 1; + else r[cell] = riverId; + } + + const source = riverCells[0]; + const mouth = riverCells.at(-2) || 0; + const parent = riverParents[key] || 0; + + const widthFactor = !parent || parent === riverId ? mainStemWidthFactor : defaultWidthFactor; + const meanderedPoints: number[] = Rivers.addMeandering({fl: flux, conf, h: cells.h, p: cells.p}, riverCells); + const discharge = flux[mouth]; // m3 in second + const length: number = Rivers.getApproximateLength(meanderedPoints); + const width: number = Rivers.getWidth(Rivers.getOffset(discharge, meanderedPoints.length, widthFactor, 0)); + + rivers.push({ + i: riverId, + source, + mouth, + discharge, + length, + width, + widthFactor, + sourceWidth: 0, + parent, + cells: riverCells + }); + } + + // calculate confluence flux + for (const i of cells.i) { + if (!conf[i]) continue; + + const sortedInflux = cells.c[i] + .filter(c => r[c] && currentCellHeights[c] > currentCellHeights[i]) + .map(c => flux[c]) + .sort((a, b) => b - a); + conf[i] = sortedInflux.reduce((acc, flux, index) => (index ? acc + flux : acc), 0); + } + + return {r, conf, rivers}; + } + + function downcutRivers(heights: number[]) { + const MAX_DOWNCUT = 5; + const MIN_HEIGHT_TO_DOWNCUT = 35; + + for (const i of cells.i) { + if (heights[i] < MIN_HEIGHT_TO_DOWNCUT) continue; // don't downcut lowlands + if (!flux[i]) continue; + + const higherCells = cells.c[i].filter(c => heights[c] > heights[i]); + const higherFlux = higherCells.reduce((acc, c) => acc + flux[c], 0) / higherCells.length; + if (!higherFlux) continue; + + const downcut = Math.floor(flux[i] / higherFlux); + if (downcut) heights[i] -= Math.min(downcut, MAX_DOWNCUT); + } + + return heights; + } +} + +// add distance to water value to land cells to make map less depressed +const applyDistanceField = ({h, c, t}: Pick) => { + return Array.from(h).map((height, index) => { + if (height < MIN_LAND_HEIGHT || t[index] < LAND_COAST) return height; + const mean = d3.mean(c[index].map(c => t[c])) || 0; + return height + t[index] / 100 + mean / 10000; + }); +}; + +// depression filling algorithm (for a correct water flux modeling) +const resolveDepressions = function ( + cells: Pick, + features: TPackFeatures, + heights: number[] +): [number[], Dict] { + const MAX_INTERATIONS = getInputNumber("resolveDepressionsStepsOutput"); + const checkLakeMaxIteration = MAX_INTERATIONS * 0.85; + const elevateLakeMaxIteration = MAX_INTERATIONS * 0.75; + + const ELEVATION_LIMIT = getInputNumber("lakeElevationLimitOutput"); + + const LAND_ELEVATION_INCREMENT = 0.1; + const LAKE_ELEVATION_INCREMENT = 0.2; + + const lakes = features.filter(feature => feature && feature.type === "lake") as IPackFeatureLake[]; + lakes.sort((a, b) => a.height - b.height); // lowest lakes go first + + const currentCellHeights = Array.from(heights); + const currentLakeHeights = Object.fromEntries(lakes.map(({i, height}) => [i, height])); + + const getHeight = (i: number) => currentLakeHeights[cells.f[i]] || currentCellHeights[i]; + const getMinHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(getHeight)); + + const drainableLakes = checkLakesDrainability(); + + const landCells = cells.i.filter(i => heights[i] >= MIN_LAND_HEIGHT && !cells.b[i]); + landCells.sort((a, b) => heights[a] - heights[b]); // lowest cells go first + + const depressions: number[] = []; + + for (let iteration = 0; iteration && depressions.at(-1) && iteration < MAX_INTERATIONS; iteration++) { + let depressionsLeft = 0; + + // elevate potentially drainable lakes + if (iteration < checkLakeMaxIteration) { + for (const lake of lakes) { + if (drainableLakes[lake.i] !== true) continue; + + const minShoreHeight = getMinHeight(lake.shoreline); + if (minShoreHeight >= MAX_HEIGHT || lake.height > minShoreHeight) continue; + + if (iteration > elevateLakeMaxIteration) { + for (const shoreCellId of lake.shoreline) { + // reset heights + currentCellHeights[shoreCellId] = heights[shoreCellId]; + currentLakeHeights[lake.i] = lake.height; + } + + drainableLakes[lake.i] = false; + continue; + } + + currentLakeHeights[lake.i] = minShoreHeight + LAKE_ELEVATION_INCREMENT; + depressionsLeft++; + } + } + + for (const cellId of landCells) { + const minHeight = getMinHeight(cells.c[cellId]); + if (minHeight >= MAX_HEIGHT || currentCellHeights[cellId] > minHeight) continue; + + currentCellHeights[cellId] = minHeight + LAND_ELEVATION_INCREMENT; + depressionsLeft++; + } + + depressions.push(depressionsLeft); + + // check depression resolving progress + if (depressions.length > 5) { + const depressionsInitial = depressions.at(0) || 0; + const depressiosRecently = depressions.at(-6) || 0; + + const isProgressingOverall = depressionsInitial < depressionsLeft; + if (!isProgressingOverall) return [heights, drainableLakes]; + + const isProgressingRecently = depressiosRecently < depressionsLeft; + if (!isProgressingRecently) return [currentCellHeights, drainableLakes]; + } + } + + // define lakes that potentially can be open (drained into another water body) + function checkLakesDrainability() { + const canBeDrained: Dict = {}; // all false by default + const drainAllLakes = ELEVATION_LIMIT === MAX_HEIGHT - MIN_LAND_HEIGHT; + + for (const lake of lakes) { + if (drainAllLakes) { + canBeDrained[lake.i] = true; + continue; + } + + canBeDrained[lake.i] = false; + const minShoreHeight = getMinHeight(lake.shoreline); + const minHeightShoreCell = lake.shoreline.find(cellId => heights[cellId] === minShoreHeight) || lake.shoreline[0]; + + const queue = [minHeightShoreCell]; + const checked = []; + checked[minHeightShoreCell] = true; + const breakableHeight = lake.height + ELEVATION_LIMIT; + + loopCellsAroundLake: while (queue.length) { + const cellId = queue.pop()!; + + for (const neibCellId of cells.c[cellId]) { + if (checked[neibCellId]) continue; + if (heights[neibCellId] >= breakableHeight) continue; + + if (heights[neibCellId] < MIN_LAND_HEIGHT) { + const waterFeatureMet = features[cells.f[neibCellId]]; + const isOceanMet = waterFeatureMet && waterFeatureMet.type === "ocean"; + const isLakeMet = waterFeatureMet && waterFeatureMet.type === "lake"; + + if (isOceanMet || (isLakeMet && lake.height > waterFeatureMet.height)) { + canBeDrained[lake.i] = true; + break loopCellsAroundLake; + } + } + + checked[neibCellId] = true; + queue.push(neibCellId); + } + } + } + + return canBeDrained; + } + + depressions && WARN && console.warn(`Unresolved depressions: ${depressions}. Edit heightmap to fix`); + + return [currentCellHeights, drainableLakes]; +}; diff --git a/src/scripts/tooltips.ts b/src/scripts/tooltips.ts index a2c42a75..9ba68077 100644 --- a/src/scripts/tooltips.ts +++ b/src/scripts/tooltips.ts @@ -1,4 +1,4 @@ -import {MOBILE} from "../constants"; +import {MOBILE} from "../config/constants"; import {byId} from "../utils/shorthands"; const $tooltip = byId("tooltip")!; diff --git a/src/types/overrides.d.ts b/src/types/overrides.d.ts index ea68331e..c1e7c312 100644 --- a/src/types/overrides.d.ts +++ b/src/types/overrides.d.ts @@ -14,6 +14,7 @@ interface Window { // untyped IIFE modules Biomes: any; + Rivers: any; Names: any; ThreeD: any; ReliefIcons: any; @@ -21,7 +22,6 @@ interface Window { Lakes: any; HeightmapGenerator: any; OceanLayers: any; - Rivers: any; Cultures: any; BurgsAndStates: any; Religions: any; diff --git a/src/types/pack/feature.d.ts b/src/types/pack/feature.d.ts index 43e499dc..b1642cc9 100644 --- a/src/types/pack/feature.d.ts +++ b/src/types/pack/feature.d.ts @@ -30,7 +30,8 @@ interface IPackFeatureLake extends IPackFeatureBase { flux?: number; temp?: number; evaporation?: number; - outCell?: number; + inlets?: number[]; + outlet?: number; } type TPackFeature = IPackFeatureOcean | IPackFeatureIsland | IPackFeatureLake; diff --git a/src/types/pack/pack.d.ts b/src/types/pack/pack.d.ts index fe2ceef2..758572f1 100644 --- a/src/types/pack/pack.d.ts +++ b/src/types/pack/pack.d.ts @@ -17,9 +17,9 @@ interface IPackCells { g: UintArray; s: IntArray; pop: Float32Array; - fl: UintArray; - conf: UintArray; - r: UintArray; + fl: Uint16Array; // flux volume, defined by drainWater() in river-generator.ts + r: Uint16Array; // river id, defined by defineRivers() in river-generator.ts + conf: Uint16Array; // conluence, defined by defineRivers() in river-generator.ts biome: UintArray; area: UintArray; state: UintArray; diff --git a/src/utils/arrayUtils.ts b/src/utils/arrayUtils.ts index 586456fb..490617d5 100644 --- a/src/utils/arrayUtils.ts +++ b/src/utils/arrayUtils.ts @@ -1,4 +1,4 @@ -import {UINT16_MAX, UINT32_MAX, UINT8_MAX} from "../constants"; +import {UINT16_MAX, UINT32_MAX, UINT8_MAX} from "../config/constants"; export function last(array: T[]) { return array[array.length - 1]; diff --git a/vite.config.js b/vite.config.js index af9eb798..099d37f0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -68,7 +68,6 @@ export default defineConfig(({mode}) => { {find: "src", replacement: path.resolve(pathName, "./src")}, {find: "components", replacement: path.resolve(pathName, "./src/components")}, {find: "config", replacement: path.resolve(pathName, "./src/config")}, - {find: "constants", replacement: path.resolve(pathName, "./src/constants")}, {find: "dialogs", replacement: path.resolve(pathName, "./src/dialogs")}, {find: "layers", replacement: path.resolve(pathName, "./src/layers")}, {find: "libs", replacement: path.resolve(pathName, "./src/libs")},