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