From dd29a89d6640edaab9f1e09af78432170c33a949 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Mon, 5 Sep 2022 23:50:50 +0300 Subject: [PATCH 01/20] refactor: move files to folders --- .../pack/cultures/expandCultures.ts | 106 ++++++++++++ .../generateCultures.ts} | 127 ++------------ .../generation/pack/{ => lakes}/lakes.ts | 12 +- src/scripts/generation/pack/pack.ts | 97 ++--------- .../pack/provinces/generateProvinces.ts | 11 ++ src/scripts/generation/pack/repackGrid.ts | 79 +++++++++ .../{rivers.ts => rivers/generateRivers.ts} | 157 +----------------- .../pack/rivers/resolveDepressions.ts | 148 +++++++++++++++++ .../pack/{ => routes}/generateRoutes.ts | 43 +---- .../generation/pack/routes/urquhart.ts | 44 +++++ 10 files changed, 419 insertions(+), 405 deletions(-) create mode 100644 src/scripts/generation/pack/cultures/expandCultures.ts rename src/scripts/generation/pack/{cultures.ts => cultures/generateCultures.ts} (63%) rename src/scripts/generation/pack/{ => lakes}/lakes.ts (94%) create mode 100644 src/scripts/generation/pack/provinces/generateProvinces.ts create mode 100644 src/scripts/generation/pack/repackGrid.ts rename src/scripts/generation/pack/{rivers.ts => rivers/generateRivers.ts} (62%) create mode 100644 src/scripts/generation/pack/rivers/resolveDepressions.ts rename src/scripts/generation/pack/{ => routes}/generateRoutes.ts (88%) create mode 100644 src/scripts/generation/pack/routes/urquhart.ts diff --git a/src/scripts/generation/pack/cultures/expandCultures.ts b/src/scripts/generation/pack/cultures/expandCultures.ts new file mode 100644 index 00000000..e93e938b --- /dev/null +++ b/src/scripts/generation/pack/cultures/expandCultures.ts @@ -0,0 +1,106 @@ +import FlatQueue from "flatqueue"; + +import {DISTANCE_FIELD, ELEVATION, FOREST_BIOMES, MIN_LAND_HEIGHT} from "config/generation"; +import {TIME} from "config/logging"; +import {getInputNumber} from "utils/nodeUtils"; +import {minmax} from "utils/numberUtils"; +import {isCulture} from "utils/typeUtils"; + +const {LAND_COAST, LANDLOCKED, WATER_COAST} = DISTANCE_FIELD; +const {MOUNTAINS, HILLS} = ELEVATION; + +// expand cultures across the map (Dijkstra-like algorithm) +export function expandCultures( + cultures: TCultures, + features: TPackFeatures, + cells: Pick +) { + TIME && console.time("expandCultures"); + + const cultureIds = new Uint16Array(cells.h.length); // cell cultures + const queue = new FlatQueue<{cellId: number; cultureId: number}>(); + + cultures.filter(isCulture).forEach(culture => { + queue.push({cellId: culture.center, cultureId: culture.i}, 0); + }); + + const cellsNumberFactor = cells.h.length / 1.6; + const maxExpansionCost = cellsNumberFactor * getInputNumber("neutralInput"); // limit cost for culture growth + const cost: number[] = []; + + while (queue.length) { + const priority = queue.peekValue()!; + const {cellId, cultureId} = queue.pop()!; + + const {type, expansionism, center} = getCulture(cultureId); + const cultureBiome = cells.biome[center]; + + cells.c[cellId].forEach(neibCellId => { + const biomeCost = getBiomeCost(neibCellId, cultureBiome, type); + const heightCost = getHeightCost(neibCellId, cells.h[neibCellId], type); + const riverCost = getRiverCost(cells.r[neibCellId], neibCellId, type); + const typeCost = getTypeCost(cells.t[neibCellId], type); + + const totalCost = priority + (biomeCost + heightCost + riverCost + typeCost) / expansionism; + if (totalCost > maxExpansionCost) return; + + if (!cost[neibCellId] || totalCost < cost[neibCellId]) { + if (cells.pop[neibCellId] > 0) cultureIds[neibCellId] = cultureId; // assign culture to populated cell + cost[neibCellId] = totalCost; + queue.push({cellId: neibCellId, cultureId}, totalCost); + } + }); + } + + TIME && console.timeEnd("expandCultures"); + return cultureIds; + + function getCulture(cultureId: number) { + const culture = cultures[cultureId]; + if (!isCulture(culture)) throw new Error("Wilderness cannot expand"); + return culture; + } + + function getBiomeCost(cellId: number, cultureBiome: number, type: TCultureType) { + const biome = cells.biome[cellId]; + if (cultureBiome === biome) return 10; // tiny penalty for native biome + if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters + if (type === "Nomadic" && FOREST_BIOMES.includes(biome)) return biomesData.cost[biome] * 10; // forest biome penalty for nomads + return biomesData.cost[biome] * 2; // general non-native biome penalty + } + + function getHeightCost(cellId: number, height: number, type: TCultureType) { + if (height < MIN_LAND_HEIGHT) { + const feature = features[cells.f[cellId]]; + const area = cells.area[cellId]; + + if (type === "Lake" && feature && feature.type === "lake") return 10; // almost lake crossing penalty for Lake cultures + if (type === "Naval") return area * 2; // low sea or lake crossing penalty for Naval cultures + if (type === "Nomadic") return area * 50; // giant sea or lake crossing penalty for Nomads + return area * 6; // general sea or lake crossing penalty + } + + if (type === "Highland") { + if (height >= MOUNTAINS) return 0; // no penalty for highlanders on highlands + if (height < HILLS) return 3000; // giant penalty for highlanders on lowlands + return 100; // penalty for highlanders on hills + } + + if (height >= MOUNTAINS) return 200; // general mountains crossing penalty + if (height >= HILLS) return 30; // general hills crossing penalty + return 0; + } + + function getRiverCost(riverId: number, cellId: number, type: TCultureType) { + if (type === "River") return riverId ? 0 : 100; // penalty for river cultures + if (!riverId) return 0; // no penalty for others if there is no river + return minmax(cells.fl[cellId] / 10, 20, 100); // river penalty from 20 to 100 based on flux + } + + function getTypeCost(t: number, type: TCultureType) { + if (t === LAND_COAST) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline + if (t === LANDLOCKED) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads + if (t !== WATER_COAST) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals + return 0; + } +} diff --git a/src/scripts/generation/pack/cultures.ts b/src/scripts/generation/pack/cultures/generateCultures.ts similarity index 63% rename from src/scripts/generation/pack/cultures.ts rename to src/scripts/generation/pack/cultures/generateCultures.ts index bf598782..905ba04e 100644 --- a/src/scripts/generation/pack/cultures.ts +++ b/src/scripts/generation/pack/cultures/generateCultures.ts @@ -1,24 +1,15 @@ import * as d3 from "d3"; -import FlatQueue from "flatqueue"; import {cultureSets, DEFAULT_SORT_STRING, TCultureSetName} from "config/cultureSets"; -import { - DISTANCE_FIELD, - ELEVATION, - FOREST_BIOMES, - HUNTING_BIOMES, - MIN_LAND_HEIGHT, - NOMADIC_BIOMES -} from "config/generation"; +import {DISTANCE_FIELD, ELEVATION, HUNTING_BIOMES, NOMADIC_BIOMES} from "config/generation"; import {ERROR, TIME, WARN} from "config/logging"; import {getColors} from "utils/colorUtils"; import {abbreviate} from "utils/languageUtils"; import {getInputNumber, getInputValue, getSelectedOption} from "utils/nodeUtils"; -import {minmax, rn} from "utils/numberUtils"; +import {rn} from "utils/numberUtils"; import {biased, P, rand} from "utils/probabilityUtils"; import {byId} from "utils/shorthands"; import {defaultNameBases} from "config/namebases"; -import {isCulture} from "utils/typeUtils"; const {COA} = window; @@ -33,16 +24,14 @@ const cultureTypeBaseExpansionism: {[key in TCultureType]: number} = { }; const {MOUNTAINS, HILLS} = ELEVATION; -const {LAND_COAST, LANDLOCKED, WATER_COAST} = DISTANCE_FIELD; +const {LAND_COAST, LANDLOCKED} = DISTANCE_FIELD; -export const generateCultures = function ( - features: TPackFeatures, - cells: Pick< - IPack["cells"], - "p" | "i" | "g" | "t" | "h" | "haven" | "harbor" | "f" | "r" | "fl" | "s" | "pop" | "biome" - >, - temp: Int8Array -): TCultures { +type TCellsData = Pick< + IPack["cells"], + "p" | "i" | "g" | "t" | "h" | "haven" | "harbor" | "f" | "r" | "fl" | "s" | "pop" | "biome" +>; + +export function generateCultures(features: TPackFeatures, cells: TCellsData, temp: Int8Array): TCultures { TIME && console.time("generateCultures"); const wildlands: TWilderness = {name: "Wildlands", i: 0, base: 1, origins: [null], shield: "round"}; @@ -272,100 +261,4 @@ export const generateCultures = function ( ERROR && console.error(`Name base ${base} is not available, applying a fallback one`); return base % nameBases.length; } -}; - -// expand cultures across the map (Dijkstra-like algorithm) -export const expandCultures = function ( - cultures: TCultures, - features: TPackFeatures, - cells: Pick -) { - TIME && console.time("expandCultures"); - - const cultureIds = new Uint16Array(cells.h.length); // cell cultures - const queue = new FlatQueue<{cellId: number; cultureId: number}>(); - - cultures.filter(isCulture).forEach(culture => { - queue.push({cellId: culture.center, cultureId: culture.i}, 0); - }); - - const cellsNumberFactor = cells.h.length / 1.6; - const maxExpansionCost = cellsNumberFactor * getInputNumber("neutralInput"); // limit cost for culture growth - const cost: number[] = []; - - while (queue.length) { - const priority = queue.peekValue()!; - const {cellId, cultureId} = queue.pop()!; - - const {type, expansionism, center} = getCulture(cultureId); - const cultureBiome = cells.biome[center]; - - cells.c[cellId].forEach(neibCellId => { - const biomeCost = getBiomeCost(neibCellId, cultureBiome, type); - const heightCost = getHeightCost(neibCellId, cells.h[neibCellId], type); - const riverCost = getRiverCost(cells.r[neibCellId], neibCellId, type); - const typeCost = getTypeCost(cells.t[neibCellId], type); - - const totalCost = priority + (biomeCost + heightCost + riverCost + typeCost) / expansionism; - if (totalCost > maxExpansionCost) return; - - if (!cost[neibCellId] || totalCost < cost[neibCellId]) { - if (cells.pop[neibCellId] > 0) cultureIds[neibCellId] = cultureId; // assign culture to populated cell - cost[neibCellId] = totalCost; - queue.push({cellId: neibCellId, cultureId}, totalCost); - } - }); - } - - TIME && console.timeEnd("expandCultures"); - return cultureIds; - - function getCulture(cultureId: number) { - const culture = cultures[cultureId]; - if (!isCulture(culture)) throw new Error("Wilderness cannot expand"); - return culture; - } - - function getBiomeCost(cellId: number, cultureBiome: number, type: TCultureType) { - const biome = cells.biome[cellId]; - if (cultureBiome === biome) return 10; // tiny penalty for native biome - if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters - if (type === "Nomadic" && FOREST_BIOMES.includes(biome)) return biomesData.cost[biome] * 10; // forest biome penalty for nomads - return biomesData.cost[biome] * 2; // general non-native biome penalty - } - - function getHeightCost(cellId: number, height: number, type: TCultureType) { - if (height < MIN_LAND_HEIGHT) { - const feature = features[cells.f[cellId]]; - const area = cells.area[cellId]; - - if (type === "Lake" && feature && feature.type === "lake") return 10; // almost lake crossing penalty for Lake cultures - if (type === "Naval") return area * 2; // low sea or lake crossing penalty for Naval cultures - if (type === "Nomadic") return area * 50; // giant sea or lake crossing penalty for Nomads - return area * 6; // general sea or lake crossing penalty - } - - if (type === "Highland") { - if (height >= MOUNTAINS) return 0; // no penalty for highlanders on highlands - if (height < HILLS) return 3000; // giant penalty for highlanders on lowlands - return 100; // penalty for highlanders on hills - } - - if (height >= MOUNTAINS) return 200; // general mountains crossing penalty - if (height >= HILLS) return 30; // general hills crossing penalty - return 0; - } - - function getRiverCost(riverId: number, cellId: number, type: TCultureType) { - if (type === "River") return riverId ? 0 : 100; // penalty for river cultures - if (!riverId) return 0; // no penalty for others if there is no river - return minmax(cells.fl[cellId] / 10, 20, 100); // river penalty from 20 to 100 based on flux - } - - function getTypeCost(t: number, type: TCultureType) { - if (t === LAND_COAST) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline - if (t === LANDLOCKED) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads - if (t !== WATER_COAST) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals - return 0; - } -}; +} diff --git a/src/scripts/generation/pack/lakes.ts b/src/scripts/generation/pack/lakes/lakes.ts similarity index 94% rename from src/scripts/generation/pack/lakes.ts rename to src/scripts/generation/pack/lakes/lakes.ts index 367ad85c..869eb402 100644 --- a/src/scripts/generation/pack/lakes.ts +++ b/src/scripts/generation/pack/lakes/lakes.ts @@ -14,7 +14,7 @@ export interface ILakeClimateData extends IPackFeatureLake { enteringFlux?: number; } -export const getClimateData = function ( +export function getClimateData( lakes: IPackFeatureLake[], heights: Float32Array, drainableLakes: Dict, @@ -44,13 +44,9 @@ export const getClimateData = function ( }); return lakeData; -}; +} -export const mergeLakeData = function ( - features: TPackFeatures, - lakeData: ILakeClimateData[], - rivers: Pick[] -) { +export function mergeLakeData(features: TPackFeatures, lakeData: ILakeClimateData[], rivers: Pick[]) { const updatedFeatures = features.map(feature => { if (!feature) return 0; if (feature.type !== "lake") return feature; @@ -71,7 +67,7 @@ export const mergeLakeData = function ( }); return updatedFeatures as TPackFeatures; -}; +} function defineLakeGroup({ firstCell, diff --git a/src/scripts/generation/pack/pack.ts b/src/scripts/generation/pack/pack.ts index df1969ef..4c53e1f8 100644 --- a/src/scripts/generation/pack/pack.ts +++ b/src/scripts/generation/pack/pack.ts @@ -1,21 +1,15 @@ -import * as d3 from "d3"; - -import {UINT16_MAX} from "config/constants"; -import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation"; -import {TIME} from "config/logging"; -import {calculateVoronoi} from "scripts/generation/graph"; import {markupPackFeatures} from "scripts/generation/markup"; import {rankCells} from "scripts/generation/pack/rankCells"; -import {createTypedArray} from "utils/arrayUtils"; import {pick} from "utils/functionUtils"; -import {rn} from "utils/numberUtils"; -import {generateCultures, expandCultures} from "./cultures"; -import {generateRivers} from "./rivers"; import {generateBurgsAndStates} from "./burgsAndStates/generateBurgsAndStates"; -import {generateRoutes} from "./generateRoutes"; +import {expandCultures} from "./cultures/expandCultures"; +import {generateCultures} from "./cultures/generateCultures"; +import {generateProvinces} from "./provinces/generateProvinces"; import {generateReligions} from "./religions/generateReligions"; +import {repackGrid} from "./repackGrid"; +import {generateRivers} from "./rivers/generateRivers"; +import {generateRoutes} from "./routes/generateRoutes"; -const {LAND_COAST, WATER_COAST, DEEPER_WATER} = DISTANCE_FIELD; const {Biomes} = window; export function createPack(grid: IGrid): IPack { @@ -149,13 +143,10 @@ export function createPack(grid: IGrid): IPack { } }); + const {provinceIds, provinces} = generateProvinces(); + // BurgsAndStates.generateProvinces(); // BurgsAndStates.defineBurgFeatures(); - - // renderLayer("states"); - // renderLayer("borders"); - // BurgsAndStates.drawStateLabels(); - // Rivers.specify(); // const updatedFeatures = generateLakeNames(); @@ -190,7 +181,7 @@ export function createPack(grid: IGrid): IPack { state: stateIds, route: cellRoutes, religion: religionIds, - province: new Uint16Array(cells.i.length) + province: provinceIds }, features: mergedFeatures, rivers: rawRivers, // "name" | "basin" | "type" @@ -199,77 +190,9 @@ export function createPack(grid: IGrid): IPack { burgs, routes, religions, + provinces, events }; return pack; } - -// repack grid cells: discart deep water cells, add land cells along the coast -function repackGrid(grid: IGrid) { - TIME && console.time("repackGrid"); - const {cells: gridCells, points, features} = grid; - const newCells: {p: TPoints; g: number[]; h: number[]} = {p: [], g: [], h: []}; // store new data - const spacing2 = grid.spacing ** 2; - - for (const i of gridCells.i) { - const height = gridCells.h[i]; - const type = gridCells.t[i]; - - // exclude ocean points far from coast - if (height < MIN_LAND_HEIGHT && type !== WATER_COAST && type !== DEEPER_WATER) continue; - - const feature = features[gridCells.f[i]]; - const isLake = feature && feature.type === "lake"; - - // exclude non-coastal lake points - if (type === DEEPER_WATER && (i % 4 === 0 || isLake)) continue; - - const [x, y] = points[i]; - addNewPoint(i, x, y, height); - - // add additional points for cells along coast - if (type === LAND_COAST || type === WATER_COAST) { - if (gridCells.b[i]) continue; // not for near-border cells - gridCells.c[i].forEach(e => { - if (i > e) return; - if (gridCells.t[e] === type) { - const dist2 = (y - points[e][1]) ** 2 + (x - points[e][0]) ** 2; - if (dist2 < spacing2) return; // too close to each other - const x1 = rn((x + points[e][0]) / 2, 1); - const y1 = rn((y + points[e][1]) / 2, 1); - addNewPoint(i, x1, y1, height); - } - }); - } - } - - function addNewPoint(i: number, x: number, y: number, height: number) { - newCells.p.push([x, y]); - newCells.g.push(i); - newCells.h.push(height); - } - - const {cells, vertices} = calculateVoronoi(newCells.p, grid.boundary); - - function getCellArea(i: number) { - const polygon = cells.v[i].map(v => vertices.p[v]); - const area = Math.abs(d3.polygonArea(polygon)); - return Math.min(area, UINT16_MAX); - } - - const pack = { - vertices, - cells: { - ...cells, - p: newCells.p, - g: createTypedArray({maxValue: grid.points.length, from: newCells.g}), - q: d3.quadtree(newCells.p.map(([x, y], i) => [x, y, i])) as unknown as Quadtree, - h: new Uint8Array(newCells.h), - area: createTypedArray({maxValue: UINT16_MAX, from: cells.i}).map(getCellArea) - } - }; - - TIME && console.timeEnd("repackGrid"); - return pack; -} diff --git a/src/scripts/generation/pack/provinces/generateProvinces.ts b/src/scripts/generation/pack/provinces/generateProvinces.ts new file mode 100644 index 00000000..57e86914 --- /dev/null +++ b/src/scripts/generation/pack/provinces/generateProvinces.ts @@ -0,0 +1,11 @@ +import {TIME} from "config/logging"; + +export function generateProvinces() { + TIME && console.time("generateProvinces"); + + const provinceIds = new Uint16Array(1000); // cells.i.length + const provinces = [] as TProvinces; + + TIME && console.timeEnd("generateProvinces"); + return {provinceIds, provinces}; +} diff --git a/src/scripts/generation/pack/repackGrid.ts b/src/scripts/generation/pack/repackGrid.ts new file mode 100644 index 00000000..48a32b1e --- /dev/null +++ b/src/scripts/generation/pack/repackGrid.ts @@ -0,0 +1,79 @@ +import * as d3 from "d3"; + +import {UINT16_MAX} from "config/constants"; +import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation"; +import {TIME} from "config/logging"; +import {createTypedArray} from "utils/arrayUtils"; +import {rn} from "utils/numberUtils"; +import {calculateVoronoi} from "../graph"; + +const {LAND_COAST, WATER_COAST, DEEPER_WATER} = DISTANCE_FIELD; + +// repack grid cells: discart deep water cells, add land cells along the coast +export function repackGrid(grid: IGrid) { + TIME && console.time("repackGrid"); + const {cells: gridCells, points, features} = grid; + const newCells: {p: TPoints; g: number[]; h: number[]} = {p: [], g: [], h: []}; // store new data + const spacing2 = grid.spacing ** 2; + + for (const i of gridCells.i) { + const height = gridCells.h[i]; + const type = gridCells.t[i]; + + // exclude ocean points far from coast + if (height < MIN_LAND_HEIGHT && type !== WATER_COAST && type !== DEEPER_WATER) continue; + + const feature = features[gridCells.f[i]]; + const isLake = feature && feature.type === "lake"; + + // exclude non-coastal lake points + if (type === DEEPER_WATER && (i % 4 === 0 || isLake)) continue; + + const [x, y] = points[i]; + addNewPoint(i, x, y, height); + + // add additional points for cells along coast + if (type === LAND_COAST || type === WATER_COAST) { + if (gridCells.b[i]) continue; // not for near-border cells + gridCells.c[i].forEach(e => { + if (i > e) return; + if (gridCells.t[e] === type) { + const dist2 = (y - points[e][1]) ** 2 + (x - points[e][0]) ** 2; + if (dist2 < spacing2) return; // too close to each other + const x1 = rn((x + points[e][0]) / 2, 1); + const y1 = rn((y + points[e][1]) / 2, 1); + addNewPoint(i, x1, y1, height); + } + }); + } + } + + function addNewPoint(i: number, x: number, y: number, height: number) { + newCells.p.push([x, y]); + newCells.g.push(i); + newCells.h.push(height); + } + + const {cells, vertices} = calculateVoronoi(newCells.p, grid.boundary); + + function getCellArea(i: number) { + const polygon = cells.v[i].map(v => vertices.p[v]); + const area = Math.abs(d3.polygonArea(polygon)); + return Math.min(area, UINT16_MAX); + } + + const pack = { + vertices, + cells: { + ...cells, + p: newCells.p, + g: createTypedArray({maxValue: grid.points.length, from: newCells.g}), + q: d3.quadtree(newCells.p.map(([x, y], i) => [x, y, i])) as unknown as Quadtree, + h: new Uint8Array(newCells.h), + area: createTypedArray({maxValue: UINT16_MAX, from: cells.i}).map(getCellArea) + } + }; + + TIME && console.timeEnd("repackGrid"); + return pack; +} diff --git a/src/scripts/generation/pack/rivers.ts b/src/scripts/generation/pack/rivers/generateRivers.ts similarity index 62% rename from src/scripts/generation/pack/rivers.ts rename to src/scripts/generation/pack/rivers/generateRivers.ts index 8fe4bc21..b21b5fe5 100644 --- a/src/scripts/generation/pack/rivers.ts +++ b/src/scripts/generation/pack/rivers/generateRivers.ts @@ -1,13 +1,13 @@ import * as d3 from "d3"; -import {INFO, TIME, WARN} from "config/logging"; +import {TIME} 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 {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation"; import {pick} from "utils/functionUtils"; import {byId} from "utils/shorthands"; -import {mergeLakeData, getClimateData, ILakeClimateData} from "./lakes"; +import {mergeLakeData, getClimateData, ILakeClimateData} from "../lakes/lakes"; +import {resolveDepressions} from "./resolveDepressions"; const {Rivers} = window; const {LAND_COAST} = DISTANCE_FIELD; @@ -276,155 +276,10 @@ export function generateRivers( } // add distance to water value to land cells to make map less depressed -const applyDistanceField = ({h, c, t}: Pick) => { +function applyDistanceField({h, c, t}: Pick) { return new Float32Array(h.length).map((_, index) => { if (h[index] < MIN_LAND_HEIGHT || t[index] < LAND_COAST) return h[index]; const mean = d3.mean(c[index].map(c => t[c])) || 0; return h[index] + t[index] / 100 + mean / 10000; }); -}; - -// depression filling algorithm (for a correct water flux modeling) -const resolveDepressions = function ( - cells: Pick, - features: TPackFeatures, - initialCellHeights: Float32Array -): [Float32Array, Dict] { - TIME && console.time("resolveDepressions"); - - const MAX_INTERATIONS = getInputNumber("resolveDepressionsStepsOutput"); - const checkLakeMaxIteration = MAX_INTERATIONS * 0.85; - const elevateLakeMaxIteration = MAX_INTERATIONS * 0.75; - - 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 getHeight = (i: number) => currentLakeHeights[cells.f[i]] || currentCellHeights[i]; - const getMinHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(getHeight)); - const getMinLandHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(i => currentCellHeights[i])); - - const landCells = cells.i.filter(i => initialCellHeights[i] >= MIN_LAND_HEIGHT && !cells.b[i]); - landCells.sort((a, b) => initialCellHeights[a] - initialCellHeights[b]); // lowest cells go first - - const currentCellHeights = Float32Array.from(initialCellHeights); - const currentLakeHeights = Object.fromEntries(lakes.map(({i, height}) => [i, height])); - const currentDrainableLakes = checkLakesDrainability(); - const depressions: number[] = []; - - let bestDepressions = Infinity; - let bestCellHeights: typeof currentCellHeights | null = null; - let bestDrainableLakes: typeof currentDrainableLakes | null = null; - - for (let iteration = 0; depressions.at(-1) !== 0 && iteration < MAX_INTERATIONS; iteration++) { - let depressionsLeft = 0; - - // elevate potentially drainable lakes - if (iteration < checkLakeMaxIteration) { - for (const lake of lakes) { - if (currentDrainableLakes[lake.i] !== true) continue; - - const minShoreHeight = getMinLandHeight(lake.shoreline); - if (minShoreHeight >= MAX_HEIGHT || currentLakeHeights[lake.i] > minShoreHeight) continue; - - if (iteration > elevateLakeMaxIteration) { - // reset heights - for (const shoreCellId of lake.shoreline) { - currentCellHeights[shoreCellId] = initialCellHeights[shoreCellId]; - } - currentLakeHeights[lake.i] = lake.height; - - currentDrainableLakes[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); - if (depressionsLeft < bestDepressions) { - bestDepressions = depressionsLeft; - bestCellHeights = Float32Array.from(currentCellHeights); - bestDrainableLakes = structuredClone(currentDrainableLakes); - } - } - - TIME && console.timeEnd("resolveDepressions"); - - const depressionsLeft = depressions.at(-1); - if (depressionsLeft) { - if (bestCellHeights && bestDrainableLakes) { - WARN && - console.warn(`Cannot resolve all depressions. Depressions: ${depressions[0]}. Best result: ${bestDepressions}`); - return [bestCellHeights, bestDrainableLakes]; - } - - WARN && console.warn(`Cannot resolve depressions. Depressions: ${depressionsLeft}`); - return [initialCellHeights, {}]; - } - - INFO && console.info(`ⓘ resolved all ${depressions[0]} depressions in ${depressions.length} iterations`); - return [currentCellHeights, currentDrainableLakes]; - - // define lakes that potentially can be open (drained into another water body) - function checkLakesDrainability() { - const canBeDrained: Dict = {}; // all false by default - - const ELEVATION_LIMIT = getInputNumber("lakeElevationLimitOutput"); - 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 => initialCellHeights[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 (initialCellHeights[neibCellId] >= breakableHeight) continue; - - if (initialCellHeights[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; - } -}; +} diff --git a/src/scripts/generation/pack/rivers/resolveDepressions.ts b/src/scripts/generation/pack/rivers/resolveDepressions.ts new file mode 100644 index 00000000..301a425e --- /dev/null +++ b/src/scripts/generation/pack/rivers/resolveDepressions.ts @@ -0,0 +1,148 @@ +import {MIN_LAND_HEIGHT, MAX_HEIGHT} from "config/generation"; +import {TIME, WARN, INFO} from "config/logging"; +import {getInputNumber} from "utils/nodeUtils"; + +// depression filling algorithm (for a correct water flux modeling) +export function resolveDepressions( + cells: Pick, + features: TPackFeatures, + initialCellHeights: Float32Array +): [Float32Array, Dict] { + TIME && console.time("resolveDepressions"); + + const MAX_INTERATIONS = getInputNumber("resolveDepressionsStepsOutput"); + const checkLakeMaxIteration = MAX_INTERATIONS * 0.85; + const elevateLakeMaxIteration = MAX_INTERATIONS * 0.75; + + 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 getHeight = (i: number) => currentLakeHeights[cells.f[i]] || currentCellHeights[i]; + const getMinHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(getHeight)); + const getMinLandHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(i => currentCellHeights[i])); + + const landCells = cells.i.filter(i => initialCellHeights[i] >= MIN_LAND_HEIGHT && !cells.b[i]); + landCells.sort((a, b) => initialCellHeights[a] - initialCellHeights[b]); // lowest cells go first + + const currentCellHeights = Float32Array.from(initialCellHeights); + const currentLakeHeights = Object.fromEntries(lakes.map(({i, height}) => [i, height])); + const currentDrainableLakes = checkLakesDrainability(); + const depressions: number[] = []; + + let bestDepressions = Infinity; + let bestCellHeights: typeof currentCellHeights | null = null; + let bestDrainableLakes: typeof currentDrainableLakes | null = null; + + for (let iteration = 0; depressions.at(-1) !== 0 && iteration < MAX_INTERATIONS; iteration++) { + let depressionsLeft = 0; + + // elevate potentially drainable lakes + if (iteration < checkLakeMaxIteration) { + for (const lake of lakes) { + if (currentDrainableLakes[lake.i] !== true) continue; + + const minShoreHeight = getMinLandHeight(lake.shoreline); + if (minShoreHeight >= MAX_HEIGHT || currentLakeHeights[lake.i] > minShoreHeight) continue; + + if (iteration > elevateLakeMaxIteration) { + // reset heights + for (const shoreCellId of lake.shoreline) { + currentCellHeights[shoreCellId] = initialCellHeights[shoreCellId]; + } + currentLakeHeights[lake.i] = lake.height; + + currentDrainableLakes[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); + if (depressionsLeft < bestDepressions) { + bestDepressions = depressionsLeft; + bestCellHeights = Float32Array.from(currentCellHeights); + bestDrainableLakes = structuredClone(currentDrainableLakes); + } + } + + TIME && console.timeEnd("resolveDepressions"); + + const depressionsLeft = depressions.at(-1); + if (depressionsLeft) { + if (bestCellHeights && bestDrainableLakes) { + WARN && + console.warn(`Cannot resolve all depressions. Depressions: ${depressions[0]}. Best result: ${bestDepressions}`); + return [bestCellHeights, bestDrainableLakes]; + } + + WARN && console.warn(`Cannot resolve depressions. Depressions: ${depressionsLeft}`); + return [initialCellHeights, {}]; + } + + INFO && console.info(`ⓘ resolved all ${depressions[0]} depressions in ${depressions.length} iterations`); + return [currentCellHeights, currentDrainableLakes]; + + // define lakes that potentially can be open (drained into another water body) + function checkLakesDrainability() { + const canBeDrained: Dict = {}; // all false by default + + const ELEVATION_LIMIT = getInputNumber("lakeElevationLimitOutput"); + 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 => initialCellHeights[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 (initialCellHeights[neibCellId] >= breakableHeight) continue; + + if (initialCellHeights[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; + } +} diff --git a/src/scripts/generation/pack/generateRoutes.ts b/src/scripts/generation/pack/routes/generateRoutes.ts similarity index 88% rename from src/scripts/generation/pack/generateRoutes.ts rename to src/scripts/generation/pack/routes/generateRoutes.ts index b7f3e76a..e852fbd6 100644 --- a/src/scripts/generation/pack/generateRoutes.ts +++ b/src/scripts/generation/pack/routes/generateRoutes.ts @@ -1,10 +1,10 @@ -import Delaunator from "delaunator"; import FlatQueue from "flatqueue"; import {TIME} from "config/logging"; import {ELEVATION, MIN_LAND_HEIGHT, ROUTES} from "config/generation"; import {dist2} from "utils/functionUtils"; import {isBurg} from "utils/typeUtils"; +import {calculateUrquhartEdges} from "./urquhart"; type TCellsData = Pick; @@ -292,44 +292,3 @@ function getRouteSegments(pathCells: number[], connections: Map return segments; } - -// Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation -// this gives us an aproximation of a desired road network, i.e. connections between burgs -// code from https://observablehq.com/@mbostock/urquhart-graph -function calculateUrquhartEdges(points: TPoints) { - const score = (p0: number, p1: number) => dist2(points[p0], points[p1]); - - const {halfedges, triangles} = Delaunator.from(points); - const n = triangles.length; - - const removed = new Uint8Array(n); - const edges = []; - - for (let e = 0; e < n; e += 3) { - const p0 = triangles[e], - p1 = triangles[e + 1], - p2 = triangles[e + 2]; - - const p01 = score(p0, p1), - p12 = score(p1, p2), - p20 = score(p2, p0); - - removed[ - p20 > p01 && p20 > p12 - ? Math.max(e + 2, halfedges[e + 2]) - : p12 > p01 && p12 > p20 - ? Math.max(e + 1, halfedges[e + 1]) - : Math.max(e, halfedges[e]) - ] = 1; - } - - for (let e = 0; e < n; ++e) { - if (e > halfedges[e] && !removed[e]) { - const t0 = triangles[e]; - const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1]; - edges.push([t0, t1]); - } - } - - return edges; -} diff --git a/src/scripts/generation/pack/routes/urquhart.ts b/src/scripts/generation/pack/routes/urquhart.ts new file mode 100644 index 00000000..67d6ecd3 --- /dev/null +++ b/src/scripts/generation/pack/routes/urquhart.ts @@ -0,0 +1,44 @@ +import Delaunator from "delaunator"; + +import {dist2} from "utils/functionUtils"; + +// Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation +// this gives us an aproximation of a desired road network, i.e. connections between burgs +// code from https://observablehq.com/@mbostock/urquhart-graph +export function calculateUrquhartEdges(points: TPoints) { + const score = (p0: number, p1: number) => dist2(points[p0], points[p1]); + + const {halfedges, triangles} = Delaunator.from(points); + const n = triangles.length; + + const removed = new Uint8Array(n); + const edges = []; + + for (let e = 0; e < n; e += 3) { + const p0 = triangles[e], + p1 = triangles[e + 1], + p2 = triangles[e + 2]; + + const p01 = score(p0, p1), + p12 = score(p1, p2), + p20 = score(p2, p0); + + removed[ + p20 > p01 && p20 > p12 + ? Math.max(e + 2, halfedges[e + 2]) + : p12 > p01 && p12 > p20 + ? Math.max(e + 1, halfedges[e + 1]) + : Math.max(e, halfedges[e]) + ] = 1; + } + + for (let e = 0; e < n; ++e) { + if (e > halfedges[e] && !removed[e]) { + const t0 = triangles[e]; + const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1]; + edges.push([t0, t1]); + } + } + + return edges; +} From 2b61ac6a0f63ca9b4e0e3a7e40e0533327abb02d Mon Sep 17 00:00:00 2001 From: Azgaar Date: Tue, 6 Sep 2022 01:23:43 +0300 Subject: [PATCH 02/20] refactor: provinces start --- .../pack/provinces/generateProvinces.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/scripts/generation/pack/provinces/generateProvinces.ts b/src/scripts/generation/pack/provinces/generateProvinces.ts index 57e86914..5cbc37eb 100644 --- a/src/scripts/generation/pack/provinces/generateProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateProvinces.ts @@ -1,11 +1,27 @@ import {TIME} from "config/logging"; +import {getInputNumber} from "utils/nodeUtils"; +import {gauss} from "utils/probabilityUtils"; -export function generateProvinces() { +const forms = { + Monarchy: {County: 22, Earldom: 6, Shire: 2, Landgrave: 2, Margrave: 2, Barony: 2, Captaincy: 1, Seneschalty: 1}, + Republic: {Province: 6, Department: 2, Governorate: 2, District: 1, Canton: 1, Prefecture: 1}, + Theocracy: {Parish: 3, Deanery: 1}, + Union: {Province: 1, State: 1, Canton: 1, Republic: 1, County: 1, Council: 1}, + Anarchy: {Council: 1, Commune: 1, Community: 1, Tribe: 1}, + Wild: {Territory: 10, Land: 5, Region: 2, Tribe: 1, Clan: 1, Dependency: 1, Area: 1} +}; + +export function generateProvinces(states: TStates, cells: Pick) { TIME && console.time("generateProvinces"); - const provinceIds = new Uint16Array(1000); // cells.i.length + const provinceIds = new Uint16Array(cells.i.length); const provinces = [] as TProvinces; + const percentage = getInputNumber("provincesInput"); + if (states.length < 2 || percentage === 0) return {provinceIds, provinces}; + + const maxGrowth = percentage === 100 ? 1000 : gauss(20, 5, 5, 100) * percentage ** 0.5; + TIME && console.timeEnd("generateProvinces"); return {provinceIds, provinces}; } From fe2f8428adfe6ed4f51fc5f5c547b57596f5b67a Mon Sep 17 00:00:00 2001 From: Azgaar Date: Tue, 6 Sep 2022 23:16:21 +0300 Subject: [PATCH 03/20] refactor: generateCoreProvinces --- package.json | 2 + .../pack/burgsAndStates/defineStateForm.ts | 2 +- src/scripts/generation/pack/pack.ts | 2 +- .../generation/pack/provinces/config.ts | 8 +++ .../pack/provinces/expandProvinces.ts | 9 +++ .../pack/provinces/generateCoreProvinces.ts | 67 +++++++++++++++++++ .../pack/provinces/generateProvinces.ts | 30 ++++----- src/types/pack/burgs.d.ts | 1 + src/types/pack/provinces.d.ts | 6 ++ src/types/pack/states.d.ts | 4 +- yarn.lock | 17 +++++ 11 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 src/scripts/generation/pack/provinces/config.ts create mode 100644 src/scripts/generation/pack/provinces/expandProvinces.ts create mode 100644 src/scripts/generation/pack/provinces/generateCoreProvinces.ts diff --git a/package.json b/package.json index 5754d5fc..417e5623 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-replace": "^4.0.0", "@types/d3": "^5.9.0", + "@types/d3-array": "^3.0.3", "@types/delaunator": "^5.0.0", "@types/jquery": "^3.5.14", "@types/jqueryui": "^1.12.16", @@ -35,6 +36,7 @@ }, "dependencies": { "d3": "5.8.0", + "d3-array": "^3.2.0", "delaunator": "^5.0.0", "flatqueue": "^2.0.3", "lineclip": "^1.1.5", diff --git a/src/scripts/generation/pack/burgsAndStates/defineStateForm.ts b/src/scripts/generation/pack/burgsAndStates/defineStateForm.ts index 0610edd0..a434434e 100644 --- a/src/scripts/generation/pack/burgsAndStates/defineStateForm.ts +++ b/src/scripts/generation/pack/burgsAndStates/defineStateForm.ts @@ -40,7 +40,7 @@ export function defineStateForm( const generic = {Monarchy: 25, Republic: 2, Union: 1}; const naval = {Monarchy: 6, Republic: 2, Union: 1}; -function defineForm(type: TCultureType, areaTier: AreaTiers) { +function defineForm(type: TCultureType, areaTier: AreaTiers): TStateForm { const isAnarchy = P((1 - areaTier / 5) / 100); // [1% - 0.2%] chance if (isAnarchy) return "Anarchy"; diff --git a/src/scripts/generation/pack/pack.ts b/src/scripts/generation/pack/pack.ts index 4c53e1f8..c79f20e5 100644 --- a/src/scripts/generation/pack/pack.ts +++ b/src/scripts/generation/pack/pack.ts @@ -143,7 +143,7 @@ export function createPack(grid: IGrid): IPack { } }); - const {provinceIds, provinces} = generateProvinces(); + const {provinceIds, provinces} = generateProvinces(states, burgs, cultures, {i: cells.i}); // BurgsAndStates.generateProvinces(); // BurgsAndStates.defineBurgFeatures(); diff --git a/src/scripts/generation/pack/provinces/config.ts b/src/scripts/generation/pack/provinces/config.ts new file mode 100644 index 00000000..a2ea85d9 --- /dev/null +++ b/src/scripts/generation/pack/provinces/config.ts @@ -0,0 +1,8 @@ +export const provinceForms = { + Monarchy: {County: 22, Earldom: 6, Shire: 2, Landgrave: 2, Margrave: 2, Barony: 2, Captaincy: 1, Seneschalty: 1}, + Republic: {Province: 6, Department: 2, Governorate: 2, District: 1, Canton: 1, Prefecture: 1}, + Theocracy: {Parish: 3, Deanery: 1}, + Union: {Province: 1, State: 1, Canton: 1, Republic: 1, County: 1, Council: 1}, + Anarchy: {Council: 1, Commune: 1, Community: 1, Tribe: 1}, + Wild: {Territory: 10, Land: 5, Region: 2, Tribe: 1, Clan: 1, Dependency: 1, Area: 1} +}; diff --git a/src/scripts/generation/pack/provinces/expandProvinces.ts b/src/scripts/generation/pack/provinces/expandProvinces.ts new file mode 100644 index 00000000..7e996d1d --- /dev/null +++ b/src/scripts/generation/pack/provinces/expandProvinces.ts @@ -0,0 +1,9 @@ +import {gauss} from "utils/probabilityUtils"; + +export function expandProvinces(percentage: number, cells: Pick) { + const provinceIds = new Uint16Array(cells.i.length); + + const maxGrowth = percentage === 100 ? 1000 : gauss(20, 5, 5, 100) * percentage ** 0.5; + + return provinceIds; +} diff --git a/src/scripts/generation/pack/provinces/generateCoreProvinces.ts b/src/scripts/generation/pack/provinces/generateCoreProvinces.ts new file mode 100644 index 00000000..ce53c068 --- /dev/null +++ b/src/scripts/generation/pack/provinces/generateCoreProvinces.ts @@ -0,0 +1,67 @@ +import {group} from "d3-array"; + +import {brighter, getMixedColor} from "utils/colorUtils"; +import {gauss, P, rw} from "utils/probabilityUtils"; +import {isBurg, isState} from "utils/typeUtils"; +import {provinceForms} from "./config"; + +const {COA, Names} = window; + +export function generateCoreProvinces(states: TStates, burgs: TBurgs, cultures: TCultures, percentage: number) { + const provinces = [] as IProvince[]; + + const validBurgs = burgs.filter(isBurg); + const burgsToStateMap = group(validBurgs, (burg: IBurg) => burg.state); + + states.filter(isState).forEach(state => { + const stateBurgs = burgsToStateMap.get(state.i); + if (!stateBurgs || stateBurgs.length < 2) return; // at least 2 provinces are required + + stateBurgs + .sort((a, b) => b.population * gauss(1, 0.2, 0.5, 1.5, 3) - a.population) + .sort((a, b) => b.capital - a.capital); + + const provincesNumber = Math.max(Math.ceil((stateBurgs.length * percentage) / 100), 2); + const formsPool: Dict = structuredClone(provinceForms[state.form]); + + for (let i = 0; i < provincesNumber; i++) { + const {i: burg, cell: center, culture: cultureId, coa: burgEmblem, name: burgName, type} = stateBurgs[i]; + const nameByBurg = P(0.5); + const name = nameByBurg ? burgName : generateName(cultureId, cultures); + 3; + const formName = rw(formsPool); + formsPool[formName] += 10; // increase chance to get the same form again + + const fullName = name + " " + formName; + const color = brighter(getMixedColor(state.color, 0.2), 0.3); + const coa = generateEmblem(nameByBurg, burgEmblem, type, cultures, cultureId, state); + + provinces.push({i: provinces.length, name, formName, center, burg, state: state.i, fullName, color, coa}); + } + }); + + return provinces; +} + +function generateName(cultureId: number, cultures: TCultures) { + const base = cultures[cultureId].base; + return Names.getState(Names.getBaseShort(base), base); +} + +function generateEmblem( + nameByBurg: boolean, + burgEmblem: ICoa | "string", + type: TCultureType, + cultures: TCultures, + cultureId: number, + state: IState +) { + const kinship = nameByBurg ? 0.8 : 0.4; + const coa: ICoa = COA.generate(burgEmblem, kinship, null, type); + + const cultureShield = cultures[cultureId].shield; + const stateShield = (state.coa as ICoa)?.shield; + coa.shield = COA.getShield(cultureShield, stateShield); + + return coa; +} diff --git a/src/scripts/generation/pack/provinces/generateProvinces.ts b/src/scripts/generation/pack/provinces/generateProvinces.ts index 5cbc37eb..e93d6cac 100644 --- a/src/scripts/generation/pack/provinces/generateProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateProvinces.ts @@ -1,26 +1,24 @@ import {TIME} from "config/logging"; import {getInputNumber} from "utils/nodeUtils"; -import {gauss} from "utils/probabilityUtils"; +import {expandProvinces} from "./expandProvinces"; +import {generateCoreProvinces} from "./generateCoreProvinces"; -const forms = { - Monarchy: {County: 22, Earldom: 6, Shire: 2, Landgrave: 2, Margrave: 2, Barony: 2, Captaincy: 1, Seneschalty: 1}, - Republic: {Province: 6, Department: 2, Governorate: 2, District: 1, Canton: 1, Prefecture: 1}, - Theocracy: {Parish: 3, Deanery: 1}, - Union: {Province: 1, State: 1, Canton: 1, Republic: 1, County: 1, Council: 1}, - Anarchy: {Council: 1, Commune: 1, Community: 1, Tribe: 1}, - Wild: {Territory: 10, Land: 5, Region: 2, Tribe: 1, Clan: 1, Dependency: 1, Area: 1} -}; - -export function generateProvinces(states: TStates, cells: Pick) { +export function generateProvinces( + states: TStates, + burgs: TBurgs, + cultures: TCultures, + cells: Pick +) { TIME && console.time("generateProvinces"); - const provinceIds = new Uint16Array(cells.i.length); - const provinces = [] as TProvinces; - const percentage = getInputNumber("provincesInput"); - if (states.length < 2 || percentage === 0) return {provinceIds, provinces}; + if (states.length < 2 || percentage === 0) + return {provinceIds: new Uint16Array(cells.i.length), provinces: [] as TProvinces[]}; - const maxGrowth = percentage === 100 ? 1000 : gauss(20, 5, 5, 100) * percentage ** 0.5; + const coreProvinces = generateCoreProvinces(states, burgs, cultures, percentage); + const provinceIds = expandProvinces(percentage, cells); + + const provinces = [...coreProvinces]; TIME && console.timeEnd("generateProvinces"); return {provinceIds, provinces}; diff --git a/src/types/pack/burgs.d.ts b/src/types/pack/burgs.d.ts index 01e39700..8b1a5237 100644 --- a/src/types/pack/burgs.d.ts +++ b/src/types/pack/burgs.d.ts @@ -3,6 +3,7 @@ interface IBurg { name: string; feature: number; state: number; + culture: number; cell: number; x: number; y: number; diff --git a/src/types/pack/provinces.d.ts b/src/types/pack/provinces.d.ts index 502b5b80..a055a542 100644 --- a/src/types/pack/provinces.d.ts +++ b/src/types/pack/provinces.d.ts @@ -1,7 +1,13 @@ interface IProvince { i: number; name: string; + burg: number; + formName: string; fullName: string; + color: Hex | CssUrls; + state: number; + center: number; + coa: ICoa | string; removed?: boolean; } diff --git a/src/types/pack/states.d.ts b/src/types/pack/states.d.ts index 8136e6ce..f001f711 100644 --- a/src/types/pack/states.d.ts +++ b/src/types/pack/states.d.ts @@ -7,7 +7,7 @@ interface IState { type: TCultureType; culture: number; expansionism: number; - form: string; + form: TStateForm; formName: string; fullName: string; coa: ICoa | string; @@ -38,6 +38,8 @@ interface ICoa { t1: string; } +type TStateForm = "Monarchy" | "Republic" | "Theocracy" | "Union" | "Anarchy"; + type TRelation = | "Ally" | "Friendly" diff --git a/yarn.lock b/yarn.lock index e5e80b0e..2de78906 100644 --- a/yarn.lock +++ b/yarn.lock @@ -155,6 +155,11 @@ resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-1.2.9.tgz#c7dc78992cd8ca5c850243a265fd257ea56df1fa" integrity sha512-E/7RgPr2ylT5dWG0CswMi9NpFcjIEDqLcUSBgNHe/EMahfqYaTx4zhcggG3khqoEB/leY4Vl6nTSbwLUPjXceA== +"@types/d3-array@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.3.tgz#87d990bf504d14ad6b16766979d04e943c046dac" + integrity sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ== + "@types/d3-axis@^1": version "1.0.16" resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-1.0.16.tgz#93d7a28795c2f8b0e2fd550fcc4d29b7f174e693" @@ -738,6 +743,13 @@ d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0: resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== +d3-array@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" + integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g== + dependencies: + internmap "1 - 2" + d3-axis@1: version "1.0.12" resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9" @@ -1469,6 +1481,11 @@ inherits@2, inherits@^2.0.3, inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + is-builtin-module@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.1.0.tgz#6fdb24313b1c03b75f8b9711c0feb8c30b903b00" From a1e772773078bed6dbe7647b667f1d4afa37b1f9 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Tue, 6 Sep 2022 23:53:24 +0300 Subject: [PATCH 04/20] refactor: expandProvinces --- .../pack/burgsAndStates/expandStates.ts | 117 +++++++++--------- src/scripts/generation/pack/pack.ts | 9 +- .../pack/provinces/expandProvinces.ts | 83 ++++++++++++- .../pack/provinces/generateProvinces.ts | 4 +- src/types/pack/pack.d.ts | 4 +- 5 files changed, 152 insertions(+), 65 deletions(-) diff --git a/src/scripts/generation/pack/burgsAndStates/expandStates.ts b/src/scripts/generation/pack/burgsAndStates/expandStates.ts index 64bdc3aa..c1f40925 100644 --- a/src/scripts/generation/pack/burgsAndStates/expandStates.ts +++ b/src/scripts/generation/pack/burgsAndStates/expandStates.ts @@ -6,6 +6,42 @@ import {minmax} from "utils/numberUtils"; import {ELEVATION, FOREST_BIOMES, MIN_LAND_HEIGHT, DISTANCE_FIELD} from "config/generation"; import type {TStateData} from "./createStateData"; +const costs = { + SAME_CULTURE: -9, + DIFFERENT_CULTURES: 100, + + MAX_SUITABILITY: 20, + UNINHABITED_LAND: 5000, + NATIVE_BIOME_FIXED: 10, + + GENERIC_WATER_CROSSING: 1000, + NOMADS_WATER_CROSSING: 10000, + NAVAL_WATER_CROSSING: 300, + LAKE_STATES_LAKE_CROSSING: 10, + GENERIC_MOUNTAINS_CROSSING: 2200, + GENERIC_HILLS_CROSSING: 300, + HIGHLAND_STATE_LOWLANDS: 1100, + HIGHLAND_STATE_HIGHTLAND: 0, + + RIVER_STATE_RIVER_CROSSING: 0, + RIVER_STATE_NO_RIVER: 100, + RIVER_CROSSING_MIN: 20, + RIVER_CROSSING_MAX: 100, + + GENERIC_LAND_COAST: 20, + MARITIME_LAND_COAST: 0, + NOMADS_LAND_COAST: 60, + GENERIC_LANDLOCKED: 0, + NAVAL_LANDLOCKED: 30 +}; + +const multipliers = { + HUNTERS_NON_NATIVE_BIOME: 2, + NOMADS_FOREST_BIOMES: 3, + GENERIC_NON_NATIVE_BIOME: 1, + GENERIC_DEEP_WATER: 2 +}; + // growth algorithm to assign cells to states export function expandStates( capitalCells: Map, @@ -30,39 +66,6 @@ export function expandStates( queue.push({cellId, stateId}, 0); } - // expansion costs (less is better) - const SAME_CULTURE_BONUS = -9; - const DIFFERENT_CULTURES_FEE = 100; - - const MAX_SUITABILITY_COST = 20; - const UNINHABITED_LAND_FEE = 5000; - - const NATIVE_BIOME_FIXED_COST = 10; - const HUNTERS_NON_NATIVE_BIOME_FEE_MULTIPLIER = 2; - const NOMADS_FOREST_BIOMES_FEE_MULTIPLIER = 3; - const GENERIC_NON_NATIVE_BIOME_FEE_MULTIPLIER = 1; - - const GENERIC_DEEP_WATER_FEE_MULTIPLIER = 2; - const GENERIC_WATER_CROSSING_FEE = 1000; - const NOMADS_WATER_CROSSING_FEE = 10000; - const NAVAL_WATER_CROSSING_FEE = 300; - const LAKE_STATES_LAKE_CROSSING_FEE = 10; - const GENERIC_MOUNTAINS_CROSSING_FEE = 2200; - const GENERIC_HILLS_CROSSING_FEE = 300; - const HIGHLAND_STATE_LOWLANDS_FEE = 1100; - const HIGHLAND_STATE_HIGHTLAND_COST = 0; - - const RIVER_STATE_RIVER_CROSSING_COST = 0; - const RIVER_STATE_NO_RIVER_COST = 100; - const RIVER_CROSSING_MIN_COST = 20; - const RIVER_CROSSING_MAX_COST = 100; - - const GENERIC_LAND_COAST_FEE = 20; - const MARITIME_LAND_COAST_FEE = 0; - const NOMADS_LAND_COAST_FEE = 60; - const GENERIC_LANDLOCKED_FEE = 0; - const NAVAL_LANDLOCKED_FEE = 30; - const statesMap = new Map(statesData.map(stateData => [stateData.i, stateData])); while (queue.length) { @@ -100,7 +103,7 @@ export function expandStates( return normalizeStates(stateIds, capitalCells, cells.c, cells.h); function getCultureCost(cellId: number, stateCulture: number) { - return cells.culture[cellId] === stateCulture ? SAME_CULTURE_BONUS : DIFFERENT_CULTURES_FEE; + return cells.culture[cellId] === stateCulture ? costs.SAME_CULTURE : costs.DIFFERENT_CULTURES; } function getPopulationCost(cellId: number) { @@ -108,19 +111,19 @@ export function expandStates( if (isWater) return 0; const suitability = cells.s[cellId]; - if (suitability) return Math.max(MAX_SUITABILITY_COST - suitability, 0); + if (suitability) return Math.max(costs.MAX_SUITABILITY - suitability, 0); - return UNINHABITED_LAND_FEE; + return costs.UNINHABITED_LAND; } function getBiomeCost(cellId: number, capitalBiome: number, type: TCultureType) { const biome = cells.biome[cellId]; - if (biome === capitalBiome) return NATIVE_BIOME_FIXED_COST; + if (biome === capitalBiome) return costs.NATIVE_BIOME_FIXED; const defaultCost = biomesData.cost[biome]; - if (type === "Hunting") return defaultCost * HUNTERS_NON_NATIVE_BIOME_FEE_MULTIPLIER; - if (type === "Nomadic" && FOREST_BIOMES.includes(biome)) return defaultCost * NOMADS_FOREST_BIOMES_FEE_MULTIPLIER; - return defaultCost * GENERIC_NON_NATIVE_BIOME_FEE_MULTIPLIER; + if (type === "Hunting") return defaultCost * multipliers.HUNTERS_NON_NATIVE_BIOME; + if (type === "Nomadic" && FOREST_BIOMES.includes(biome)) return defaultCost * multipliers.NOMADS_FOREST_BIOMES; + return defaultCost * multipliers.GENERIC_NON_NATIVE_BIOME; } function getHeightCost(cellId: number, type: TCultureType) { @@ -131,12 +134,12 @@ export function expandStates( const feature = features[cells.f[cellId]]; if (feature === 0) throw new Error(`No feature for cell ${cellId}`); const isDeepWater = cells.t[cellId] > DISTANCE_FIELD.WATER_COAST; - const multiplier = isDeepWater ? GENERIC_DEEP_WATER_FEE_MULTIPLIER : 1; + const multiplier = isDeepWater ? multipliers.GENERIC_DEEP_WATER : 1; - if (type === "Lake" && feature.type === "lake") return LAKE_STATES_LAKE_CROSSING_FEE * multiplier; - if (type === "Naval") return NAVAL_WATER_CROSSING_FEE * multiplier; - if (type === "Nomadic") return NOMADS_WATER_CROSSING_FEE * multiplier; - return GENERIC_WATER_CROSSING_FEE * multiplier; + if (type === "Lake" && feature.type === "lake") return costs.LAKE_STATES_LAKE_CROSSING * multiplier; + if (type === "Naval") return costs.NAVAL_WATER_CROSSING * multiplier; + if (type === "Nomadic") return costs.NOMADS_WATER_CROSSING * multiplier; + return costs.GENERIC_WATER_CROSSING * multiplier; } const isLowlands = height <= ELEVATION.FOOTHILLS; @@ -144,22 +147,22 @@ export function expandStates( const isMountains = height >= ELEVATION.MOUNTAINS; if (type === "Highland") { - if (isLowlands) return HIGHLAND_STATE_LOWLANDS_FEE; - return HIGHLAND_STATE_HIGHTLAND_COST; + if (isLowlands) return costs.HIGHLAND_STATE_LOWLANDS; + return costs.HIGHLAND_STATE_HIGHTLAND; } - if (isMountains) return GENERIC_MOUNTAINS_CROSSING_FEE; - if (isHills) return GENERIC_HILLS_CROSSING_FEE; + if (isMountains) return costs.GENERIC_MOUNTAINS_CROSSING; + if (isHills) return costs.GENERIC_HILLS_CROSSING; return 0; } function getRiverCost(cellId: number, type: TCultureType) { const isRiver = cells.r[cellId] !== 0; - if (type === "River") return isRiver ? RIVER_STATE_RIVER_CROSSING_COST : RIVER_STATE_NO_RIVER_COST; + if (type === "River") return isRiver ? costs.RIVER_STATE_RIVER_CROSSING : costs.RIVER_STATE_NO_RIVER; if (!isRiver) return 0; const flux = cells.fl[cellId]; - return minmax(flux / 10, RIVER_CROSSING_MIN_COST, RIVER_CROSSING_MAX_COST); + return minmax(flux / 10, costs.RIVER_CROSSING_MIN, costs.RIVER_CROSSING_MAX); } function getTypeCost(cellId: number, type: TCultureType) { @@ -168,15 +171,15 @@ export function expandStates( const isLandCoast = t === DISTANCE_FIELD.LAND_COAST; if (isLandCoast) { - if (isMaritime) return MARITIME_LAND_COAST_FEE; - if (type === "Nomadic") return NOMADS_LAND_COAST_FEE; - return GENERIC_LAND_COAST_FEE; + if (isMaritime) return costs.MARITIME_LAND_COAST; + if (type === "Nomadic") return costs.NOMADS_LAND_COAST; + return costs.GENERIC_LAND_COAST; } const isLandlocked = t === DISTANCE_FIELD.LANDLOCKED; if (isLandlocked) { - if (type === "Naval") return NAVAL_LANDLOCKED_FEE; - return GENERIC_LANDLOCKED_FEE; + if (type === "Naval") return costs.NAVAL_LANDLOCKED; + return costs.GENERIC_LANDLOCKED; } return 0; @@ -193,7 +196,7 @@ function normalizeStates( const normalizedStateIds = Uint16Array.from(stateIds); - for (let cellId = 0; cellId > heights.length; cellId++) { + for (let cellId = 0; cellId < heights.length; cellId++) { if (heights[cellId] < MIN_LAND_HEIGHT) continue; const neibs = neibCells[cellId].filter(neib => heights[neib] >= MIN_LAND_HEIGHT); diff --git a/src/scripts/generation/pack/pack.ts b/src/scripts/generation/pack/pack.ts index c79f20e5..7ef9069d 100644 --- a/src/scripts/generation/pack/pack.ts +++ b/src/scripts/generation/pack/pack.ts @@ -143,7 +143,14 @@ export function createPack(grid: IGrid): IPack { } }); - const {provinceIds, provinces} = generateProvinces(states, burgs, cultures, {i: cells.i}); + const {provinceIds, provinces} = generateProvinces(states, burgs, cultures, { + i: cells.i, + c: cells.c, + h: heights, + t: distanceField, + state: stateIds, + burg: burgIds + }); // BurgsAndStates.generateProvinces(); // BurgsAndStates.defineBurgFeatures(); diff --git a/src/scripts/generation/pack/provinces/expandProvinces.ts b/src/scripts/generation/pack/provinces/expandProvinces.ts index 7e996d1d..b336d59f 100644 --- a/src/scripts/generation/pack/provinces/expandProvinces.ts +++ b/src/scripts/generation/pack/provinces/expandProvinces.ts @@ -1,9 +1,86 @@ +import {DISTANCE_FIELD, ELEVATION, MIN_LAND_HEIGHT} from "config/generation"; +import FlatQueue from "flatqueue"; + import {gauss} from "utils/probabilityUtils"; -export function expandProvinces(percentage: number, cells: Pick) { +const {WATER_COAST} = DISTANCE_FIELD; +const {MOUNTAINS, HILLS, LOWLANDS} = ELEVATION; + +export function expandProvinces( + percentage: number, + provinces: IProvince[], + cells: Pick +) { const provinceIds = new Uint16Array(cells.i.length); - const maxGrowth = percentage === 100 ? 1000 : gauss(20, 5, 5, 100) * percentage ** 0.5; + const queue = new FlatQueue<{cellId: number; provinceId: number; stateId: number}>(); + const cost: number[] = []; - return provinceIds; + const maxExpansionCost = percentage === 100 ? 1000 : gauss(20, 5, 5, 100) * percentage ** 0.5; + + for (const {i: provinceId, center: cellId, state: stateId} of provinces) { + provinceIds[cellId] = provinceId; + cost[cellId] = 1; + queue.push({cellId, provinceId, stateId}, 0); + } + + while (queue.length) { + const priority = queue.peekValue()!; + const {cellId, provinceId, stateId} = queue.pop()!; + + cells.c[cellId].forEach(neibCellId => { + const isLand = cells.h[neibCellId] >= MIN_LAND_HEIGHT; + if (isLand && cells.state[neibCellId] !== stateId) return; // can expand only within state + + const evevationCost = getElevationCost(cells.h[neibCellId], cells.t[neibCellId]); + const totalCost = priority + evevationCost; + if (totalCost > maxExpansionCost) return; + + if (!cost[neibCellId] || totalCost < cost[neibCellId]) { + if (isLand) provinceIds[neibCellId] = provinceId; // assign province to cell + cost[neibCellId] = totalCost; + + queue.push({cellId: neibCellId, provinceId, stateId}, totalCost); + } + }); + } + + return normalizeProvinces(provinceIds, cells.c, cells.state, cells.burg); +} + +function getElevationCost(elevation: number, distance: number) { + if (elevation >= MOUNTAINS) return 100; + if (elevation >= HILLS) return 30; + if (elevation >= LOWLANDS) return 10; + if (elevation >= MIN_LAND_HEIGHT) return 5; + if (distance === WATER_COAST) return 100; + + return 300; // deep water +} + +function normalizeProvinces( + provinceIds: Uint16Array, + neibCells: number[][], + stateIds: Uint16Array, + burgIds: Uint16Array +) { + const normalizedIds = Uint16Array.from(provinceIds); + + for (let cellId = 0; cellId < neibCells.length; cellId++) { + if (!stateIds[cellId]) continue; // skip water or neutral cells + if (burgIds[cellId]) continue; // do not overwrite burgs + + const neibs = neibCells[cellId].filter(neib => stateIds[neib] >= stateIds[cellId]); + + const adversaries = neibs.filter(neib => normalizedIds[neib] !== normalizedIds[cellId]); + if (adversaries.length < 2) continue; + + const buddies = neibs.filter(neib => normalizedIds[neib] === normalizedIds[cellId]); + if (buddies.length > 2) continue; + + // change cells's province + if (adversaries.length > buddies.length) normalizedIds[cellId] = normalizedIds[adversaries[0]]; + } + + return normalizedIds; } diff --git a/src/scripts/generation/pack/provinces/generateProvinces.ts b/src/scripts/generation/pack/provinces/generateProvinces.ts index e93d6cac..719032bd 100644 --- a/src/scripts/generation/pack/provinces/generateProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateProvinces.ts @@ -7,7 +7,7 @@ export function generateProvinces( states: TStates, burgs: TBurgs, cultures: TCultures, - cells: Pick + cells: Pick ) { TIME && console.time("generateProvinces"); @@ -16,7 +16,7 @@ export function generateProvinces( return {provinceIds: new Uint16Array(cells.i.length), provinces: [] as TProvinces[]}; const coreProvinces = generateCoreProvinces(states, burgs, cultures, percentage); - const provinceIds = expandProvinces(percentage, cells); + const provinceIds = expandProvinces(percentage, coreProvinces, cells); const provinces = [...coreProvinces]; diff --git a/src/types/pack/pack.d.ts b/src/types/pack/pack.d.ts index 39491bf8..824431c1 100644 --- a/src/types/pack/pack.d.ts +++ b/src/types/pack/pack.d.ts @@ -27,8 +27,8 @@ interface IPackCells { state: Uint16Array; culture: Uint16Array; religion: Uint16Array; - province: UintArray; - burg: UintArray; + province: Uint16Array; + burg: Uint16Array; haven: UintArray; harbor: UintArray; route: Uint8Array; // [0, 1, 2, 3], see ROUTES enum, defined by generateRoutes() From 859d20546af5aa5dcfac190ff7fca43e0e457057 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Wed, 7 Sep 2022 01:09:56 +0300 Subject: [PATCH 05/20] refactor: generate wild provicnes start --- .../pack/provinces/generateProvinces.ts | 4 +- .../pack/provinces/generateWildProvinces.ts | 126 ++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/scripts/generation/pack/provinces/generateWildProvinces.ts diff --git a/src/scripts/generation/pack/provinces/generateProvinces.ts b/src/scripts/generation/pack/provinces/generateProvinces.ts index 719032bd..c0145acb 100644 --- a/src/scripts/generation/pack/provinces/generateProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateProvinces.ts @@ -2,6 +2,7 @@ import {TIME} from "config/logging"; import {getInputNumber} from "utils/nodeUtils"; import {expandProvinces} from "./expandProvinces"; import {generateCoreProvinces} from "./generateCoreProvinces"; +import {generateWildProvinces} from "./generateWildProvinces"; export function generateProvinces( states: TStates, @@ -17,8 +18,9 @@ export function generateProvinces( const coreProvinces = generateCoreProvinces(states, burgs, cultures, percentage); const provinceIds = expandProvinces(percentage, coreProvinces, cells); + const wildProvinces = generateWildProvinces(states, burgs, cultures, coreProvinces, provinceIds, cells); // mutates provinceIds - const provinces = [...coreProvinces]; + const provinces = [...coreProvinces, ...wildProvinces]; TIME && console.timeEnd("generateProvinces"); return {provinceIds, provinces}; diff --git a/src/scripts/generation/pack/provinces/generateWildProvinces.ts b/src/scripts/generation/pack/provinces/generateWildProvinces.ts new file mode 100644 index 00000000..08ab719f --- /dev/null +++ b/src/scripts/generation/pack/provinces/generateWildProvinces.ts @@ -0,0 +1,126 @@ +import {group} from "d3-array"; +import {rand} from "utils/probabilityUtils"; + +// add "wild" provinces if some cells don't have a province assigned +export function generateWildProvinces( + states: TStates, + burgs: TBurgs, + cultures: TCultures, + coreProvinces: IProvince[], + provinceIds: Uint16Array, + cells: Pick +) { + const stateProvincesMap = group(coreProvinces, (province: IProvince) => province.state); + const noProvinceCells = Array.from(cells.i.filter(i => cells.state[i] && !provinceIds[i])); + const wildProvinces = [] as IProvince[]; + + for (const {i: stateId, name: stateName} of states) { + const stateProvinces = stateProvincesMap.get(stateId); + if (!stateProvinces || !stateProvinces.length) continue; + + const coreProvinceNames = stateProvinces.map(({name}) => name); + const colonyNamePool = [stateName, ...coreProvinceNames].filter(name => name && !/new/i.test(name)); + const getColonyName = () => { + if (colonyNamePool.length < 1) return null; + + const index = rand(colonyNamePool.length - 1); + const spliced = colonyNamePool.splice(index, 1); + return spliced[0] ? `New ${spliced[0]}` : null; + }; + + let noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === stateId); + while (noProvinceCellsInState.length) { + // add new province + const provinceId = coreProvinces.length + wildProvinces.length; + const burgCell = noProvinceCellsInState.find(i => cells.burg[i]); + const center = burgCell || noProvinceCellsInState[0]; + const burg = burgCell ? cells.burg[burgCell] : 0; + provinceIds[center] = provinceId; + + // expand province + const costs = []; + costs[center] = 1; + queue.push(center, 0); + + while (queue.length) { + const priority = queue.peekValue(); + const next = queue.pop(); + + cells.c[next].forEach(neibCellId => { + if (cells.province[neibCellId]) return; + const land = cells.h[neibCellId] >= 20; + if (cells.state[neibCellId] && cells.state[neibCellId] !== s.i) return; + const cost = land ? (cells.state[neibCellId] === s.i ? 3 : 20) : cells.t[neibCellId] ? 10 : 30; + const totalCost = priority + cost; + + if (totalCost > max) return; + if (!costs[neibCellId] || totalCost < costs[neibCellId]) { + if (land && cells.state[neibCellId] === s.i) cells.province[neibCellId] = provinceId; // assign province to a cell + costs[neibCellId] = totalCost; + queue.push(neibCellId, totalCost); + } + }); + } + + // generate "wild" province name + const cultureId = cells.culture[center]; + const f = pack.features[cells.f[center]]; + const color = brighter(getMixedColor(s.color, 0.2), 0.3); + + const provCells = noProvinceCellsInState.filter(i => cells.province[i] === provinceId); + const singleIsle = provCells.length === f.cells && !provCells.find(i => cells.f[i] !== f.i); + const isleGroup = !singleIsle && !provCells.find(i => pack.features[cells.f[i]].group !== "isle"); + const colony = !singleIsle && !isleGroup && P(0.5) && !isPassable(s.center, center); + + const name = (function () { + const colonyName = colony && P(0.8) && getColonyName(); + if (colonyName) return colonyName; + if (burgCell && P(0.5)) return burgs[burg].name; + const base = pack.cultures[cultureId].base; + + return Names.getState(Names.getBaseShort(base), base); + })(); + + const formName = (function () { + if (singleIsle) return "Island"; + if (isleGroup) return "Islands"; + if (colony) return "Colony"; + return rw(forms["Wild"]); + })(); + + const fullName = name + " " + formName; + + const dominion = colony ? P(0.95) : singleIsle || isleGroup ? P(0.7) : P(0.3); + const kinship = dominion ? 0 : 0.4; + const type = getType(center, burgs[burg]?.port); + const coa = COA.generate(s.coa, kinship, dominion, type); + coa.shield = COA.getPackShield(cultureId, s.i); + + provinces.push({i: provinceId, state: s.i, center, burg, name, formName, fullName, color, coa}); + s.provinces.push(provinceId); + + // check if there is a land way within the same state between two cells + function isPassable(from, to) { + if (cells.f[from] !== cells.f[to]) return false; // on different islands + const queue = [from]; + + const used = new Uint8Array(cells.i.length); + const state = cells.state[from]; + + while (queue.length) { + const current = queue.pop(); + if (current === to) return true; // way is found + cells.c[current].forEach(c => { + if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) return; + queue.push(c); + used[c] = 1; + }); + } + return false; // way is not found + } + + // re-check + noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === stateId && !provinceIds[i]); + } + } +} From 2c35122bb8820d7d3f2bc2ce719a024d5cac9c28 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Thu, 8 Sep 2022 01:38:26 +0300 Subject: [PATCH 06/20] refactor: generate wild provicnes continue --- src/scripts/generation/pack/pack.ts | 4 +- .../pack/provinces/expandProvinces.ts | 2 +- .../pack/provinces/generateCoreProvinces.ts | 9 +- .../pack/provinces/generateProvinces.ts | 15 +- .../pack/provinces/generateWildProvinces.ts | 282 +++++++++++------- 5 files changed, 201 insertions(+), 111 deletions(-) diff --git a/src/scripts/generation/pack/pack.ts b/src/scripts/generation/pack/pack.ts index 7ef9069d..b2917308 100644 --- a/src/scripts/generation/pack/pack.ts +++ b/src/scripts/generation/pack/pack.ts @@ -143,11 +143,13 @@ export function createPack(grid: IGrid): IPack { } }); - const {provinceIds, provinces} = generateProvinces(states, burgs, cultures, { + const {provinceIds, provinces} = generateProvinces(states, burgs, cultures, mergedFeatures, { i: cells.i, c: cells.c, h: heights, t: distanceField, + f: featureIds, + culture: cultureIds, state: stateIds, burg: burgIds }); diff --git a/src/scripts/generation/pack/provinces/expandProvinces.ts b/src/scripts/generation/pack/provinces/expandProvinces.ts index b336d59f..453d495b 100644 --- a/src/scripts/generation/pack/provinces/expandProvinces.ts +++ b/src/scripts/generation/pack/provinces/expandProvinces.ts @@ -1,6 +1,6 @@ -import {DISTANCE_FIELD, ELEVATION, MIN_LAND_HEIGHT} from "config/generation"; import FlatQueue from "flatqueue"; +import {DISTANCE_FIELD, ELEVATION, MIN_LAND_HEIGHT} from "config/generation"; import {gauss} from "utils/probabilityUtils"; const {WATER_COAST} = DISTANCE_FIELD; diff --git a/src/scripts/generation/pack/provinces/generateCoreProvinces.ts b/src/scripts/generation/pack/provinces/generateCoreProvinces.ts index ce53c068..bcebc5c7 100644 --- a/src/scripts/generation/pack/provinces/generateCoreProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateCoreProvinces.ts @@ -4,6 +4,7 @@ import {brighter, getMixedColor} from "utils/colorUtils"; import {gauss, P, rw} from "utils/probabilityUtils"; import {isBurg, isState} from "utils/typeUtils"; import {provinceForms} from "./config"; +import {generateProvinceName, generateProvinceEmblem} from "./utils"; const {COA, Names} = window; @@ -26,9 +27,9 @@ export function generateCoreProvinces(states: TStates, burgs: TBurgs, cultures: for (let i = 0; i < provincesNumber; i++) { const {i: burg, cell: center, culture: cultureId, coa: burgEmblem, name: burgName, type} = stateBurgs[i]; + const nameByBurg = P(0.5); - const name = nameByBurg ? burgName : generateName(cultureId, cultures); - 3; + const name = generateName(nameByBurg, burgName, cultureId, cultures); const formName = rw(formsPool); formsPool[formName] += 10; // increase chance to get the same form again @@ -43,7 +44,9 @@ export function generateCoreProvinces(states: TStates, burgs: TBurgs, cultures: return provinces; } -function generateName(cultureId: number, cultures: TCultures) { +function generateName(nameByBurg: boolean, burgName: string, cultureId: number, cultures: TCultures) { + if (nameByBurg) return burgName; + const base = cultures[cultureId].base; return Names.getState(Names.getBaseShort(base), base); } diff --git a/src/scripts/generation/pack/provinces/generateProvinces.ts b/src/scripts/generation/pack/provinces/generateProvinces.ts index c0145acb..cbdd29d4 100644 --- a/src/scripts/generation/pack/provinces/generateProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateProvinces.ts @@ -8,7 +8,8 @@ export function generateProvinces( states: TStates, burgs: TBurgs, cultures: TCultures, - cells: Pick + features: TPackFeatures, + cells: Pick ) { TIME && console.time("generateProvinces"); @@ -18,7 +19,17 @@ export function generateProvinces( const coreProvinces = generateCoreProvinces(states, burgs, cultures, percentage); const provinceIds = expandProvinces(percentage, coreProvinces, cells); - const wildProvinces = generateWildProvinces(states, burgs, cultures, coreProvinces, provinceIds, cells); // mutates provinceIds + + const wildProvinces = generateWildProvinces({ + states, + burgs, + cultures, + features, + coreProvinces, + provinceIds, + percentage, + cells + }); // mutates provinceIds const provinces = [...coreProvinces, ...wildProvinces]; diff --git a/src/scripts/generation/pack/provinces/generateWildProvinces.ts b/src/scripts/generation/pack/provinces/generateWildProvinces.ts index 08ab719f..851969e8 100644 --- a/src/scripts/generation/pack/provinces/generateWildProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateWildProvinces.ts @@ -1,126 +1,200 @@ import {group} from "d3-array"; -import {rand} from "utils/probabilityUtils"; +import FlatQueue from "flatqueue"; + +import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation"; +import {unique} from "utils/arrayUtils"; +import {brighter, getMixedColor} from "utils/colorUtils"; +import {gauss, P, ra, rw} from "utils/probabilityUtils"; +import {isBurg, isState} from "utils/typeUtils"; +import {provinceForms} from "./config"; + +const {COA, Names} = window; // add "wild" provinces if some cells don't have a province assigned -export function generateWildProvinces( - states: TStates, - burgs: TBurgs, - cultures: TCultures, - coreProvinces: IProvince[], - provinceIds: Uint16Array, - cells: Pick -) { - const stateProvincesMap = group(coreProvinces, (province: IProvince) => province.state); +export function generateWildProvinces({ + states, + burgs, + cultures, + features, + coreProvinces, + provinceIds, + percentage, + cells +}: { + states: TStates; + burgs: TBurgs; + cultures: TCultures; + features: TPackFeatures; + coreProvinces: IProvince[]; + provinceIds: Uint16Array; + percentage: number; + cells: Pick; +}) { const noProvinceCells = Array.from(cells.i.filter(i => cells.state[i] && !provinceIds[i])); const wildProvinces = [] as IProvince[]; + const colonyNamesMap = createColonyNamesMap(); - for (const {i: stateId, name: stateName} of states) { - const stateProvinces = stateProvincesMap.get(stateId); - if (!stateProvinces || !stateProvinces.length) continue; + for (const state of states) { + if (!isState(state)) continue; - const coreProvinceNames = stateProvinces.map(({name}) => name); - const colonyNamePool = [stateName, ...coreProvinceNames].filter(name => name && !/new/i.test(name)); - const getColonyName = () => { - if (colonyNamePool.length < 1) return null; - - const index = rand(colonyNamePool.length - 1); - const spliced = colonyNamePool.splice(index, 1); - return spliced[0] ? `New ${spliced[0]}` : null; - }; - - let noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === stateId); + let noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === state.i); while (noProvinceCellsInState.length) { - // add new province const provinceId = coreProvinces.length + wildProvinces.length; const burgCell = noProvinceCellsInState.find(i => cells.burg[i]); const center = burgCell || noProvinceCellsInState[0]; - const burg = burgCell ? cells.burg[burgCell] : 0; - provinceIds[center] = provinceId; - - // expand province - const costs = []; - costs[center] = 1; - queue.push(center, 0); - - while (queue.length) { - const priority = queue.peekValue(); - const next = queue.pop(); - - cells.c[next].forEach(neibCellId => { - if (cells.province[neibCellId]) return; - const land = cells.h[neibCellId] >= 20; - if (cells.state[neibCellId] && cells.state[neibCellId] !== s.i) return; - const cost = land ? (cells.state[neibCellId] === s.i ? 3 : 20) : cells.t[neibCellId] ? 10 : 30; - const totalCost = priority + cost; - - if (totalCost > max) return; - if (!costs[neibCellId] || totalCost < costs[neibCellId]) { - if (land && cells.state[neibCellId] === s.i) cells.province[neibCellId] = provinceId; // assign province to a cell - costs[neibCellId] = totalCost; - queue.push(neibCellId, totalCost); - } - }); - } - - // generate "wild" province name const cultureId = cells.culture[center]; - const f = pack.features[cells.f[center]]; - const color = brighter(getMixedColor(s.color, 0.2), 0.3); - const provCells = noProvinceCellsInState.filter(i => cells.province[i] === provinceId); - const singleIsle = provCells.length === f.cells && !provCells.find(i => cells.f[i] !== f.i); - const isleGroup = !singleIsle && !provCells.find(i => pack.features[cells.f[i]].group !== "isle"); - const colony = !singleIsle && !isleGroup && P(0.5) && !isPassable(s.center, center); - - const name = (function () { - const colonyName = colony && P(0.8) && getColonyName(); - if (colonyName) return colonyName; - if (burgCell && P(0.5)) return burgs[burg].name; - const base = pack.cultures[cultureId].base; - - return Names.getState(Names.getBaseShort(base), base); - })(); - - const formName = (function () { - if (singleIsle) return "Island"; - if (isleGroup) return "Islands"; - if (colony) return "Colony"; - return rw(forms["Wild"]); - })(); + const burgId = burgCell ? cells.burg[burgCell] : 0; + const burg = burgs[burgId]; + const provinceCells = expandWildProvince(center, provinceId, state.i); // mutates provinceIds + const formName = getProvinceForm(center, provinceCells, state.center); + const name = getProvinceName(state.i, formName, burg, cultureId); const fullName = name + " " + formName; - const dominion = colony ? P(0.95) : singleIsle || isleGroup ? P(0.7) : P(0.3); - const kinship = dominion ? 0 : 0.4; - const type = getType(center, burgs[burg]?.port); - const coa = COA.generate(s.coa, kinship, dominion, type); - coa.shield = COA.getPackShield(cultureId, s.i); + const coa = generateEmblem(formName, state, burg, cultureId); + const color = brighter(getMixedColor(state.color, 0.2), 0.3); - provinces.push({i: provinceId, state: s.i, center, burg, name, formName, fullName, color, coa}); - s.provinces.push(provinceId); - - // check if there is a land way within the same state between two cells - function isPassable(from, to) { - if (cells.f[from] !== cells.f[to]) return false; // on different islands - const queue = [from]; - - const used = new Uint8Array(cells.i.length); - const state = cells.state[from]; - - while (queue.length) { - const current = queue.pop(); - if (current === to) return true; // way is found - cells.c[current].forEach(c => { - if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) return; - queue.push(c); - used[c] = 1; - }); - } - return false; // way is not found - } + wildProvinces.push({i: provinceId, name, formName, center, burg: burgId, state: state.i, fullName, color, coa}); // re-check - noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === stateId && !provinceIds[i]); + noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === state.i && !provinceIds[i]); } } + + return wildProvinces; + + function createColonyNamesMap() { + const stateProvincesMap = group(coreProvinces, (province: IProvince) => province.state); + + const colonyNamesMap = new Map( + states.map(state => { + const stateProvinces = stateProvincesMap.get(state.i) || []; + const coreProvinceNames = stateProvinces.map(province => province.name); + const colonyNamePool = unique([state.name, ...coreProvinceNames].filter(name => name && !/new/i.test(name))); + return [state.i, colonyNamePool]; + }) + ); + + return colonyNamesMap; + } + + function getColonyName(stateId: number) { + const namesPool = colonyNamesMap.get(stateId) || []; + if (namesPool.length < 1) return null; + + const name = ra(namesPool); + colonyNamesMap.set( + stateId, + namesPool.filter(n => n !== name) + ); + + return `New ${name}`; + } + + function getProvinceName(stateId: number, formName: string, burg: TNoBurg | IBurg, cultureId: number) { + const colonyName = formName === "Colony" && P(0.8) && getColonyName(stateId); + if (colonyName) return colonyName; + + if (burg?.name && P(0.5)) return burg.name; + + const base = cultures[cultureId].base; + return Names.getState(Names.getBaseShort(base), base); + } + + function expandWildProvince(center: number, provinceId: number, stateId: number) { + const maxExpansionCost = percentage === 100 ? 1000 : gauss(20, 5, 5, 100) * percentage ** 0.5; + + const provinceCells = [center]; + provinceIds[center] = provinceId; + + const queue = new FlatQueue(); + const cost: number[] = []; + cost[center] = 1; + queue.push(center, 0); + + while (queue.length) { + const priority = queue.peekValue()!; + const next = queue.pop()!; + + cells.c[next].forEach(neibCellId => { + if (provinceIds[neibCellId]) return; + if (cells.state[neibCellId] !== stateId) return; + + const isLand = cells.h[neibCellId] >= MIN_LAND_HEIGHT; + const cellCost = isLand ? 3 : cells.t[neibCellId] === DISTANCE_FIELD.WATER_COAST ? 10 : 30; + const totalCost = priority + cellCost; + if (totalCost > maxExpansionCost) return; + + if (!cost[neibCellId] || totalCost < cost[neibCellId]) { + if (isLand && cells.state[neibCellId] === stateId) { + // assign province to a cell + provinceCells.push(neibCellId); + provinceIds[neibCellId] = provinceId; + } + cost[neibCellId] = totalCost; + queue.push(neibCellId, totalCost); + } + }); + } + + return provinceCells; + } + + function getProvinceForm(center: number, provinceCells: number[], stateCenter: number) { + const feature = features[cells.f[center]]; + if (feature === 0) throw new Error("Feature is not defined"); + + const provinceFeatures = unique(provinceCells.map(i => cells.f[i])); + const isWholeIsle = provinceCells.length === feature.cells && provinceFeatures.length === 1; + if (isWholeIsle) return "Island"; + + const isIsleGroup = provinceFeatures.every(featureId => (features[featureId] as TPackFeature)?.group === "isle"); + if (isIsleGroup) return "Islands"; + + const isColony = P(0.5) && !isConnected(stateCenter, center); + if (isColony) return "Colony"; + + return rw(provinceForms["Wild"]); + + // check if two cells are connected by land withing same state + function isConnected(from: number, to: number) { + if (cells.f[from] !== cells.f[to]) return false; // on different islands + const queue = [from]; + const checked: Dict = {[from]: true}; + const stateId = cells.state[from]; + + while (queue.length) { + const current = queue.pop()!; + if (current === to) return true; + + for (const neibId of cells.c[current]) { + if (checked[neibId] || cells.state[neibId] !== stateId) continue; + queue.push(neibId); + checked[neibId] = true; + } + } + return false; + } + } + + function generateEmblem(formName: string, state: IState, burg: TNoBurg | IBurg, cultureId: number) { + const dominion = P(getDominionChance(formName)); + const kinship = dominion ? 0 : 0.4; + const coaType = isBurg(burg) ? burg.type : "Generic"; + const coa = COA.generate(state.coa, kinship, dominion, coaType); + + const cultureShield = cultures[cultureId].shield; + const stateShield = (state.coa as ICoa)?.shield; + coa.shield = COA.getShield(cultureShield, stateShield); + + return coa; + } + + function getDominionChance(formName: string) { + if (formName === "Colony") return 0.95; + if (formName === "Island") return 0.7; + if (formName === "Islands") return 0.7; + return 0.3; + } } From 4dc5648310afb76b219537bd8b77ab3f2a573444 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Thu, 8 Sep 2022 22:22:03 +0300 Subject: [PATCH 07/20] refactor: generate provinces end --- .../renderers/{drawProvinces.js => drawProvinces.ts} | 0 src/scripts/generation/generation.ts | 4 ++-- src/scripts/generation/pack/provinces/generateProvinces.ts | 7 +++---- src/types/pack/provinces.d.ts | 4 +++- 4 files changed, 8 insertions(+), 7 deletions(-) rename src/layers/renderers/{drawProvinces.js => drawProvinces.ts} (100%) diff --git a/src/layers/renderers/drawProvinces.js b/src/layers/renderers/drawProvinces.ts similarity index 100% rename from src/layers/renderers/drawProvinces.js rename to src/layers/renderers/drawProvinces.ts diff --git a/src/scripts/generation/generation.ts b/src/scripts/generation/generation.ts index 5d90ff9a..afbe73db 100644 --- a/src/scripts/generation/generation.ts +++ b/src/scripts/generation/generation.ts @@ -69,8 +69,8 @@ async function generate(options?: IGenerationOptions) { // renderLayer("biomes"); renderLayer("burgs"); renderLayer("routes"); - renderLayer("states"); - // renderLayer("religions"); + // renderLayer("states"); + renderLayer("provinces"); // pack.cells.route.forEach((route, index) => { // if (route === 2) drawPoint(pack.cells.p[index], {color: "black"}); diff --git a/src/scripts/generation/pack/provinces/generateProvinces.ts b/src/scripts/generation/pack/provinces/generateProvinces.ts index cbdd29d4..906ccfaa 100644 --- a/src/scripts/generation/pack/provinces/generateProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateProvinces.ts @@ -10,12 +10,11 @@ export function generateProvinces( cultures: TCultures, features: TPackFeatures, cells: Pick -) { +): {provinceIds: Uint16Array; provinces: TProvinces} { TIME && console.time("generateProvinces"); const percentage = getInputNumber("provincesInput"); - if (states.length < 2 || percentage === 0) - return {provinceIds: new Uint16Array(cells.i.length), provinces: [] as TProvinces[]}; + if (states.length < 2 || percentage === 0) return {provinceIds: new Uint16Array(cells.i.length), provinces: [0]}; const coreProvinces = generateCoreProvinces(states, burgs, cultures, percentage); const provinceIds = expandProvinces(percentage, coreProvinces, cells); @@ -31,7 +30,7 @@ export function generateProvinces( cells }); // mutates provinceIds - const provinces = [...coreProvinces, ...wildProvinces]; + const provinces: TProvinces = [0, ...coreProvinces, ...wildProvinces]; TIME && console.timeEnd("generateProvinces"); return {provinceIds, provinces}; diff --git a/src/types/pack/provinces.d.ts b/src/types/pack/provinces.d.ts index a055a542..a9b202b7 100644 --- a/src/types/pack/provinces.d.ts +++ b/src/types/pack/provinces.d.ts @@ -11,4 +11,6 @@ interface IProvince { removed?: boolean; } -type TProvinces = IProvince[]; +type TNoProvince = 0; + +type TProvinces = [TNoProvince, ...IProvince[]]; From b2ab69984389210e7fdc38c99a2df135ce2c738e Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 10 Sep 2022 22:50:45 +0300 Subject: [PATCH 08/20] refactor: render provinces --- src/index.css | 5 +- src/layers/renderers/drawEmblems.js | 3 +- src/layers/renderers/drawProvinces.ts | 151 +++++------------- src/scripts/generation/pack/pack.ts | 3 +- .../pack/provinces/generateCoreProvinces.ts | 3 +- .../pack/provinces/generateProvinces.ts | 6 +- .../pack/provinces/generateWildProvinces.ts | 2 +- .../pack/provinces/specifyProvinces.ts | 20 +++ src/scripts/getPolesOfInaccessibility.ts | 64 ++++++++ src/types/pack/provinces.d.ts | 1 + src/types/pack/states.d.ts | 2 +- src/utils/typeUtils.ts | 3 + 12 files changed, 136 insertions(+), 127 deletions(-) create mode 100644 src/scripts/generation/pack/provinces/specifyProvinces.ts create mode 100644 src/scripts/getPolesOfInaccessibility.ts diff --git a/src/index.css b/src/index.css index a626de1c..39b660e4 100644 --- a/src/index.css +++ b/src/index.css @@ -169,6 +169,7 @@ a { font-size: 0.8em; } +#provincesBody, #statesBody { stroke-width: 3; } @@ -179,10 +180,6 @@ a { stroke-linejoin: round; } -#provincesBody { - stroke-width: 0.2; -} - #statesBody, #provincesBody, #relig, diff --git a/src/layers/renderers/drawEmblems.js b/src/layers/renderers/drawEmblems.js index d571c681..06f1ca1e 100644 --- a/src/layers/renderers/drawEmblems.js +++ b/src/layers/renderers/drawEmblems.js @@ -1,6 +1,5 @@ import * as d3 from "d3"; -import {getProvincesVertices} from "./drawProvinces"; import {minmax, rn} from "utils/numberUtils"; import {byId} from "utils/shorthands"; @@ -42,7 +41,7 @@ export function drawEmblems() { const sizeProvinces = getProvinceEmblemsSize(); const provinceCOAs = validProvinces.map(province => { - if (!province.pole) getProvincesVertices(); + if (!province.pole) throw "Pole is not defined"; const [x, y] = province.pole || pack.cells.p[province.center]; const size = province.coaSize || 1; const shift = (sizeProvinces * size) / 2; diff --git a/src/layers/renderers/drawProvinces.ts b/src/layers/renderers/drawProvinces.ts index 10b4de42..f6c884fc 100644 --- a/src/layers/renderers/drawProvinces.ts +++ b/src/layers/renderers/drawProvinces.ts @@ -1,122 +1,45 @@ -import polylabel from "polylabel"; +import {pick} from "utils/functionUtils"; +import {byId} from "utils/shorthands"; +import {isProvince} from "utils/typeUtils"; +import {getPaths} from "./utils/getVertexPaths"; export function drawProvinces() { - const labelsOn = provs.attr("data-labels") == 1; - provs.selectAll("*").remove(); + /* global */ const {cells, vertices, features, provinces} = pack; - const provinces = pack.provinces; - const {body, gap} = getProvincesVertices(); - - const g = provs.append("g").attr("id", "provincesBody"); - const bodyData = body.map((p, i) => [p.length > 10 ? p : null, i, provinces[i].color]).filter(d => d[0]); - g.selectAll("path") - .data(bodyData) - .enter() - .append("path") - .attr("d", d => d[0]) - .attr("fill", d => d[2]) - .attr("stroke", "none") - .attr("id", d => "province" + d[1]); - const gapData = gap.map((p, i) => [p.length > 10 ? p : null, i, provinces[i].color]).filter(d => d[0]); - g.selectAll(".path") - .data(gapData) - .enter() - .append("path") - .attr("d", d => d[0]) - .attr("fill", "none") - .attr("stroke", d => d[2]) - .attr("id", d => "province-gap" + d[1]); - - const labels = provs.append("g").attr("id", "provinceLabels"); - labels.style("display", `${labelsOn ? "block" : "none"}`); - const labelData = provinces.filter(p => p.i && !p.removed && p.pole); - labels - .selectAll(".path") - .data(labelData) - .enter() - .append("text") - .attr("x", d => d.pole[0]) - .attr("y", d => d.pole[1]) - .attr("id", d => "provinceLabel" + d.i) - .text(d => d.name); -} - -export function getProvincesVertices() { - const cells = pack.cells, - vertices = pack.vertices, - provinces = pack.provinces, - n = cells.i.length; - const used = new Uint8Array(cells.i.length); - const vArray = new Array(provinces.length); // store vertices array - const body = new Array(provinces.length).fill(""); // store path around each province - const gap = new Array(provinces.length).fill(""); // store path along water for each province to fill the gaps - - for (const i of cells.i) { - if (!cells.province[i] || used[i]) continue; - const p = cells.province[i]; - const onborder = cells.c[i].some(n => cells.province[n] !== p); - if (!onborder) continue; - - const borderWith = cells.c[i].map(c => cells.province[c]).find(n => n !== p); - const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.province[i] === borderWith)); - const chain = connectVertices(vertex, p, borderWith); - if (chain.length < 3) continue; - const points = chain.map(v => vertices.p[v[0]]); - if (!vArray[p]) vArray[p] = []; - vArray[p].push(points); - body[p] += "M" + points.join("L"); - gap[p] += - "M" + - vertices.p[chain[0][0]] + - chain.reduce( - (r, v, i, d) => - !i ? r : !v[2] ? r + "L" + vertices.p[v[0]] : d[i + 1] && !d[i + 1][2] ? r + "M" + vertices.p[v[0]] : r, - "" - ); - } - - // find province visual center - vArray.forEach((ar, i) => { - const sorted = ar.sort((a, b) => b.length - a.length); // sort by points number - provinces[i].pole = polylabel(sorted, 1.0); // pole of inaccessibility + const paths = getPaths({ + getType: (cellId: number) => cells.province[cellId], + cells: pick(cells, "c", "v", "b", "h", "f"), + vertices, + features, + options: {fill: true, waterGap: true, halo: false} }); - return {body, gap}; + const getColor = (i: string) => (provinces[Number(i)] as IProvince).color; - // connect vertices to chain - function connectVertices(start, t, province) { - const chain = []; // vertices chain to form a path - let land = vertices.c[start].some(c => cells.h[c] >= 20 && cells.province[c] !== t); - function check(i) { - province = cells.province[i]; - land = cells.h[i] >= 20; - } + const getLabels = () => { + const renderLabels = byId("provs")!.getAttribute("data-labels") === "1"; + if (!renderLabels) return []; - for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); i++) { - const prev = chain[chain.length - 1] ? chain[chain.length - 1][0] : -1; // previous vertex in chain - chain.push([current, province, land]); // add current vertex to sequence - const c = vertices.c[current]; // cells adjacent to vertex - c.filter(c => cells.province[c] === t).forEach(c => (used[c] = 1)); - const c0 = c[0] >= n || cells.province[c[0]] !== t; - const c1 = c[1] >= n || cells.province[c[1]] !== t; - const c2 = c[2] >= n || cells.province[c[2]] !== t; - const v = vertices.v[current]; // neighboring vertices - if (v[0] !== prev && c0 !== c1) { - current = v[0]; - check(c0 ? c[0] : c[1]); - } else if (v[1] !== prev && c1 !== c2) { - current = v[1]; - check(c1 ? c[1] : c[2]); - } else if (v[2] !== prev && c0 !== c2) { - current = v[2]; - check(c2 ? c[2] : c[0]); - } - if (current === chain[chain.length - 1][0]) { - ERROR && console.error("Next vertex is not found"); - break; - } - } - chain.push([start, province, land]); // add starting vertex to sequence to close the path - return chain; - } + return provinces.filter(isProvince).map(({i, pole: [x, y], name}) => { + return `${name}`; + }); + }; + + const htmlPaths = paths.map(([index, {fill, waterGap}]) => { + const color = getColor(index); + + return /* html */ ` + + + `; + }); + + byId("provs")!.innerHTML = /* html*/ ` + + ${htmlPaths.join("")} + + + ${getLabels().join("")} + + `; } diff --git a/src/scripts/generation/pack/pack.ts b/src/scripts/generation/pack/pack.ts index b2917308..e2000597 100644 --- a/src/scripts/generation/pack/pack.ts +++ b/src/scripts/generation/pack/pack.ts @@ -143,9 +143,10 @@ export function createPack(grid: IGrid): IPack { } }); - const {provinceIds, provinces} = generateProvinces(states, burgs, cultures, mergedFeatures, { + const {provinceIds, provinces} = generateProvinces(states, burgs, cultures, mergedFeatures, vertices, { i: cells.i, c: cells.c, + v: cells.v, h: heights, t: distanceField, f: featureIds, diff --git a/src/scripts/generation/pack/provinces/generateCoreProvinces.ts b/src/scripts/generation/pack/provinces/generateCoreProvinces.ts index bcebc5c7..84c804f9 100644 --- a/src/scripts/generation/pack/provinces/generateCoreProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateCoreProvinces.ts @@ -4,7 +4,6 @@ import {brighter, getMixedColor} from "utils/colorUtils"; import {gauss, P, rw} from "utils/probabilityUtils"; import {isBurg, isState} from "utils/typeUtils"; import {provinceForms} from "./config"; -import {generateProvinceName, generateProvinceEmblem} from "./utils"; const {COA, Names} = window; @@ -37,7 +36,7 @@ export function generateCoreProvinces(states: TStates, burgs: TBurgs, cultures: const color = brighter(getMixedColor(state.color, 0.2), 0.3); const coa = generateEmblem(nameByBurg, burgEmblem, type, cultures, cultureId, state); - provinces.push({i: provinces.length, name, formName, center, burg, state: state.i, fullName, color, coa}); + provinces.push({i: provinces.length + 1, name, formName, center, burg, state: state.i, fullName, color, coa}); } }); diff --git a/src/scripts/generation/pack/provinces/generateProvinces.ts b/src/scripts/generation/pack/provinces/generateProvinces.ts index 906ccfaa..d9c79c62 100644 --- a/src/scripts/generation/pack/provinces/generateProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateProvinces.ts @@ -3,13 +3,15 @@ import {getInputNumber} from "utils/nodeUtils"; import {expandProvinces} from "./expandProvinces"; import {generateCoreProvinces} from "./generateCoreProvinces"; import {generateWildProvinces} from "./generateWildProvinces"; +import {specifyProvinces} from "./specifyProvinces"; export function generateProvinces( states: TStates, burgs: TBurgs, cultures: TCultures, features: TPackFeatures, - cells: Pick + vertices: IGraphVertices, + cells: Pick ): {provinceIds: Uint16Array; provinces: TProvinces} { TIME && console.time("generateProvinces"); @@ -30,7 +32,7 @@ export function generateProvinces( cells }); // mutates provinceIds - const provinces: TProvinces = [0, ...coreProvinces, ...wildProvinces]; + const provinces = specifyProvinces(provinceIds, coreProvinces, wildProvinces, vertices, cells.c, cells.v); TIME && console.timeEnd("generateProvinces"); return {provinceIds, provinces}; diff --git a/src/scripts/generation/pack/provinces/generateWildProvinces.ts b/src/scripts/generation/pack/provinces/generateWildProvinces.ts index 851969e8..f9f64c5e 100644 --- a/src/scripts/generation/pack/provinces/generateWildProvinces.ts +++ b/src/scripts/generation/pack/provinces/generateWildProvinces.ts @@ -39,7 +39,7 @@ export function generateWildProvinces({ let noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === state.i); while (noProvinceCellsInState.length) { - const provinceId = coreProvinces.length + wildProvinces.length; + const provinceId = coreProvinces.length + wildProvinces.length + 1; const burgCell = noProvinceCellsInState.find(i => cells.burg[i]); const center = burgCell || noProvinceCellsInState[0]; const cultureId = cells.culture[center]; diff --git a/src/scripts/generation/pack/provinces/specifyProvinces.ts b/src/scripts/generation/pack/provinces/specifyProvinces.ts new file mode 100644 index 00000000..96d88dc2 --- /dev/null +++ b/src/scripts/generation/pack/provinces/specifyProvinces.ts @@ -0,0 +1,20 @@ +import {getPolesOfInaccessibility} from "scripts/getPolesOfInaccessibility"; + +export function specifyProvinces( + provinceIds: Uint16Array, + coreProvinces: IProvince[], + wildProvinces: IProvince[], + vertices: IGraphVertices, + cellNeighbors: number[][], + cellVertices: number[][] +): TProvinces { + const getType = (cellId: number) => provinceIds[cellId]; + const poles = getPolesOfInaccessibility({vertices, getType, cellNeighbors, cellVertices}); + + const provinces = [...coreProvinces, ...wildProvinces].map(province => { + const pole = poles[province.i]; + return {...province, pole}; + }); + + return [0, ...provinces]; +} diff --git a/src/scripts/getPolesOfInaccessibility.ts b/src/scripts/getPolesOfInaccessibility.ts new file mode 100644 index 00000000..dd40f43d --- /dev/null +++ b/src/scripts/getPolesOfInaccessibility.ts @@ -0,0 +1,64 @@ +import polylabel from "polylabel"; + +import {TIME} from "config/logging"; +import {connectVertices} from "./connectVertices"; + +interface IGetPolesProps { + vertices: IGraphVertices; + cellNeighbors: number[][]; + cellVertices: number[][]; + getType: (cellId: number) => number; +} + +export function getPolesOfInaccessibility(props: IGetPolesProps) { + TIME && console.time("getPolesOfInaccessibility"); + const multiPolygons = getMultiPolygons(props); + + const poles: Dict = Object.fromEntries( + Object.entries(multiPolygons).map(([id, multiPolygon]) => { + const [x, y] = polylabel(multiPolygon, 20); + return [id, [x, y]]; + }) + ); + + TIME && console.timeEnd("getPolesOfInaccessibility"); + return poles; +} + +function getMultiPolygons({vertices, getType, cellNeighbors, cellVertices}: IGetPolesProps) { + const multiPolygons: Dict = {}; + + const checkedCells = new Uint8Array(cellNeighbors.length); + const addToChecked = (cellId: number) => { + checkedCells[cellId] = 1; + }; + const isChecked = (cellId: number) => checkedCells[cellId] === 1; + + for (let cellId = 0; cellId < cellNeighbors.length; cellId++) { + if (isChecked(cellId) || getType(cellId) === 0) continue; + addToChecked(cellId); + + const type = getType(cellId); + const ofSameType = (cellId: number) => getType(cellId) === type; + const ofDifferentType = (cellId: number) => getType(cellId) !== type; + + const onborderCell = cellNeighbors[cellId].find(ofDifferentType); + if (onborderCell === undefined) continue; + + const startingVertex = cellVertices[cellId].find(v => vertices.c[v].some(ofDifferentType)); + if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`); + + const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true}); + if (vertexChain.length < 3) continue; + + addPolygon(type, vertexChain); + } + + return multiPolygons; + + function addPolygon(id: number, vertexChain: number[]) { + if (!multiPolygons[id]) multiPolygons[id] = []; + const polygon = vertexChain.map(vertex => vertices.p[vertex]); + multiPolygons[id].push(polygon); + } +} diff --git a/src/types/pack/provinces.d.ts b/src/types/pack/provinces.d.ts index a9b202b7..39f7ec8e 100644 --- a/src/types/pack/provinces.d.ts +++ b/src/types/pack/provinces.d.ts @@ -7,6 +7,7 @@ interface IProvince { color: Hex | CssUrls; state: number; center: number; + pole: TPoint; coa: ICoa | string; removed?: boolean; } diff --git a/src/types/pack/states.d.ts b/src/types/pack/states.d.ts index f001f711..79dd27cd 100644 --- a/src/types/pack/states.d.ts +++ b/src/types/pack/states.d.ts @@ -10,8 +10,8 @@ interface IState { form: TStateForm; formName: string; fullName: string; + pole: TPoint; coa: ICoa | string; - // pole: TPoint ? area: number; cells: number; burgs: number; diff --git a/src/utils/typeUtils.ts b/src/utils/typeUtils.ts index 8248144b..f355f139 100644 --- a/src/utils/typeUtils.ts +++ b/src/utils/typeUtils.ts @@ -14,3 +14,6 @@ export const isBurg = (burg: TNoBurg | IBurg): burg is IBurg => burg.i !== 0 && export const isReligion = (religion: TNoReligion | IReligion): religion is IReligion => religion.i !== 0 && !(religion as IReligion).removed; + +export const isProvince = (province: TNoProvince | IProvince): province is IProvince => + province !== 0 && !(province as IProvince).removed; From ff5ef1ca4bb67716fed598160d228eb99c4d1a32 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 10 Sep 2022 23:04:03 +0300 Subject: [PATCH 09/20] refactor: add poles to state data --- .../pack/burgsAndStates/generateBurgsAndStates.ts | 9 ++++++++- .../generation/pack/burgsAndStates/specifyStates.ts | 6 +++++- src/scripts/getPolesOfInaccessibility.ts | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/scripts/generation/pack/burgsAndStates/generateBurgsAndStates.ts b/src/scripts/generation/pack/burgsAndStates/generateBurgsAndStates.ts index d2d2dc8f..471a6bc1 100644 --- a/src/scripts/generation/pack/burgsAndStates/generateBurgsAndStates.ts +++ b/src/scripts/generation/pack/burgsAndStates/generateBurgsAndStates.ts @@ -1,4 +1,5 @@ import {WARN} from "config/logging"; +import {getPolesOfInaccessibility} from "scripts/getPolesOfInaccessibility"; import {pick} from "utils/functionUtils"; import {getInputNumber} from "utils/nodeUtils"; import {collectStatistics} from "./collectStatistics"; @@ -70,7 +71,13 @@ export function generateBurgsAndStates( const statistics = collectStatistics({...cells, state: stateIds, burg: burgIds}, burgs); const diplomacy = generateRelations(statesData, statistics, pick(cells, "f")); - const {states, conflicts} = specifyStates(statesData, statistics, diplomacy, cultures, burgs); + const poles = getPolesOfInaccessibility({ + vertices, + getType: (cellId: number) => stateIds[cellId], + cellNeighbors: cells.c, + cellVertices: cells.v + }); + const {states, conflicts} = specifyStates(statesData, statistics, diplomacy, poles, cultures, burgs); return {burgIds, stateIds, burgs, states, conflicts}; diff --git a/src/scripts/generation/pack/burgsAndStates/specifyStates.ts b/src/scripts/generation/pack/burgsAndStates/specifyStates.ts index ba9be9ee..3edbe3c2 100644 --- a/src/scripts/generation/pack/burgsAndStates/specifyStates.ts +++ b/src/scripts/generation/pack/burgsAndStates/specifyStates.ts @@ -14,6 +14,7 @@ export function specifyStates( statesData: TStateData[], statistics: TStateStatistics, diplomacy: TDiplomacy, + poles: Dict, cultures: TCultures, burgs: TBurgs ): {states: TStates; conflicts: IConflict[]} { @@ -41,6 +42,8 @@ export function specifyStates( const name = defineStateName(center, capitalName, nameBase, formName); const fullName = defineFullStateName(name, formName); + const pole = poles[i]; + return { name, ...stateData, @@ -52,7 +55,8 @@ export function specifyStates( burgs: burgsNumber, ...stats, neighbors, - relations + relations, + pole }; }); diff --git a/src/scripts/getPolesOfInaccessibility.ts b/src/scripts/getPolesOfInaccessibility.ts index dd40f43d..384cea99 100644 --- a/src/scripts/getPolesOfInaccessibility.ts +++ b/src/scripts/getPolesOfInaccessibility.ts @@ -2,6 +2,7 @@ import polylabel from "polylabel"; import {TIME} from "config/logging"; import {connectVertices} from "./connectVertices"; +import {rn} from "utils/numberUtils"; interface IGetPolesProps { vertices: IGraphVertices; @@ -17,7 +18,7 @@ export function getPolesOfInaccessibility(props: IGetPolesProps) { const poles: Dict = Object.fromEntries( Object.entries(multiPolygons).map(([id, multiPolygon]) => { const [x, y] = polylabel(multiPolygon, 20); - return [id, [x, y]]; + return [id, [rn(x), rn(y)]]; }) ); From e35cc2e9cb83918b5afae6ebe357a2f377fde972 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Mon, 12 Sep 2022 02:34:16 +0300 Subject: [PATCH 10/20] refactor: draw state labels start --- src/layers/renderers/drawLabels.ts | 88 +++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/src/layers/renderers/drawLabels.ts b/src/layers/renderers/drawLabels.ts index a7537c1d..a515f0cc 100644 --- a/src/layers/renderers/drawLabels.ts +++ b/src/layers/renderers/drawLabels.ts @@ -1,15 +1,26 @@ +import {MIN_LAND_HEIGHT} from "config/generation"; +import * as d3 from "d3"; +import Delaunator from "delaunator"; +import {Voronoi} from "modules/voronoi"; + +import {findCell} from "utils/graphUtils"; +import {isState} from "utils/typeUtils"; + export function drawLabels() { - drawBurgLabels(); + /* global */ const {cells, vertices, features, states, burgs} = pack; + + drawStateLabels(cells, features, states, vertices); + drawBurgLabels(burgs); // TODO: draw other labels window.Zoom.invoke(); } -function drawBurgLabels() { +function drawBurgLabels(burgs: TBurgs) { // remove old data burgLabels.selectAll("text").remove(); - const validBurgs = pack.burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[]; + const validBurgs = burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[]; // capitals const capitals = validBurgs.filter(burg => burg.capital); @@ -45,3 +56,74 @@ function drawBurgLabels() { .attr("dy", `${townSize * -1.5}px`) .text(d => d.name); } + +function drawStateLabels(cells: IPack["cells"], features: TPackFeatures, states: TStates, vertices: IGraphVertices) { + const lineGen = d3.line().curve(d3.curveBundle.beta(1)); + const mode = options.stateLabelsMode || "auto"; + + const labelPaths = getLabelPaths(); + + function getLabelPaths() { + const labelPaths: number[][] = []; + const MIN_HULL_SIZE = 20; + + for (const state of states) { + if (!isState(state)) continue; + const used: Dict = {}; + + const visualCenter = findCell(...state.pole); + const start = cells.state[visualCenter] === state.i ? visualCenter : state.center; + const hull = getHull(start, state.i, state.cells, used); + const points = [...hull].map(vertex => vertices.p[vertex]); + const delaunay = Delaunator.from(points); + const voronoi = new Voronoi(delaunay, points, points.length); + const chain = connectCenters(voronoi.vertices, state.pole[1]); + const relaxed = chain.map(i => voronoi.vertices.p[i]).filter((p, i) => i % 15 === 0 || i + 1 === chain.length); + labelPaths.push([state.i, relaxed]); + } + + return labelPaths; + + function getHull(start: number, stateId: number, stateCells: number, used: Dict) { + const queue = [start]; + const hull = new Set(); + const addHull = (cellId: number, neibCellIndex: number) => hull.add(cells.v[cellId][neibCellIndex]); + const maxPassableLakeSize = stateCells / 10; + + while (queue.length) { + const cellId = queue.pop()!; + + cells.c[cellId].forEach((neibCellId, neibCellIndex) => { + if (used[neibCellId]) return; + if (isHullEdge(neibCellId)) return addHull(neibCellId, neibCellIndex); + + used[neibCellId] = true; + return queue.push(neibCellId); + }); + } + + return hull; + + function isHullEdge(cellId: number) { + if (cells.b[cellId]) return true; + + if (cells.h[cellId] < MIN_LAND_HEIGHT) { + const feature = features[cells.f[cellId]]; + if (!feature || feature.type !== "lake") return true; + if (feature.cells > maxPassableLakeSize) return true; + return false; + } + + if (cells.state[cellId] !== stateId) return true; + + if (hull.size > MIN_HULL_SIZE) { + // stop on narrow passages + const sameStateNeibs = cells.c[cellId].filter(c => cells.state[c] === stateId); + if (sameStateNeibs.length < 3) return true; + } + + return false; + } + } + } +} From e07bf91cd7a51979cff27176133912ca36a2f4ac Mon Sep 17 00:00:00 2001 From: Azgaar Date: Tue, 13 Sep 2022 00:13:13 +0300 Subject: [PATCH 11/20] refactor: draw state labels start --- src/layers/renderers/drawLabels.ts | 128 ++++++++++++++++++++--- src/scripts/generation/generation.ts | 4 +- src/scripts/getPolesOfInaccessibility.ts | 4 +- src/utils/debugUtils.ts | 13 +++ 4 files changed, 131 insertions(+), 18 deletions(-) diff --git a/src/layers/renderers/drawLabels.ts b/src/layers/renderers/drawLabels.ts index a515f0cc..bcaecec2 100644 --- a/src/layers/renderers/drawLabels.ts +++ b/src/layers/renderers/drawLabels.ts @@ -1,13 +1,17 @@ -import {MIN_LAND_HEIGHT} from "config/generation"; import * as d3 from "d3"; import Delaunator from "delaunator"; -import {Voronoi} from "modules/voronoi"; +import FlatQueue from "flatqueue"; +import {simplify} from "scripts/simplify"; +import {Voronoi} from "modules/voronoi"; +import {MIN_LAND_HEIGHT} from "config/generation"; import {findCell} from "utils/graphUtils"; import {isState} from "utils/typeUtils"; +import {drawPath, drawPoint, drawPolyline} from "utils/debugUtils"; export function drawLabels() { /* global */ const {cells, vertices, features, states, burgs} = pack; + /* global: findCell, graphWidth, graphHeight */ drawStateLabels(cells, features, states, vertices); drawBurgLabels(burgs); @@ -62,42 +66,64 @@ function drawStateLabels(cells: IPack["cells"], features: TPackFeatures, states: const mode = options.stateLabelsMode || "auto"; const labelPaths = getLabelPaths(); + console.log(labelPaths); function getLabelPaths() { - const labelPaths: number[][] = []; + const labelPaths: [number, TPoints][] = []; const MIN_HULL_SIZE = 20; + const lineGen = d3.line().curve(d3.curveBundle.beta(1)); for (const state of states) { if (!isState(state)) continue; - const used: Dict = {}; + const used: Dict = {}; // mutable const visualCenter = findCell(...state.pole); - const start = cells.state[visualCenter] === state.i ? visualCenter : state.center; - const hull = getHull(start, state.i, state.cells, used); + const startingCell = cells.state[visualCenter] === state.i ? visualCenter : state.center; + const hull = getHull(startingCell, state.i, state.cells, used); const points = [...hull].map(vertex => vertices.p[vertex]); + const delaunay = Delaunator.from(points); const voronoi = new Voronoi(delaunay, points, points.length); - const chain = connectCenters(voronoi.vertices, state.pole[1]); - const relaxed = chain.map(i => voronoi.vertices.p[i]).filter((p, i) => i % 15 === 0 || i + 1 === chain.length); - labelPaths.push([state.i, relaxed]); + const chain = connectVertices(voronoi.vertices, state.pole, used); + + drawPoint(state.pole, {color: "blue", radius: 1}); + + if (state.i === 1) { + points.forEach(point => { + drawPoint(point, {color: "red", radius: 0.5}); + }); + } + + const pathPoints = simplify( + chain.map(i => voronoi.vertices.p[i]), + 30 + ); + + drawPath(lineGen(pathPoints)!, {stroke: "red", strokeWidth: 0.5}); + + labelPaths.push([state.i, pathPoints]); } return labelPaths; function getHull(start: number, stateId: number, stateCells: number, used: Dict) { - const queue = [start]; - const hull = new Set(); - const addHull = (cellId: number, neibCellIndex: number) => hull.add(cells.v[cellId][neibCellIndex]); const maxPassableLakeSize = stateCells / 10; + const queue = [start]; + + const hull = new Set(); + const addToHull = (cellId: number, index: number) => { + const vertex = cells.v[cellId][index]; + if (vertex) hull.add(vertex); + }; while (queue.length) { const cellId = queue.pop()!; - cells.c[cellId].forEach((neibCellId, neibCellIndex) => { + cells.c[cellId].forEach((neibCellId, index) => { if (used[neibCellId]) return; - if (isHullEdge(neibCellId)) return addHull(neibCellId, neibCellIndex); - used[neibCellId] = true; + + if (isHullEdge(neibCellId)) return addToHull(cellId, index); return queue.push(neibCellId); }); } @@ -125,5 +151,77 @@ function drawStateLabels(cells: IPack["cells"], features: TPackFeatures, states: return false; } } + + function connectVertices(vertices: Voronoi["vertices"], pole: TPoint, used: Dict) { + // check if vertex is inside the area + const inside = vertices.p.map(([x, y]) => { + if (x <= 0 || y <= 0 || x >= graphWidth || y >= graphHeight) return false; // out of the screen + return used[findCell(x, y)]; + }); + + const innerVertices = d3.range(vertices.p.length).filter(i => inside[i]); + if (innerVertices.length < 2) return [0]; + + const horyzontalShift = getHoryzontalShift(vertices.p.length); + const {right: start, left: end} = getEdgeVertices(innerVertices, vertices.p, pole, horyzontalShift); + + // connect leftmost and rightmost vertices with shortest path + const cost: number[] = []; + const from: number[] = []; + const queue = new FlatQueue(); + queue.push(start, 0); + + while (queue.length) { + const priority = queue.peekValue()!; + const next = queue.pop()!; + + if (next === end) break; + + for (const neibVertex of vertices.v[next]) { + if (neibVertex === -1) continue; + + const totalCost = priority + (inside[neibVertex] ? 1 : 100); + if (from[neibVertex] || totalCost >= cost[neibVertex]) continue; + + cost[neibVertex] = totalCost; + from[neibVertex] = next; + queue.push(neibVertex, totalCost); + } + } + + // restore path + const chain = [end]; + let cur = end; + while (cur !== start) { + cur = from[cur]; + if (inside[cur]) chain.push(cur); + } + return chain; + } + + function getHoryzontalShift(verticesNumber: number) { + console.log({verticesNumber}); + return 0; + if (verticesNumber < 100) return 1; + if (verticesNumber < 200) return 0.3; + if (verticesNumber < 300) return 0.1; + return 0; + } + + function getEdgeVertices(innerVertices: number[], points: TPoints, pole: TPoint, horyzontalShift: number) { + let leftmost = {value: Infinity, vertex: innerVertices.at(0)!}; + let rightmost = {value: -Infinity, vertex: innerVertices.at(-1)!}; + + for (const vertex of innerVertices) { + const [x, y] = points[vertex]; + const valueX = x - pole[0]; + const valueY = Math.abs(y - pole[1]) * horyzontalShift; + + if (valueX + valueY < leftmost.value) leftmost = {value: valueX + valueY, vertex}; + if (valueX - valueY > rightmost.value) rightmost = {value: valueX - valueY, vertex}; + } + + return {left: leftmost.vertex, right: rightmost.vertex}; + } } } diff --git a/src/scripts/generation/generation.ts b/src/scripts/generation/generation.ts index afbe73db..5338eb5e 100644 --- a/src/scripts/generation/generation.ts +++ b/src/scripts/generation/generation.ts @@ -69,8 +69,8 @@ async function generate(options?: IGenerationOptions) { // renderLayer("biomes"); renderLayer("burgs"); renderLayer("routes"); - // renderLayer("states"); - renderLayer("provinces"); + renderLayer("states"); + renderLayer("labels"); // pack.cells.route.forEach((route, index) => { // if (route === 2) drawPoint(pack.cells.p[index], {color: "black"}); diff --git a/src/scripts/getPolesOfInaccessibility.ts b/src/scripts/getPolesOfInaccessibility.ts index 384cea99..e7e7a414 100644 --- a/src/scripts/getPolesOfInaccessibility.ts +++ b/src/scripts/getPolesOfInaccessibility.ts @@ -14,10 +14,12 @@ interface IGetPolesProps { export function getPolesOfInaccessibility(props: IGetPolesProps) { TIME && console.time("getPolesOfInaccessibility"); const multiPolygons = getMultiPolygons(props); + const sortByLength = (a: unknown[], b: unknown[]) => b.length - a.length; + console.log(multiPolygons); const poles: Dict = Object.fromEntries( Object.entries(multiPolygons).map(([id, multiPolygon]) => { - const [x, y] = polylabel(multiPolygon, 20); + const [x, y] = polylabel(multiPolygon.sort(sortByLength), 20); return [id, [rn(x), rn(y)]]; }) ); diff --git a/src/utils/debugUtils.ts b/src/utils/debugUtils.ts index f72c614f..c973cedf 100644 --- a/src/utils/debugUtils.ts +++ b/src/utils/debugUtils.ts @@ -31,6 +31,19 @@ export function drawLine([x1, y1]: TPoint, [x2, y2]: TPoint, {stroke = "#444", s .attr("stroke-width", strokeWidth); } +export function drawPolyline(points: TPoints, {fill = "none", stroke = "#444", strokeWidth = 0.2} = {}) { + debug + .append("polyline") + .attr("points", points.join(" ")) + .attr("fill", fill) + .attr("stroke", stroke) + .attr("stroke-width", strokeWidth); +} + +export function drawPath(d: string, {fill = "none", stroke = "#444", strokeWidth = 0.2} = {}) { + debug.append("path").attr("d", d).attr("fill", fill).attr("stroke", stroke).attr("stroke-width", strokeWidth); +} + export function drawArrow([x1, y1]: TPoint, [x2, y2]: TPoint, {width = 1, color = "#444"} = {}) { const normal = getNormal([x1, y1], [x2, y2]); const [xMid, yMid] = [(x1 + x2) / 2, (y1 + y2) / 2]; From 6954888ab23a122f0749158a4dc3e322eb4d6cdb Mon Sep 17 00:00:00 2001 From: Azgaar Date: Wed, 14 Sep 2022 02:17:09 +0300 Subject: [PATCH 12/20] refactor: draw state labels - raycasting approach --- index.html | 4 +- src/dialogs/dialogs/lake-editor.js | 2 +- src/layers/renderers/drawLabels.ts | 265 ++++++++++------------- src/modules/burgs-and-states.js | 2 +- src/scripts/generation/generation.ts | 4 +- src/scripts/getPolesOfInaccessibility.ts | 1 - 6 files changed, 123 insertions(+), 155 deletions(-) diff --git a/index.html b/index.html index 4806603e..8906d880 100644 --- a/index.html +++ b/index.html @@ -2604,8 +2604,8 @@
-
Avarage depth:
- +
Average depth:
+
diff --git a/src/dialogs/dialogs/lake-editor.js b/src/dialogs/dialogs/lake-editor.js index 71be417f..76961958 100644 --- a/src/dialogs/dialogs/lake-editor.js +++ b/src/dialogs/dialogs/lake-editor.js @@ -65,7 +65,7 @@ export function open({el}) { const heights = lakeCells.map(i => cells.h[i]); byId("lakeElevation").value = getHeight(l.height); - byId("lakeAvarageDepth").value = getHeight(d3.mean(heights), true); + byId("lakeAverageDepth").value = getHeight(d3.mean(heights), true); byId("lakeMaxDepth").value = getHeight(d3.min(heights), true); byId("lakeFlux").value = l.flux; diff --git a/src/layers/renderers/drawLabels.ts b/src/layers/renderers/drawLabels.ts index bcaecec2..0b6a8317 100644 --- a/src/layers/renderers/drawLabels.ts +++ b/src/layers/renderers/drawLabels.ts @@ -1,20 +1,15 @@ import * as d3 from "d3"; -import Delaunator from "delaunator"; -import FlatQueue from "flatqueue"; -import {simplify} from "scripts/simplify"; -import {Voronoi} from "modules/voronoi"; -import {MIN_LAND_HEIGHT} from "config/generation"; import {findCell} from "utils/graphUtils"; import {isState} from "utils/typeUtils"; -import {drawPath, drawPoint, drawPolyline} from "utils/debugUtils"; +import {drawPath, drawPoint} from "utils/debugUtils"; export function drawLabels() { /* global */ const {cells, vertices, features, states, burgs} = pack; /* global: findCell, graphWidth, graphHeight */ drawStateLabels(cells, features, states, vertices); - drawBurgLabels(burgs); + // drawBurgLabels(burgs); // TODO: draw other labels window.Zoom.invoke(); @@ -62,166 +57,140 @@ function drawBurgLabels(burgs: TBurgs) { } function drawStateLabels(cells: IPack["cells"], features: TPackFeatures, states: TStates, vertices: IGraphVertices) { + console.time("drawStateLabels"); const lineGen = d3.line().curve(d3.curveBundle.beta(1)); const mode = options.stateLabelsMode || "auto"; + // increase step to increase performarce and make more horyzontal, decrease to increase accuracy + const STEP = 9; + const raycast = precalculateAngles(STEP); + + const INITIAL_DISTANCE = 5; + const DISTANCE_STEP = 15; + const MAX_ITERATIONS = 100; + const labelPaths = getLabelPaths(); - console.log(labelPaths); function getLabelPaths() { const labelPaths: [number, TPoints][] = []; - const MIN_HULL_SIZE = 20; const lineGen = d3.line().curve(d3.curveBundle.beta(1)); for (const state of states) { if (!isState(state)) continue; - const used: Dict = {}; // mutable - const visualCenter = findCell(...state.pole); - const startingCell = cells.state[visualCenter] === state.i ? visualCenter : state.center; - const hull = getHull(startingCell, state.i, state.cells, used); - const points = [...hull].map(vertex => vertices.p[vertex]); + const offset = getOffsetWidth(state.cells); + const [x0, y0] = state.pole; - const delaunay = Delaunator.from(points); - const voronoi = new Voronoi(delaunay, points, points.length); - const chain = connectVertices(voronoi.vertices, state.pole, used); - - drawPoint(state.pole, {color: "blue", radius: 1}); - - if (state.i === 1) { - points.forEach(point => { - drawPoint(point, {color: "red", radius: 0.5}); - }); - } - - const pathPoints = simplify( - chain.map(i => voronoi.vertices.p[i]), - 30 + const offsetPoints = new Map( + (offset ? raycast : []).map(({angle, x: x1, y: y1}) => { + const [x, y] = [x0 + offset * x1, y0 + offset * y1]; + return [angle, {x, y}]; + }) ); - drawPath(lineGen(pathPoints)!, {stroke: "red", strokeWidth: 0.5}); + const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => { + let distanceMin: number; + if (offset) { + const point1 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90)!; + const distance1 = getMaxDistance(state.i, point1, dx, dy); + + const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90)!; + const distance2 = getMaxDistance(state.i, point2, dx, dy); + distanceMin = Math.min(distance1, distance2); + } else { + distanceMin = getMaxDistance(state.i, {x: x0, y: y0}, dx, dy); + } + + const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy]; + return {angle, distance: distanceMin * modifier, x, y}; + }); + + const {angle, x, y} = distances.reduce( + (acc, {angle, distance, x, y}) => { + if (distance > acc.distance) return {angle, distance, x, y}; + return acc; + }, + {angle: 0, distance: 0, x: 0, y: 0} + ); + + const oppositeAngle = angle >= 180 ? angle - 180 : angle + 180; + const {x: x2, y: y2} = distances.reduce( + (acc, {angle, distance, x, y}) => { + const angleDif = getAnglesDif(angle, oppositeAngle); + const score = distance * getAngleModifier(angleDif); + if (score > acc.score) return {angle, score, x, y}; + return acc; + }, + {angle: 0, score: 0, x: 0, y: 0} + ); + + drawPath(lineGen([[x, y], state.pole, [x2, y2]])!, {stroke: "red", strokeWidth: 1}); + + const pathPoints: TPoints = []; labelPaths.push([state.i, pathPoints]); } return labelPaths; - - function getHull(start: number, stateId: number, stateCells: number, used: Dict) { - const maxPassableLakeSize = stateCells / 10; - const queue = [start]; - - const hull = new Set(); - const addToHull = (cellId: number, index: number) => { - const vertex = cells.v[cellId][index]; - if (vertex) hull.add(vertex); - }; - - while (queue.length) { - const cellId = queue.pop()!; - - cells.c[cellId].forEach((neibCellId, index) => { - if (used[neibCellId]) return; - used[neibCellId] = true; - - if (isHullEdge(neibCellId)) return addToHull(cellId, index); - return queue.push(neibCellId); - }); - } - - return hull; - - function isHullEdge(cellId: number) { - if (cells.b[cellId]) return true; - - if (cells.h[cellId] < MIN_LAND_HEIGHT) { - const feature = features[cells.f[cellId]]; - if (!feature || feature.type !== "lake") return true; - if (feature.cells > maxPassableLakeSize) return true; - return false; - } - - if (cells.state[cellId] !== stateId) return true; - - if (hull.size > MIN_HULL_SIZE) { - // stop on narrow passages - const sameStateNeibs = cells.c[cellId].filter(c => cells.state[c] === stateId); - if (sameStateNeibs.length < 3) return true; - } - - return false; - } - } - - function connectVertices(vertices: Voronoi["vertices"], pole: TPoint, used: Dict) { - // check if vertex is inside the area - const inside = vertices.p.map(([x, y]) => { - if (x <= 0 || y <= 0 || x >= graphWidth || y >= graphHeight) return false; // out of the screen - return used[findCell(x, y)]; - }); - - const innerVertices = d3.range(vertices.p.length).filter(i => inside[i]); - if (innerVertices.length < 2) return [0]; - - const horyzontalShift = getHoryzontalShift(vertices.p.length); - const {right: start, left: end} = getEdgeVertices(innerVertices, vertices.p, pole, horyzontalShift); - - // connect leftmost and rightmost vertices with shortest path - const cost: number[] = []; - const from: number[] = []; - const queue = new FlatQueue(); - queue.push(start, 0); - - while (queue.length) { - const priority = queue.peekValue()!; - const next = queue.pop()!; - - if (next === end) break; - - for (const neibVertex of vertices.v[next]) { - if (neibVertex === -1) continue; - - const totalCost = priority + (inside[neibVertex] ? 1 : 100); - if (from[neibVertex] || totalCost >= cost[neibVertex]) continue; - - cost[neibVertex] = totalCost; - from[neibVertex] = next; - queue.push(neibVertex, totalCost); - } - } - - // restore path - const chain = [end]; - let cur = end; - while (cur !== start) { - cur = from[cur]; - if (inside[cur]) chain.push(cur); - } - return chain; - } - - function getHoryzontalShift(verticesNumber: number) { - console.log({verticesNumber}); - return 0; - if (verticesNumber < 100) return 1; - if (verticesNumber < 200) return 0.3; - if (verticesNumber < 300) return 0.1; - return 0; - } - - function getEdgeVertices(innerVertices: number[], points: TPoints, pole: TPoint, horyzontalShift: number) { - let leftmost = {value: Infinity, vertex: innerVertices.at(0)!}; - let rightmost = {value: -Infinity, vertex: innerVertices.at(-1)!}; - - for (const vertex of innerVertices) { - const [x, y] = points[vertex]; - const valueX = x - pole[0]; - const valueY = Math.abs(y - pole[1]) * horyzontalShift; - - if (valueX + valueY < leftmost.value) leftmost = {value: valueX + valueY, vertex}; - if (valueX - valueY > rightmost.value) rightmost = {value: valueX - valueY, vertex}; - } - - return {left: leftmost.vertex, right: rightmost.vertex}; - } } + + function getMaxDistance(stateId: number, point: {x: number; y: number}, dx: number, dy: number) { + let distance = INITIAL_DISTANCE; + + for (let i = 0; i < MAX_ITERATIONS; i++) { + const [x, y] = [point.x + distance * dx, point.y + distance * dy]; + const cellId = findCell(x, y); + + // const inside = cells.state[cellId] === stateId; + // drawPoint([x, y], {color: inside ? "blue" : "red", radius: 1}); + + if (cells.state[cellId] !== stateId) break; + distance += DISTANCE_STEP; + } + + return distance; + } + + console.timeEnd("drawStateLabels"); +} + +// point offset to reduce label overlap with state borders +function getOffsetWidth(cellsNumber: number) { + if (cellsNumber < 80) return 0; + if (cellsNumber < 140) return 5; + if (cellsNumber < 200) return 15; + if (cellsNumber < 300) return 20; + if (cellsNumber < 500) return 25; + return 30; +} + +// difference between two angles in range [0, 180] +function getAnglesDif(angle1: number, angle2: number) { + return 180 - Math.abs(Math.abs(angle1 - angle2) - 180); +} + +// score multiplier based on angle difference betwee left and right sides +function getAngleModifier(angleDif: number) { + if (angleDif === 0) return 1; + if (angleDif <= 15) return 0.95; + if (angleDif <= 30) return 0.9; + if (angleDif <= 45) return 0.6; + if (angleDif <= 60) return 0.3; + if (angleDif <= 90) return 0.1; + return 0; // >90 +} + +function precalculateAngles(step: number) { + const RAD = Math.PI / 180; + const angles = []; + + for (let angle = 0; angle < 360; angle += step) { + const x = Math.cos(angle * RAD); + const y = Math.sin(angle * RAD); + const angleDif = 90 - Math.abs((angle % 180) - 90); + const modifier = 1 - angleDif / 120; // [0.25, 1] + angles.push({angle, modifier, x, y}); + } + + return angles; } diff --git a/src/modules/burgs-and-states.js b/src/modules/burgs-and-states.js index 190322ea..a65061da 100644 --- a/src/modules/burgs-and-states.js +++ b/src/modules/burgs-and-states.js @@ -833,7 +833,7 @@ window.BurgsAndStates = (function () { valid.forEach(s => (s.diplomacy = new Array(states.length).fill("x"))); // clear all relationships if (valid.length < 2) return; // no states to renerate relations with - const areaMean = d3.mean(valid.map(s => s.area)); // avarage state area + const areaMean = d3.mean(valid.map(s => s.area)); // average state area // generic relations for (let f = 1; f < states.length; f++) { diff --git a/src/scripts/generation/generation.ts b/src/scripts/generation/generation.ts index 5338eb5e..65fb65dd 100644 --- a/src/scripts/generation/generation.ts +++ b/src/scripts/generation/generation.ts @@ -67,8 +67,8 @@ async function generate(options?: IGenerationOptions) { // renderLayer("heightmap"); // renderLayer("rivers"); // renderLayer("biomes"); - renderLayer("burgs"); - renderLayer("routes"); + // renderLayer("burgs"); + // renderLayer("routes"); renderLayer("states"); renderLayer("labels"); diff --git a/src/scripts/getPolesOfInaccessibility.ts b/src/scripts/getPolesOfInaccessibility.ts index e7e7a414..e95500b0 100644 --- a/src/scripts/getPolesOfInaccessibility.ts +++ b/src/scripts/getPolesOfInaccessibility.ts @@ -15,7 +15,6 @@ export function getPolesOfInaccessibility(props: IGetPolesProps) { TIME && console.time("getPolesOfInaccessibility"); const multiPolygons = getMultiPolygons(props); const sortByLength = (a: unknown[], b: unknown[]) => b.length - a.length; - console.log(multiPolygons); const poles: Dict = Object.fromEntries( Object.entries(multiPolygons).map(([id, multiPolygon]) => { From 151c3d149529ec1254ffbb9d8960d7fdaab450ec Mon Sep 17 00:00:00 2001 From: Azgaar Date: Thu, 15 Sep 2022 01:20:31 +0300 Subject: [PATCH 13/20] refactor: draw state labels - rendering --- src/layers/renderers/drawLabels.ts | 196 ------------- .../renderers/drawLabels/drawBurgLabels.ts | 40 +++ .../renderers/drawLabels/drawStateLabels.ts | 277 ++++++++++++++++++ src/layers/renderers/drawLabels/index.ts | 12 + 4 files changed, 329 insertions(+), 196 deletions(-) delete mode 100644 src/layers/renderers/drawLabels.ts create mode 100644 src/layers/renderers/drawLabels/drawBurgLabels.ts create mode 100644 src/layers/renderers/drawLabels/drawStateLabels.ts create mode 100644 src/layers/renderers/drawLabels/index.ts diff --git a/src/layers/renderers/drawLabels.ts b/src/layers/renderers/drawLabels.ts deleted file mode 100644 index 0b6a8317..00000000 --- a/src/layers/renderers/drawLabels.ts +++ /dev/null @@ -1,196 +0,0 @@ -import * as d3 from "d3"; - -import {findCell} from "utils/graphUtils"; -import {isState} from "utils/typeUtils"; -import {drawPath, drawPoint} from "utils/debugUtils"; - -export function drawLabels() { - /* global */ const {cells, vertices, features, states, burgs} = pack; - /* global: findCell, graphWidth, graphHeight */ - - drawStateLabels(cells, features, states, vertices); - // drawBurgLabels(burgs); - // TODO: draw other labels - - window.Zoom.invoke(); -} - -function drawBurgLabels(burgs: TBurgs) { - // remove old data - burgLabels.selectAll("text").remove(); - - const validBurgs = burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[]; - - // capitals - const capitals = validBurgs.filter(burg => burg.capital); - const capitalSize = Number(burgIcons.select("#cities").attr("size")) || 1; - - burgLabels - .select("#cities") - .selectAll("text") - .data(capitals) - .enter() - .append("text") - .attr("id", d => "burgLabel" + d.i) - .attr("data-id", d => d.i) - .attr("x", d => d.x) - .attr("y", d => d.y) - .attr("dy", `${capitalSize * -1.5}px`) - .text(d => d.name); - - // towns - const towns = validBurgs.filter(burg => !burg.capital); - const townSize = Number(burgIcons.select("#towns").attr("size")) || 0.5; - - burgLabels - .select("#towns") - .selectAll("text") - .data(towns) - .enter() - .append("text") - .attr("id", d => "burgLabel" + d.i) - .attr("data-id", d => d.i) - .attr("x", d => d.x) - .attr("y", d => d.y) - .attr("dy", `${townSize * -1.5}px`) - .text(d => d.name); -} - -function drawStateLabels(cells: IPack["cells"], features: TPackFeatures, states: TStates, vertices: IGraphVertices) { - console.time("drawStateLabels"); - const lineGen = d3.line().curve(d3.curveBundle.beta(1)); - const mode = options.stateLabelsMode || "auto"; - - // increase step to increase performarce and make more horyzontal, decrease to increase accuracy - const STEP = 9; - const raycast = precalculateAngles(STEP); - - const INITIAL_DISTANCE = 5; - const DISTANCE_STEP = 15; - const MAX_ITERATIONS = 100; - - const labelPaths = getLabelPaths(); - - function getLabelPaths() { - const labelPaths: [number, TPoints][] = []; - const lineGen = d3.line().curve(d3.curveBundle.beta(1)); - - for (const state of states) { - if (!isState(state)) continue; - - const offset = getOffsetWidth(state.cells); - const [x0, y0] = state.pole; - - const offsetPoints = new Map( - (offset ? raycast : []).map(({angle, x: x1, y: y1}) => { - const [x, y] = [x0 + offset * x1, y0 + offset * y1]; - return [angle, {x, y}]; - }) - ); - - const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => { - let distanceMin: number; - - if (offset) { - const point1 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90)!; - const distance1 = getMaxDistance(state.i, point1, dx, dy); - - const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90)!; - const distance2 = getMaxDistance(state.i, point2, dx, dy); - distanceMin = Math.min(distance1, distance2); - } else { - distanceMin = getMaxDistance(state.i, {x: x0, y: y0}, dx, dy); - } - - const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy]; - return {angle, distance: distanceMin * modifier, x, y}; - }); - - const {angle, x, y} = distances.reduce( - (acc, {angle, distance, x, y}) => { - if (distance > acc.distance) return {angle, distance, x, y}; - return acc; - }, - {angle: 0, distance: 0, x: 0, y: 0} - ); - - const oppositeAngle = angle >= 180 ? angle - 180 : angle + 180; - const {x: x2, y: y2} = distances.reduce( - (acc, {angle, distance, x, y}) => { - const angleDif = getAnglesDif(angle, oppositeAngle); - const score = distance * getAngleModifier(angleDif); - if (score > acc.score) return {angle, score, x, y}; - return acc; - }, - {angle: 0, score: 0, x: 0, y: 0} - ); - - drawPath(lineGen([[x, y], state.pole, [x2, y2]])!, {stroke: "red", strokeWidth: 1}); - - const pathPoints: TPoints = []; - labelPaths.push([state.i, pathPoints]); - } - - return labelPaths; - } - - function getMaxDistance(stateId: number, point: {x: number; y: number}, dx: number, dy: number) { - let distance = INITIAL_DISTANCE; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const [x, y] = [point.x + distance * dx, point.y + distance * dy]; - const cellId = findCell(x, y); - - // const inside = cells.state[cellId] === stateId; - // drawPoint([x, y], {color: inside ? "blue" : "red", radius: 1}); - - if (cells.state[cellId] !== stateId) break; - distance += DISTANCE_STEP; - } - - return distance; - } - - console.timeEnd("drawStateLabels"); -} - -// point offset to reduce label overlap with state borders -function getOffsetWidth(cellsNumber: number) { - if (cellsNumber < 80) return 0; - if (cellsNumber < 140) return 5; - if (cellsNumber < 200) return 15; - if (cellsNumber < 300) return 20; - if (cellsNumber < 500) return 25; - return 30; -} - -// difference between two angles in range [0, 180] -function getAnglesDif(angle1: number, angle2: number) { - return 180 - Math.abs(Math.abs(angle1 - angle2) - 180); -} - -// score multiplier based on angle difference betwee left and right sides -function getAngleModifier(angleDif: number) { - if (angleDif === 0) return 1; - if (angleDif <= 15) return 0.95; - if (angleDif <= 30) return 0.9; - if (angleDif <= 45) return 0.6; - if (angleDif <= 60) return 0.3; - if (angleDif <= 90) return 0.1; - return 0; // >90 -} - -function precalculateAngles(step: number) { - const RAD = Math.PI / 180; - const angles = []; - - for (let angle = 0; angle < 360; angle += step) { - const x = Math.cos(angle * RAD); - const y = Math.sin(angle * RAD); - const angleDif = 90 - Math.abs((angle % 180) - 90); - const modifier = 1 - angleDif / 120; // [0.25, 1] - angles.push({angle, modifier, x, y}); - } - - return angles; -} diff --git a/src/layers/renderers/drawLabels/drawBurgLabels.ts b/src/layers/renderers/drawLabels/drawBurgLabels.ts new file mode 100644 index 00000000..bf2b5cf0 --- /dev/null +++ b/src/layers/renderers/drawLabels/drawBurgLabels.ts @@ -0,0 +1,40 @@ +export function drawBurgLabels(burgs: TBurgs) { + // remove old data + burgLabels.selectAll("text").remove(); + + const validBurgs = burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[]; + + // capitals + const capitals = validBurgs.filter(burg => burg.capital); + const capitalSize = Number(burgIcons.select("#cities").attr("size")) || 1; + + burgLabels + .select("#cities") + .selectAll("text") + .data(capitals) + .enter() + .append("text") + .attr("id", d => "burgLabel" + d.i) + .attr("data-id", d => d.i) + .attr("x", d => d.x) + .attr("y", d => d.y) + .attr("dy", `${capitalSize * -1.5}px`) + .text(d => d.name); + + // towns + const towns = validBurgs.filter(burg => !burg.capital); + const townSize = Number(burgIcons.select("#towns").attr("size")) || 0.5; + + burgLabels + .select("#towns") + .selectAll("text") + .data(towns) + .enter() + .append("text") + .attr("id", d => "burgLabel" + d.i) + .attr("data-id", d => d.i) + .attr("x", d => d.x) + .attr("y", d => d.y) + .attr("dy", `${townSize * -1.5}px`) + .text(d => d.name); +} diff --git a/src/layers/renderers/drawLabels/drawStateLabels.ts b/src/layers/renderers/drawLabels/drawStateLabels.ts new file mode 100644 index 00000000..cac6eb5c --- /dev/null +++ b/src/layers/renderers/drawLabels/drawStateLabels.ts @@ -0,0 +1,277 @@ +import * as d3 from "d3"; + +import {findCell} from "utils/graphUtils"; +import {isState} from "utils/typeUtils"; +import {drawPath, drawPoint, drawPolyline} from "utils/debugUtils"; +import {round, splitInTwo} from "utils/stringUtils"; +import {minmax, rn} from "utils/numberUtils"; + +// increase step to 15 or 30 to make it faster and more horyzontal, decrease to 5 to improve accuracy +const STEP = 9; +const raycast = precalculateAngles(STEP); + +const INITIAL_DISTANCE = 5; +const DISTANCE_STEP = 15; +const MAX_ITERATIONS = 100; + +export function drawStateLabels(cells: IPack["cells"], states: TStates) { + /* global: findCell, graphWidth, graphHeight */ + console.time("drawStateLabels"); + + const labelPaths = getLabelPaths(cells.state, states); + drawLabelPath(cells.state, states, labelPaths); + + console.timeEnd("drawStateLabels"); +} + +function getLabelPaths(stateIds: Uint16Array, states: TStates) { + const labelPaths: [number, TPoints][] = []; + + for (const state of states) { + if (!isState(state)) continue; + + const offset = getOffsetWidth(state.cells); + const [x0, y0] = state.pole; + + const offsetPoints = new Map( + (offset ? raycast : []).map(({angle, x: x1, y: y1}) => { + const [x, y] = [x0 + offset * x1, y0 + offset * y1]; + return [angle, {x, y}]; + }) + ); + + const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => { + let distanceMin: number; + + if (offset) { + const point1 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90)!; + const distance1 = getMaxDistance(stateIds, state.i, point1, dx, dy); + + const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90)!; + const distance2 = getMaxDistance(stateIds, state.i, point2, dx, dy); + distanceMin = Math.min(distance1, distance2); + } else { + distanceMin = getMaxDistance(stateIds, state.i, {x: x0, y: y0}, dx, dy); + } + + const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy]; + return {angle, distance: distanceMin * modifier, x, y}; + }); + + const { + angle, + x: x1, + y: y1 + } = distances.reduce( + (acc, {angle, distance, x, y}) => { + if (distance > acc.distance) return {angle, distance, x, y}; + return acc; + }, + {angle: 0, distance: 0, x: 0, y: 0} + ); + + const oppositeAngle = angle >= 180 ? angle - 180 : angle + 180; + const {x: x2, y: y2} = distances.reduce( + (acc, {angle, distance, x, y}) => { + const angleDif = getAnglesDif(angle, oppositeAngle); + const score = distance * getAngleModifier(angleDif); + if (score > acc.score) return {angle, score, x, y}; + return acc; + }, + {angle: 0, score: 0, x: 0, y: 0} + ); + + const pathPoints: TPoints = [[x1, y1], state.pole, [x2, y2]]; + if (x1 > x2) pathPoints.reverse(); + labelPaths.push([state.i, pathPoints]); + } + + return labelPaths; +} + +function getMaxDistance(stateIds: Uint16Array, stateId: number, point: {x: number; y: number}, dx: number, dy: number) { + let distance = INITIAL_DISTANCE; + + for (let i = 0; i < MAX_ITERATIONS; i++) { + const [x, y] = [point.x + distance * dx, point.y + distance * dy]; + const cellId = findCell(x, y); + + // const inside = cells.state[cellId] === stateId; + // drawPoint([x, y], {color: inside ? "blue" : "red", radius: 1}); + + if (stateIds[cellId] !== stateId) break; + distance += DISTANCE_STEP; + } + + return distance; +} + +function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [number, TPoints][]) { + const mode = options.stateLabelsMode || "auto"; + const lineGen = d3.line().curve(d3.curveBundle.beta(1)); + + const textGroup = d3.select("g#labels > g#states"); + const pathGroup = d3.select("defs > g#deftemp > g#textPaths"); + + const example = textGroup.append("text").attr("x", 0).attr("x", 0).text("Average"); + const letterLength = example.node()!.getComputedTextLength() / 7; // average length of 1 letter + + for (const [stateId, pathPoints] of labelPaths) { + const state = states[stateId]; + if (!isState(state)) throw new Error("State must not be neutral"); + if (pathPoints.length < 2) throw new Error("Label path must have at least 2 points"); + + textGroup.select("#textPath_stateLabel" + stateId).remove(); + pathGroup.select("#stateLabel" + stateId).remove(); + + const textPath = pathGroup + .append("path") + .attr("d", round(lineGen(pathPoints)!)) + .attr("id", "textPath_stateLabel" + stateId); + + drawPath(round(lineGen(pathPoints)!), {stroke: "red", strokeWidth: 0.6}); + + const pathLength = textPath.node()!.getTotalLength() / letterLength; // path length in letters + const [lines, ratio] = getLinesAndRatio(mode, state.name, state.fullName, pathLength); + + // prolongate path if it's too short + if (pathLength && pathLength < lines[0].length) { + const [x1, y1] = pathPoints.at(0)!; + const [x2, y2] = pathPoints.at(-1)!; + const [dx, dy] = [x2 - x1, y2 - y1]; + + const mod = Math.abs((letterLength * lines[0].length) / dx) / 2; + pathPoints[0] = [rn(x1 - dx * mod), rn(y1 - dy * mod)]; + pathPoints[pathPoints.length - 1] = [rn(x2 + dx * mod), rn(y2 + dy * mod)]; + + textPath.attr("d", round(lineGen(pathPoints)!)); + } + + example.attr("font-size", ratio + "%"); + const top = (lines.length - 1) / -2; // y offset + const spans = lines.map((line, index) => { + example.text(line); + const left = example.node()!.getBBox().width / -2; // x offset + return `${line}`; + }); + + const textElement = textGroup + .append("text") + .attr("id", "stateLabel" + stateId) + .append("textPath") + .attr("xlink:href", "#textPath_stateLabel" + stateId) + .attr("startOffset", "50%") + .attr("font-size", ratio + "%") + .node()!; + + textElement.insertAdjacentHTML("afterbegin", spans.join("")); + if (mode === "full" || lines.length === 1) continue; + + const isInsideState = checkIfInsideState(textElement, stateIds, stateId); + if (isInsideState) continue; + + // replace name to one-liner + const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name; + example.text(text); + const left = example.node()!.getBBox().width / -2; // x offset + textElement.innerHTML = `${text}`; + + const correctedRatio = minmax(rn((pathLength / text.length) * 60), 40, 130); + textElement.setAttribute("font-size", correctedRatio + "%"); + } + + example.remove(); +} + +// point offset to reduce label overlap with state borders +function getOffsetWidth(cellsNumber: number) { + if (cellsNumber < 80) return 0; + if (cellsNumber < 140) return 5; + if (cellsNumber < 200) return 15; + if (cellsNumber < 300) return 20; + if (cellsNumber < 500) return 25; + return 30; +} + +// difference between two angles in range [0, 180] +function getAnglesDif(angle1: number, angle2: number) { + return 180 - Math.abs(Math.abs(angle1 - angle2) - 180); +} + +// score multiplier based on angle difference betwee left and right sides +function getAngleModifier(angleDif: number) { + if (angleDif === 0) return 1; + if (angleDif <= 15) return 0.95; + if (angleDif <= 30) return 0.9; + if (angleDif <= 45) return 0.6; + if (angleDif <= 60) return 0.3; + if (angleDif <= 90) return 0.1; + return 0; // >90 +} + +function precalculateAngles(step: number) { + const RAD = Math.PI / 180; + const angles = []; + + for (let angle = 0; angle < 360; angle += step) { + const x = Math.cos(angle * RAD); + const y = Math.sin(angle * RAD); + const angleDif = 90 - Math.abs((angle % 180) - 90); + const modifier = 1 - angleDif / 120; // [0.25, 1] + angles.push({angle, modifier, x, y}); + } + + return angles; +} + +function getLinesAndRatio( + mode: "auto" | "short" | "full", + name: string, + fullName: string, + pathLength: number +): [string[], number] { + // short name + if (mode === "short" || (mode === "auto" && pathLength < name.length)) { + const lines = splitInTwo(name); + const ratio = pathLength / lines[0].length; + return [lines, minmax(rn(ratio * 60), 50, 150)]; + } + + // full name: one line + if (pathLength > fullName.length * 2.5) { + const lines = [fullName]; + const ratio = pathLength / lines[0].length; + return [lines, minmax(rn(ratio * 70), 70, 170)]; + } + + // full name: two lines + const lines = splitInTwo(fullName); + const ratio = pathLength / lines[0].length; + return [lines, minmax(rn(ratio * 60), 70, 150)]; +} + +// check whether multi-lined label is mostly inside the state. If no, replace it with short name label +function checkIfInsideState(textElement: SVGTextPathElement, stateIds: Uint16Array, stateId: number) { + //textElement.querySelectorAll("tspan").forEach(tspan => (tspan.textContent = "A")); + + const {x, y, width, height} = textElement.getBBox(); + + const points: TPoints = [ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height], + [x + width / 2, y], + [x + width / 2, y + height] + ]; + drawPolyline(points, {stroke: "#333"}); + + for (let i = 0, pointsInside = 0; i < points.length && pointsInside < 4; i++) { + const isInside = stateIds[findCell(...points[i])] === stateId; + if (isInside) pointsInside++; + drawPoint(points[i], {color: isInside ? "green" : "red"}); + if (pointsInside > 3) return true; + } + + return true; +} diff --git a/src/layers/renderers/drawLabels/index.ts b/src/layers/renderers/drawLabels/index.ts new file mode 100644 index 00000000..c6c85aaf --- /dev/null +++ b/src/layers/renderers/drawLabels/index.ts @@ -0,0 +1,12 @@ +import {drawBurgLabels} from "./drawBurgLabels"; +import {drawStateLabels} from "./drawStateLabels"; + +export function drawLabels() { + /* global */ const {cells, states, burgs} = pack; + + drawStateLabels(cells, states); + drawBurgLabels(burgs); + // TODO: draw other labels + + window.Zoom.invoke(); +} From aa744915f816f7ea412e81d9756538d14ed29b12 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Fri, 16 Sep 2022 23:18:50 +0300 Subject: [PATCH 14/20] refactor: draw state labels - new rendering algo --- src/index.css | 2 +- .../renderers/drawLabels/drawStateLabels.ts | 101 ++++++++++-------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/src/index.css b/src/index.css index 39b660e4..e3751f8b 100644 --- a/src/index.css +++ b/src/index.css @@ -240,7 +240,7 @@ i.icon-lock { } #labels { - text-anchor: start; + text-anchor: middle; dominant-baseline: central; cursor: pointer; } diff --git a/src/layers/renderers/drawLabels/drawStateLabels.ts b/src/layers/renderers/drawLabels/drawStateLabels.ts index cac6eb5c..73a75c81 100644 --- a/src/layers/renderers/drawLabels/drawStateLabels.ts +++ b/src/layers/renderers/drawLabels/drawStateLabels.ts @@ -113,8 +113,9 @@ function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [numb const textGroup = d3.select("g#labels > g#states"); const pathGroup = d3.select("defs > g#deftemp > g#textPaths"); - const example = textGroup.append("text").attr("x", 0).attr("x", 0).text("Average"); - const letterLength = example.node()!.getComputedTextLength() / 7; // average length of 1 letter + const testLabel = textGroup.append("text").attr("x", 0).attr("x", 0).text("Example"); + const letterLength = testLabel.node()!.getComputedTextLength() / 7; // approximate length of 1 letter + testLabel.remove(); for (const [stateId, pathPoints] of labelPaths) { const state = states[stateId]; @@ -129,58 +130,58 @@ function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [numb .attr("d", round(lineGen(pathPoints)!)) .attr("id", "textPath_stateLabel" + stateId); - drawPath(round(lineGen(pathPoints)!), {stroke: "red", strokeWidth: 0.6}); + drawPath(round(lineGen(pathPoints)!), {stroke: "red", strokeWidth: 1}); const pathLength = textPath.node()!.getTotalLength() / letterLength; // path length in letters const [lines, ratio] = getLinesAndRatio(mode, state.name, state.fullName, pathLength); // prolongate path if it's too short - if (pathLength && pathLength < lines[0].length) { + const longestLineLength = d3.max(lines.map(({length}) => length))!; + if (pathLength && pathLength < longestLineLength) { const [x1, y1] = pathPoints.at(0)!; const [x2, y2] = pathPoints.at(-1)!; - const [dx, dy] = [x2 - x1, y2 - y1]; + const [dx, dy] = [(x2 - x1) / 2, (y2 - y1) / 2]; - const mod = Math.abs((letterLength * lines[0].length) / dx) / 2; - pathPoints[0] = [rn(x1 - dx * mod), rn(y1 - dy * mod)]; - pathPoints[pathPoints.length - 1] = [rn(x2 + dx * mod), rn(y2 + dy * mod)]; + const mod = longestLineLength / pathLength; + pathPoints[0] = [x1 + dx - dx * mod, y1 + dy - dy * mod]; + pathPoints[pathPoints.length - 1] = [x2 - dx + dx * mod, y2 - dy + dy * mod]; textPath.attr("d", round(lineGen(pathPoints)!)); + drawPath(round(lineGen(pathPoints)!), {stroke: "blue", strokeWidth: 0.4}); } - example.attr("font-size", ratio + "%"); - const top = (lines.length - 1) / -2; // y offset - const spans = lines.map((line, index) => { - example.text(line); - const left = example.node()!.getBBox().width / -2; // x offset - return `${line}`; - }); - const textElement = textGroup .append("text") .attr("id", "stateLabel" + stateId) .append("textPath") - .attr("xlink:href", "#textPath_stateLabel" + stateId) .attr("startOffset", "50%") .attr("font-size", ratio + "%") .node()!; + const top = (lines.length - 1) / -2; // y offset + const spans = lines.map((line, index) => `${line}`); textElement.insertAdjacentHTML("afterbegin", spans.join("")); + + const {width, height} = textElement.getBBox(); + textElement.setAttribute("href", "#textPath_stateLabel" + stateId); + if (mode === "full" || lines.length === 1) continue; - const isInsideState = checkIfInsideState(textElement, stateIds, stateId); + // check if label fits state boundaries. If no, replace it with short name + const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!]; + const angleRad = Math.atan2(y2 - y1, x2 - x1); + + const isInsideState = checkIfInsideState(textElement, angleRad, width / 2, height / 2, stateIds, stateId); if (isInsideState) continue; // replace name to one-liner const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name; - example.text(text); - const left = example.node()!.getBBox().width / -2; // x offset - textElement.innerHTML = `${text}`; + textElement.innerHTML = `${text}`; const correctedRatio = minmax(rn((pathLength / text.length) * 60), 40, 130); textElement.setAttribute("font-size", correctedRatio + "%"); + textElement.setAttribute("fill", "blue"); } - - example.remove(); } // point offset to reduce label overlap with state borders @@ -210,8 +211,8 @@ function getAngleModifier(angleDif: number) { } function precalculateAngles(step: number) { - const RAD = Math.PI / 180; const angles = []; + const RAD = Math.PI / 180; for (let angle = 0; angle < 360; angle += step) { const x = Math.cos(angle * RAD); @@ -231,9 +232,10 @@ function getLinesAndRatio( pathLength: number ): [string[], number] { // short name - if (mode === "short" || (mode === "auto" && pathLength < name.length)) { + if (mode === "short" || (mode === "auto" && pathLength <= name.length)) { const lines = splitInTwo(name); - const ratio = pathLength / lines[0].length; + const longestLineLength = d3.max(lines.map(({length}) => length))!; + const ratio = pathLength / longestLineLength; return [lines, minmax(rn(ratio * 60), 50, 150)]; } @@ -246,32 +248,45 @@ function getLinesAndRatio( // full name: two lines const lines = splitInTwo(fullName); - const ratio = pathLength / lines[0].length; + const longestLineLength = d3.max(lines.map(({length}) => length))!; + const ratio = pathLength / longestLineLength; return [lines, minmax(rn(ratio * 60), 70, 150)]; } // check whether multi-lined label is mostly inside the state. If no, replace it with short name label -function checkIfInsideState(textElement: SVGTextPathElement, stateIds: Uint16Array, stateId: number) { - //textElement.querySelectorAll("tspan").forEach(tspan => (tspan.textContent = "A")); - - const {x, y, width, height} = textElement.getBBox(); +function checkIfInsideState( + textElement: SVGTextPathElement, + angleRad: number, + halfwidth: number, + halfheight: number, + stateIds: Uint16Array, + stateId: number +) { + const bbox = textElement.getBBox(); + const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2]; const points: TPoints = [ - [x, y], - [x + width, y], - [x + width, y + height], - [x, y + height], - [x + width / 2, y], - [x + width / 2, y + height] + [-halfwidth, -halfheight], + [+halfwidth, -halfheight], + [+halfwidth, halfheight], + [-halfwidth, halfheight], + [0, halfheight], + [0, -halfheight] ]; - drawPolyline(points, {stroke: "#333"}); - for (let i = 0, pointsInside = 0; i < points.length && pointsInside < 4; i++) { - const isInside = stateIds[findCell(...points[i])] === stateId; + const sin = Math.sin(angleRad); + const cos = Math.cos(angleRad); + const rotatedPoints: TPoints = points.map(([x, y]) => [cx + x * cos - y * sin, cy + x * sin + y * cos]); + + drawPolyline([...rotatedPoints.slice(0, 4), rotatedPoints[0]], {stroke: "#333"}); + + let pointsInside = 0; + for (const [x, y] of rotatedPoints) { + const isInside = stateIds[findCell(x, y)] === stateId; if (isInside) pointsInside++; - drawPoint(points[i], {color: isInside ? "green" : "red"}); - if (pointsInside > 3) return true; + drawPoint([x, y], {color: isInside ? "green" : "red"}); + if (pointsInside > 4) return true; } - return true; + return false; } From 392325eb53abee49b8b292a6ddcf591403f4808f Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 17 Sep 2022 00:52:27 +0300 Subject: [PATCH 15/20] refactor: draw state labels - corrections --- .../renderers/drawLabels/drawStateLabels.ts | 80 +++++++++++-------- src/layers/renderers/drawLabels/index.ts | 4 +- .../generation/pack/burgsAndStates/config.ts | 3 + 3 files changed, 52 insertions(+), 35 deletions(-) diff --git a/src/layers/renderers/drawLabels/drawStateLabels.ts b/src/layers/renderers/drawLabels/drawStateLabels.ts index 73a75c81..33c86434 100644 --- a/src/layers/renderers/drawLabels/drawStateLabels.ts +++ b/src/layers/renderers/drawLabels/drawStateLabels.ts @@ -1,36 +1,42 @@ import * as d3 from "d3"; import {findCell} from "utils/graphUtils"; -import {isState} from "utils/typeUtils"; +import {isLake, isState} from "utils/typeUtils"; import {drawPath, drawPoint, drawPolyline} from "utils/debugUtils"; import {round, splitInTwo} from "utils/stringUtils"; import {minmax, rn} from "utils/numberUtils"; // increase step to 15 or 30 to make it faster and more horyzontal, decrease to 5 to improve accuracy -const STEP = 9; -const raycast = precalculateAngles(STEP); +const ANGLE_STEP = 9; +const raycast = precalculateAngles(ANGLE_STEP); -const INITIAL_DISTANCE = 5; +const INITIAL_DISTANCE = 10; const DISTANCE_STEP = 15; const MAX_ITERATIONS = 100; -export function drawStateLabels(cells: IPack["cells"], states: TStates) { +export function drawStateLabels( + features: TPackFeatures, + featureIds: Uint16Array, + stateIds: Uint16Array, + states: TStates +) { /* global: findCell, graphWidth, graphHeight */ console.time("drawStateLabels"); - const labelPaths = getLabelPaths(cells.state, states); - drawLabelPath(cells.state, states, labelPaths); + const labelPaths = getLabelPaths(features, featureIds, stateIds, states); + drawLabelPath(stateIds, states, labelPaths); console.timeEnd("drawStateLabels"); } -function getLabelPaths(stateIds: Uint16Array, states: TStates) { +function getLabelPaths(features: TPackFeatures, featureIds: Uint16Array, stateIds: Uint16Array, states: TStates) { const labelPaths: [number, TPoints][] = []; for (const state of states) { if (!isState(state)) continue; const offset = getOffsetWidth(state.cells); + const maxLakeSize = state.cells / 50; const [x0, y0] = state.pole; const offsetPoints = new Map( @@ -43,15 +49,18 @@ function getLabelPaths(stateIds: Uint16Array, states: TStates) { const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => { let distanceMin: number; - if (offset) { - const point1 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90)!; - const distance1 = getMaxDistance(stateIds, state.i, point1, dx, dy); + const distance1 = getMaxDistance(state.i, {x: x0, y: y0}, dx, dy, maxLakeSize); + if (offset) { const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90)!; - const distance2 = getMaxDistance(stateIds, state.i, point2, dx, dy); - distanceMin = Math.min(distance1, distance2); + const distance2 = getMaxDistance(state.i, point2, dx, dy, maxLakeSize); + + const point3 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90)!; + const distance3 = getMaxDistance(state.i, point3, dx, dy, maxLakeSize); + + distanceMin = Math.min(distance1, distance2, distance3); } else { - distanceMin = getMaxDistance(stateIds, state.i, {x: x0, y: y0}, dx, dy); + distanceMin = distance1; } const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy]; @@ -87,23 +96,28 @@ function getLabelPaths(stateIds: Uint16Array, states: TStates) { } return labelPaths; -} -function getMaxDistance(stateIds: Uint16Array, stateId: number, point: {x: number; y: number}, dx: number, dy: number) { - let distance = INITIAL_DISTANCE; + function getMaxDistance(stateId: number, point: {x: number; y: number}, dx: number, dy: number, maxLakeSize: number) { + let distance = INITIAL_DISTANCE; - for (let i = 0; i < MAX_ITERATIONS; i++) { - const [x, y] = [point.x + distance * dx, point.y + distance * dy]; - const cellId = findCell(x, y); + for (let i = 0; i < MAX_ITERATIONS; i++) { + const [x, y] = [point.x + distance * dx, point.y + distance * dy]; + const cellId = findCell(x, y, DISTANCE_STEP); - // const inside = cells.state[cellId] === stateId; - // drawPoint([x, y], {color: inside ? "blue" : "red", radius: 1}); + // drawPoint([x, y], {color: cellId && isPassable(cellId) ? "blue" : "red", radius: 0.8}); - if (stateIds[cellId] !== stateId) break; - distance += DISTANCE_STEP; + if (!cellId || !isPassable(cellId)) break; + distance += DISTANCE_STEP; + } + + return distance; + + function isPassable(cellId: number) { + const feature = features[featureIds[cellId]]; + if (isLake(feature) && feature.cells <= maxLakeSize) return true; + return stateIds[cellId] === stateId; + } } - - return distance; } function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [number, TPoints][]) { @@ -130,7 +144,7 @@ function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [numb .attr("d", round(lineGen(pathPoints)!)) .attr("id", "textPath_stateLabel" + stateId); - drawPath(round(lineGen(pathPoints)!), {stroke: "red", strokeWidth: 1}); + // drawPath(round(lineGen(pathPoints)!), {stroke: "red", strokeWidth: 1}); const pathLength = textPath.node()!.getTotalLength() / letterLength; // path length in letters const [lines, ratio] = getLinesAndRatio(mode, state.name, state.fullName, pathLength); @@ -147,7 +161,7 @@ function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [numb pathPoints[pathPoints.length - 1] = [x2 - dx + dx * mod, y2 - dy + dy * mod]; textPath.attr("d", round(lineGen(pathPoints)!)); - drawPath(round(lineGen(pathPoints)!), {stroke: "blue", strokeWidth: 0.4}); + // drawPath(round(lineGen(pathPoints)!), {stroke: "blue", strokeWidth: 0.4}); } const textElement = textGroup @@ -178,9 +192,9 @@ function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [numb const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name; textElement.innerHTML = `${text}`; - const correctedRatio = minmax(rn((pathLength / text.length) * 60), 40, 130); + const correctedRatio = minmax(rn((pathLength / text.length) * 50), 40, 130); textElement.setAttribute("font-size", correctedRatio + "%"); - textElement.setAttribute("fill", "blue"); + // textElement.setAttribute("fill", "blue"); } } @@ -240,7 +254,7 @@ function getLinesAndRatio( } // full name: one line - if (pathLength > fullName.length * 2.5) { + if (pathLength > fullName.length * 2) { const lines = [fullName]; const ratio = pathLength / lines[0].length; return [lines, minmax(rn(ratio * 70), 70, 170)]; @@ -278,13 +292,13 @@ function checkIfInsideState( const cos = Math.cos(angleRad); const rotatedPoints: TPoints = points.map(([x, y]) => [cx + x * cos - y * sin, cy + x * sin + y * cos]); - drawPolyline([...rotatedPoints.slice(0, 4), rotatedPoints[0]], {stroke: "#333"}); + // drawPolyline([...rotatedPoints.slice(0, 4), rotatedPoints[0]], {stroke: "#333"}); let pointsInside = 0; for (const [x, y] of rotatedPoints) { const isInside = stateIds[findCell(x, y)] === stateId; if (isInside) pointsInside++; - drawPoint([x, y], {color: isInside ? "green" : "red"}); + // drawPoint([x, y], {color: isInside ? "green" : "red"}); if (pointsInside > 4) return true; } diff --git a/src/layers/renderers/drawLabels/index.ts b/src/layers/renderers/drawLabels/index.ts index c6c85aaf..85fe8176 100644 --- a/src/layers/renderers/drawLabels/index.ts +++ b/src/layers/renderers/drawLabels/index.ts @@ -2,9 +2,9 @@ import {drawBurgLabels} from "./drawBurgLabels"; import {drawStateLabels} from "./drawStateLabels"; export function drawLabels() { - /* global */ const {cells, states, burgs} = pack; + /* global */ const {cells, features, states, burgs} = pack; - drawStateLabels(cells, states); + drawStateLabels(features, cells.f, cells.state, states); drawBurgLabels(burgs); // TODO: draw other labels diff --git a/src/scripts/generation/pack/burgsAndStates/config.ts b/src/scripts/generation/pack/burgsAndStates/config.ts index 8d038625..6c660f39 100644 --- a/src/scripts/generation/pack/burgsAndStates/config.ts +++ b/src/scripts/generation/pack/burgsAndStates/config.ts @@ -97,12 +97,15 @@ export const adjectivalForms = [ "Theocracy", "Oligarchy", "Union", + "Federation", "Confederation", "Trade Company", "League", "Tetrarchy", "Triumvirate", "Diarchy", + "Khanate", + "Khaganate", "Horde", "Marches" ]; From b6acd8ffff25949696edf36cd3a158f3576bf683 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 17 Sep 2022 00:57:54 +0300 Subject: [PATCH 16/20] refactor: state labels - fix edit --- src/dialogs/dialogs/label-editor.js | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/dialogs/dialogs/label-editor.js b/src/dialogs/dialogs/label-editor.js index 6c7aaf10..cf242fb4 100644 --- a/src/dialogs/dialogs/label-editor.js +++ b/src/dialogs/dialogs/label-editor.js @@ -14,6 +14,8 @@ export function open({el}) { closeDialogs(); if (!layerIsOn("toggleLabels")) toggleLayer("toggleLabels"); + const lineGen = d3.line().curve(d3.curveBundle.beta(1)); + const textPath = el.parentNode; const text = textPath.parentNode; elSelected = d3.select(text).call(d3.drag().on("start", dragLabel)).classed("draggable", true); @@ -123,8 +125,6 @@ export function open({el}) { redrawLabelPath(); } - const lineGen = d3.line().curve(d3.curveBundle.beta(1)); - function redrawLabelPath() { const path = byId("textPath_" + elSelected.attr("id")); const points = []; @@ -308,26 +308,12 @@ export function open({el}) { function changeText() { const input = byId("labelText").value; const el = elSelected.select("textPath").node(); - const example = d3 - .select(elSelected.node().parentNode) - .append("text") - .attr("x", 0) - .attr("x", 0) - .attr("font-size", el.getAttribute("font-size")) - .node(); const lines = input.split("|"); const top = (lines.length - 1) / -2; // y offset - const inner = lines - .map((l, d) => { - example.innerHTML = l; - const left = example.getBBox().width / -2; // x offset - return `${l}`; - }) - .join(""); + const inner = lines.map((l, d) => `${l}`).join(""); el.innerHTML = inner; - example.remove(); if (elSelected.attr("id").slice(0, 10) === "stateLabel") tip("Use States Editor to change an actual state name, not just a label", false, "warning"); From 07a744151d1de0d619c3670de973f4d32e8a8c37 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 17 Sep 2022 14:36:32 +0300 Subject: [PATCH 17/20] cherry-pick 2bbff50b60c8bf6310c81221b3143a6205d3601e --- index.html | 22 +++++++++++------- src/dialogs/dialogs/heightmap-editor.js | 31 ++++++++++++------------- src/index.css | 13 +++++++---- src/modules/ui/namesbase-editor.js | 25 ++++++++++---------- 4 files changed, 51 insertions(+), 40 deletions(-) diff --git a/index.html b/index.html index 8906d880..60f8d405 100644 --- a/index.html +++ b/index.html @@ -2206,7 +2206,7 @@ - Support Ukraine + Support Ukraine war.ukraine.ua/support-ukraine @@ -3987,11 +3987,6 @@ > - + +
+

Generator uses pop-up window to download files. Please ensure your browser does not block popups.

+
Export to GeoJSON
@@ -5613,7 +5615,6 @@ wiki-page for guidance.

-

Generator uses pop-up window to download files. Please ensure your browser does not block popups.

Export To JSON
@@ -5628,12 +5629,10 @@

Export in JSON format can be used as an API replacement.

-
-

- It's also possible to export map to Foundry VTT, see - the module. -

-
+

+ It's also possible to export map to Foundry VTT, see + the module. +