diff --git a/public/modules/routes-generator.js b/public/modules/routes-generator.js
deleted file mode 100644
index 460625ed..00000000
--- a/public/modules/routes-generator.js
+++ /dev/null
@@ -1,677 +0,0 @@
-const ROUTES_SHARP_ANGLE = 135;
-const ROUTES_VERY_SHARP_ANGLE = 115;
-
-const MIN_PASSABLE_SEA_TEMP = -4;
-const ROUTE_TYPE_MODIFIERS = {
- "-1": 1, // coastline
- "-2": 1.8, // sea
- "-3": 4, // open sea
- "-4": 6, // ocean
- default: 8 // far ocean
-};
-
-window.Routes = (function () {
- function generate(lockedRoutes = []) {
- const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs);
-
- const connections = new Map();
- lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2])));
-
- const mainRoads = generateMainRoads();
- const trails = generateTrails();
- const seaRoutes = generateSeaRoutes();
-
- pack.routes = createRoutesData(lockedRoutes);
- pack.cells.routes = buildLinks(pack.routes);
-
- function sortBurgsByFeature(burgs) {
- const burgsByFeature = {};
- const capitalsByFeature = {};
- const portsByFeature = {};
-
- const addBurg = (collection, feature, burg) => {
- if (!collection[feature]) collection[feature] = [];
- collection[feature].push(burg);
- };
-
- for (const burg of burgs) {
- if (burg.i && !burg.removed) {
- const {feature, capital, port} = burg;
- addBurg(burgsByFeature, feature, burg);
- if (capital) addBurg(capitalsByFeature, feature, burg);
- if (port) addBurg(portsByFeature, port, burg);
- }
- }
-
- return {burgsByFeature, capitalsByFeature, portsByFeature};
- }
-
- function generateMainRoads() {
- TIME && console.time("generateMainRoads");
- const mainRoads = [];
-
- for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) {
- const points = featureCapitals.map(burg => [burg.x, burg.y]);
- const urquhartEdges = calculateUrquhartEdges(points);
- urquhartEdges.forEach(([fromId, toId]) => {
- const start = featureCapitals[fromId].cell;
- const exit = featureCapitals[toId].cell;
-
- const segments = findPathSegments({isWater: false, connections, start, exit});
- for (const segment of segments) {
- addConnections(segment);
- mainRoads.push({feature: Number(key), cells: segment});
- }
- });
- }
-
- TIME && console.timeEnd("generateMainRoads");
- return mainRoads;
- }
-
- function generateTrails() {
- TIME && console.time("generateTrails");
- const trails = [];
-
- for (const [key, featureBurgs] of Object.entries(burgsByFeature)) {
- const points = featureBurgs.map(burg => [burg.x, burg.y]);
- const urquhartEdges = calculateUrquhartEdges(points);
- urquhartEdges.forEach(([fromId, toId]) => {
- const start = featureBurgs[fromId].cell;
- const exit = featureBurgs[toId].cell;
-
- const segments = findPathSegments({isWater: false, connections, start, exit});
- for (const segment of segments) {
- addConnections(segment);
- trails.push({feature: Number(key), cells: segment});
- }
- });
- }
-
- TIME && console.timeEnd("generateTrails");
- return trails;
- }
-
- function generateSeaRoutes() {
- TIME && console.time("generateSeaRoutes");
- const seaRoutes = [];
-
- for (const [featureId, featurePorts] of Object.entries(portsByFeature)) {
- const points = featurePorts.map(burg => [burg.x, burg.y]);
- const urquhartEdges = calculateUrquhartEdges(points);
-
- urquhartEdges.forEach(([fromId, toId]) => {
- const start = featurePorts[fromId].cell;
- const exit = featurePorts[toId].cell;
- const segments = findPathSegments({isWater: true, connections, start, exit});
- for (const segment of segments) {
- addConnections(segment);
- seaRoutes.push({feature: Number(featureId), cells: segment});
- }
- });
- }
-
- TIME && console.timeEnd("generateSeaRoutes");
- return seaRoutes;
- }
-
- function addConnections(segment) {
- for (let i = 0; i < segment.length; i++) {
- const cellId = segment[i];
- const nextCellId = segment[i + 1];
- if (nextCellId) {
- connections.set(`${cellId}-${nextCellId}`, true);
- connections.set(`${nextCellId}-${cellId}`, true);
- }
- }
- }
-
- function findPathSegments({isWater, connections, start, exit}) {
- const getCost = createCostEvaluator({isWater, connections});
- const pathCells = findPath(start, current => current === exit, getCost);
- if (!pathCells) return [];
- const segments = getRouteSegments(pathCells, connections);
- return segments;
- }
-
- function createRoutesData(routes) {
- const pointsArray = preparePointsArray();
-
- for (const {feature, cells, merged} of mergeRoutes(mainRoads)) {
- if (merged) continue;
- const points = getPoints("roads", cells, pointsArray);
- routes.push({i: routes.length, group: "roads", feature, points});
- }
-
- for (const {feature, cells, merged} of mergeRoutes(trails)) {
- if (merged) continue;
- const points = getPoints("trails", cells, pointsArray);
- routes.push({i: routes.length, group: "trails", feature, points});
- }
-
- for (const {feature, cells, merged} of mergeRoutes(seaRoutes)) {
- if (merged) continue;
- const points = getPoints("searoutes", cells, pointsArray);
- routes.push({i: routes.length, group: "searoutes", feature, points});
- }
-
- return routes;
- }
-
- // merge routes so that the last cell of one route is the first cell of the next route
- function mergeRoutes(routes) {
- let routesMerged = 0;
-
- for (let i = 0; i < routes.length; i++) {
- const thisRoute = routes[i];
- if (thisRoute.merged) continue;
-
- for (let j = i + 1; j < routes.length; j++) {
- const nextRoute = routes[j];
- if (nextRoute.merged) continue;
-
- if (nextRoute.cells.at(0) === thisRoute.cells.at(-1)) {
- routesMerged++;
- thisRoute.cells = thisRoute.cells.concat(nextRoute.cells.slice(1));
- nextRoute.merged = true;
- }
- }
- }
-
- return routesMerged > 1 ? mergeRoutes(routes) : routes;
- }
- }
-
- function createCostEvaluator({isWater, connections}) {
- return isWater ? getWaterPathCost : getLandPathCost;
-
- function getLandPathCost(current, next) {
- if (pack.cells.h[next] < 20) return Infinity; // ignore water cells
-
- const habitability = biomesData.habitability[pack.cells.biome[next]];
- if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier)
-
- const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
- const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
- const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3];
- const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
- const burgModifier = pack.cells.burg[next] ? 1 : 3;
-
- const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier;
- return pathCost;
- }
-
- function getWaterPathCost(current, next) {
- if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells
- if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) return Infinity; // ignore too cold cells
-
- const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
- const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default;
- const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
-
- const pathCost = distanceCost * typeModifier * connectionModifier;
- return pathCost;
- }
- }
-
- function buildLinks(routes) {
- const links = {};
-
- for (const {points, i: routeId} of routes) {
- const cells = points.map(p => p[2]);
-
- for (let i = 0; i < cells.length - 1; i++) {
- const cellId = cells[i];
- const nextCellId = cells[i + 1];
-
- if (cellId !== nextCellId) {
- if (!links[cellId]) links[cellId] = {};
- links[cellId][nextCellId] = routeId;
-
- if (!links[nextCellId]) links[nextCellId] = {};
- links[nextCellId][cellId] = routeId;
- }
- }
- }
-
- return links;
- }
-
- function preparePointsArray() {
- const {cells, burgs} = pack;
- return cells.p.map(([x, y], cellId) => {
- const burgId = cells.burg[cellId];
- if (burgId) return [burgs[burgId].x, burgs[burgId].y];
- return [x, y];
- });
- }
-
- function getPoints(group, cells, points) {
- const data = cells.map(cellId => [...points[cellId], cellId]);
-
- // resolve sharp angles
- if (group !== "searoutes") {
- for (let i = 1; i < cells.length - 1; i++) {
- const cellId = cells[i];
- if (pack.cells.burg[cellId]) continue;
-
- const [prevX, prevY] = data[i - 1];
- const [currX, currY] = data[i];
- const [nextX, nextY] = data[i + 1];
-
- const dAx = prevX - currX;
- const dAy = prevY - currY;
- const dBx = nextX - currX;
- const dBy = nextY - currY;
- const angle = Math.abs((Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / Math.PI);
-
- if (angle < ROUTES_SHARP_ANGLE) {
- const middleX = (prevX + nextX) / 2;
- const middleY = (prevY + nextY) / 2;
- let newX, newY;
-
- if (angle < ROUTES_VERY_SHARP_ANGLE) {
- newX = rn((currX + middleX * 2) / 3, 2);
- newY = rn((currY + middleY * 2) / 3, 2);
- } else {
- newX = rn((currX + middleX) / 2, 2);
- newY = rn((currY + middleY) / 2, 2);
- }
-
- if (findCell(newX, newY) === cellId) {
- data[i] = [newX, newY, cellId];
- points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes
- }
- }
- }
- }
-
- return data; // [[x, y, cell], [x, y, cell]];
- }
-
- function getRouteSegments(pathCells, connections) {
- const segments = [];
- let segment = [];
-
- for (let i = 0; i < pathCells.length; i++) {
- const cellId = pathCells[i];
- const nextCellId = pathCells[i + 1];
- const isConnected = connections.has(`${cellId}-${nextCellId}`) || connections.has(`${nextCellId}-${cellId}`);
-
- if (isConnected) {
- if (segment.length) {
- // segment stepped into existing segment
- segment.push(pathCells[i]);
- segments.push(segment);
- segment = [];
- }
- continue;
- }
-
- segment.push(pathCells[i]);
- }
-
- if (segment.length > 1) segments.push(segment);
-
- return segments;
- }
-
- // Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation
- // this gives us an aproximation of a desired road network, i.e. connections between burgs
- // code from https://observablehq.com/@mbostock/urquhart-graph
- function calculateUrquhartEdges(points) {
- const score = (p0, p1) => dist2(points[p0], points[p1]);
-
- const {halfedges, triangles} = Delaunator.from(points);
- const n = triangles.length;
-
- const removed = new Uint8Array(n);
- const edges = [];
-
- for (let e = 0; e < n; e += 3) {
- const p0 = triangles[e],
- p1 = triangles[e + 1],
- p2 = triangles[e + 2];
-
- const p01 = score(p0, p1),
- p12 = score(p1, p2),
- p20 = score(p2, p0);
-
- removed[
- p20 > p01 && p20 > p12
- ? Math.max(e + 2, halfedges[e + 2])
- : p12 > p01 && p12 > p20
- ? Math.max(e + 1, halfedges[e + 1])
- : Math.max(e, halfedges[e])
- ] = 1;
- }
-
- for (let e = 0; e < n; ++e) {
- if (e > halfedges[e] && !removed[e]) {
- const t0 = triangles[e];
- const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1];
- edges.push([t0, t1]);
- }
- }
-
- return edges;
- }
-
- // connect cell with routes system by land
- function connect(cellId) {
- const getCost = createCostEvaluator({isWater: false, connections: new Map()});
- const isExit = c => isLand(c) && isConnected(c);
- const pathCells = findPath(cellId, isExit, getCost);
- if (!pathCells) return;
-
- const pointsArray = preparePointsArray();
- const points = getPoints("trails", pathCells, pointsArray);
- const feature = pack.cells.f[cellId];
- const routeId = getNextId();
- const newRoute = {i: routeId, group: "trails", feature, points};
- pack.routes.push(newRoute);
-
- for (let i = 0; i < pathCells.length; i++) {
- const currentCell = pathCells[i];
- const nextCellId = pathCells[i + 1];
- if (nextCellId) addConnection(currentCell, nextCellId, routeId);
- }
-
- return newRoute;
-
- function addConnection(from, to, routeId) {
- const routes = pack.cells.routes;
-
- if (!routes[from]) routes[from] = {};
- routes[from][to] = routeId;
-
- if (!routes[to]) routes[to] = {};
- routes[to][from] = routeId;
- }
- }
-
- // utility functions
- function isConnected(cellId) {
- const routes = pack.cells.routes;
- return routes[cellId] && Object.keys(routes[cellId]).length > 0;
- }
-
- function areConnected(from, to) {
- const routeId = pack.cells.routes[from]?.[to];
- return routeId !== undefined;
- }
-
- function getRoute(from, to) {
- const routeId = pack.cells.routes[from]?.[to];
- if (routeId === undefined) return null;
-
- const route = pack.routes.find(route => route.i === routeId);
- if (!route) return null;
-
- return route;
- }
-
- function hasRoad(cellId) {
- const connections = pack.cells.routes[cellId];
- if (!connections) return false;
-
- return Object.values(connections).some(routeId => {
- const route = pack.routes.find(route => route.i === routeId);
- if (!route) return false;
- return route.group === "roads";
- });
- }
-
- function isCrossroad(cellId) {
- const connections = pack.cells.routes[cellId];
- if (!connections) return false;
- if (Object.keys(connections).length > 3) return true;
- const roadConnections = Object.values(connections).filter(routeId => {
- const route = pack.routes.find(route => route.i === routeId);
- return route?.group === "roads";
- });
- return roadConnections.length > 2;
- }
-
- const connectivityRateMap = {
- roads: 0.2,
- trails: 0.1,
- searoutes: 0.2,
- default: 0.1
- };
-
- function getConnectivityRate(cellId) {
- const connections = pack.cells.routes[cellId];
- if (!connections) return 0;
-
- const connectivity = Object.values(connections).reduce((acc, routeId) => {
- const route = pack.routes.find(route => route.i === routeId);
- if (!route) return acc;
- const rate = connectivityRateMap[route.group] || connectivityRateMap.default;
- return acc + rate;
- }, 0.8);
-
- return connectivity;
- }
-
- // name generator data
- const models = {
- roads: {burg_suffix: 3, prefix_suffix: 6, the_descriptor_prefix_suffix: 2, the_descriptor_burg_suffix: 1},
- trails: {burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1},
- searoutes: {burg_suffix: 4, prefix_suffix: 2, the_descriptor_prefix_suffix: 1}
- };
-
- const prefixes = [
- "King",
- "Queen",
- "Military",
- "Old",
- "New",
- "Ancient",
- "Royal",
- "Imperial",
- "Great",
- "Grand",
- "High",
- "Silver",
- "Dragon",
- "Shadow",
- "Star",
- "Mystic",
- "Whisper",
- "Eagle",
- "Golden",
- "Crystal",
- "Enchanted",
- "Frost",
- "Moon",
- "Sun",
- "Thunder",
- "Phoenix",
- "Sapphire",
- "Celestial",
- "Wandering",
- "Echo",
- "Twilight",
- "Crimson",
- "Serpent",
- "Iron",
- "Forest",
- "Flower",
- "Whispering",
- "Eternal",
- "Frozen",
- "Rain",
- "Luminous",
- "Stardust",
- "Arcane",
- "Glimmering",
- "Jade",
- "Ember",
- "Azure",
- "Gilded",
- "Divine",
- "Shadowed",
- "Cursed",
- "Moonlit",
- "Sable",
- "Everlasting",
- "Amber",
- "Nightshade",
- "Wraith",
- "Scarlet",
- "Platinum",
- "Whirlwind",
- "Obsidian",
- "Ethereal",
- "Ghost",
- "Spike",
- "Dusk",
- "Raven",
- "Spectral",
- "Burning",
- "Verdant",
- "Copper",
- "Velvet",
- "Falcon",
- "Enigma",
- "Glowing",
- "Silvered",
- "Molten",
- "Radiant",
- "Astral",
- "Wild",
- "Flame",
- "Amethyst",
- "Aurora",
- "Shadowy",
- "Solar",
- "Lunar",
- "Whisperwind",
- "Fading",
- "Titan",
- "Dawn",
- "Crystalline",
- "Jeweled",
- "Sylvan",
- "Twisted",
- "Ebon",
- "Thorn",
- "Cerulean",
- "Halcyon",
- "Infernal",
- "Storm",
- "Eldritch",
- "Sapphire",
- "Crimson",
- "Tranquil",
- "Paved"
- ];
-
- const descriptors = [
- "Great",
- "Shrouded",
- "Sacred",
- "Fabled",
- "Frosty",
- "Winding",
- "Echoing",
- "Serpentine",
- "Breezy",
- "Misty",
- "Rustic",
- "Silent",
- "Cobbled",
- "Cracked",
- "Shaky",
- "Obscure"
- ];
-
- const suffixes = {
- roads: {road: 7, route: 3, way: 2, highway: 1},
- trails: {trail: 4, path: 1, track: 1, pass: 1},
- searoutes: {"sea route": 5, lane: 2, passage: 1, seaway: 1}
- };
-
- function generateName({group, points}) {
- if (points.length < 4) return "Unnamed route segment";
-
- const model = rw(models[group]);
- const suffix = rw(suffixes[group]);
-
- const burgName = getBurgName();
- if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`;
- if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`;
- if (model === "the_descriptor_prefix_suffix") return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`;
- if (model === "the_descriptor_burg_suffix" && burgName) return `The ${ra(descriptors)} ${burgName} ${suffix}`;
- return "Unnamed route";
-
- function getBurgName() {
- const priority = [points.at(-1), points.at(0), points.slice(1, -1).reverse()];
- for (const [_x, _y, cellId] of priority) {
- const burgId = pack.cells.burg[cellId];
- if (burgId) return getAdjective(pack.burgs[burgId].name);
- }
- return null;
- }
- }
-
- const ROUTE_CURVES = {
- roads: d3.curveCatmullRom.alpha(0.1),
- trails: d3.curveCatmullRom.alpha(0.1),
- searoutes: d3.curveCatmullRom.alpha(0.5),
- default: d3.curveCatmullRom.alpha(0.1)
- };
-
- function getPath({group, points}) {
- const lineGen = d3.line();
- lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default);
- const path = round(lineGen(points.map(p => [p[0], p[1]])), 1);
- return path;
- }
-
- function getLength(routeId) {
- const path = routes.select("#route" + routeId).node();
- return path.getTotalLength();
- }
-
- function getNextId() {
- return pack.routes.length ? Math.max(...pack.routes.map(r => r.i)) + 1 : 0;
- }
-
- function remove(route) {
- const routes = pack.cells.routes;
-
- for (const point of route.points) {
- const from = point[2];
- if (!routes[from]) continue;
-
- for (const [to, routeId] of Object.entries(routes[from])) {
- if (routeId === route.i) {
- delete routes[from][to];
- delete routes[to][from];
- }
- }
- }
-
- pack.routes = pack.routes.filter(r => r.i !== route.i);
- viewbox.select("#route" + route.i).remove();
- }
-
- return {
- generate,
- buildLinks,
- connect,
- isConnected,
- areConnected,
- getRoute,
- hasRoad,
- isCrossroad,
- getConnectivityRate,
- generateName,
- getPath,
- getLength,
- getNextId,
- remove
- };
-})();
diff --git a/src/index.html b/src/index.html
index 262e5838..3f46d62c 100644
--- a/src/index.html
+++ b/src/index.html
@@ -8494,7 +8494,6 @@
-
diff --git a/src/modules/index.ts b/src/modules/index.ts
index 27bcecba..f8fa62ef 100644
--- a/src/modules/index.ts
+++ b/src/modules/index.ts
@@ -8,5 +8,6 @@ import "./river-generator";
import "./burgs-generator";
import "./biomes";
import "./cultures-generator";
+import "./routes-generator";
import "./states-generator";
import "./provinces-generator";
diff --git a/src/modules/routes-generator.ts b/src/modules/routes-generator.ts
new file mode 100644
index 00000000..b233db47
--- /dev/null
+++ b/src/modules/routes-generator.ts
@@ -0,0 +1,786 @@
+import { curveCatmullRom, line } from "d3";
+import Delaunator from "delaunator";
+import {
+ distanceSquared,
+ findClosestCell,
+ findPath,
+ getAdjective,
+ isLand,
+ ra,
+ rn,
+ round,
+ rw,
+} from "../utils";
+import type { Burg } from "./burgs-generator";
+import type { Point } from "./voronoi";
+
+const ROUTES_SHARP_ANGLE = 135;
+const ROUTES_VERY_SHARP_ANGLE = 115;
+
+const MIN_PASSABLE_SEA_TEMP = -4;
+const ROUTE_TYPE_MODIFIERS: Record = {
+ "-1": 1, // coastline
+ "-2": 1.8, // sea
+ "-3": 4, // open sea
+ "-4": 6, // ocean
+ default: 8, // far ocean
+};
+
+// name generator data
+const models: Record> = {
+ roads: {
+ burg_suffix: 3,
+ prefix_suffix: 6,
+ the_descriptor_prefix_suffix: 2,
+ the_descriptor_burg_suffix: 1,
+ },
+ trails: { burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1 },
+ searoutes: {
+ burg_suffix: 4,
+ prefix_suffix: 2,
+ the_descriptor_prefix_suffix: 1,
+ },
+};
+
+const prefixes: string[] = [
+ "King",
+ "Queen",
+ "Military",
+ "Old",
+ "New",
+ "Ancient",
+ "Royal",
+ "Imperial",
+ "Great",
+ "Grand",
+ "High",
+ "Silver",
+ "Dragon",
+ "Shadow",
+ "Star",
+ "Mystic",
+ "Whisper",
+ "Eagle",
+ "Golden",
+ "Crystal",
+ "Enchanted",
+ "Frost",
+ "Moon",
+ "Sun",
+ "Thunder",
+ "Phoenix",
+ "Sapphire",
+ "Celestial",
+ "Wandering",
+ "Echo",
+ "Twilight",
+ "Crimson",
+ "Serpent",
+ "Iron",
+ "Forest",
+ "Flower",
+ "Whispering",
+ "Eternal",
+ "Frozen",
+ "Rain",
+ "Luminous",
+ "Stardust",
+ "Arcane",
+ "Glimmering",
+ "Jade",
+ "Ember",
+ "Azure",
+ "Gilded",
+ "Divine",
+ "Shadowed",
+ "Cursed",
+ "Moonlit",
+ "Sable",
+ "Everlasting",
+ "Amber",
+ "Nightshade",
+ "Wraith",
+ "Scarlet",
+ "Platinum",
+ "Whirlwind",
+ "Obsidian",
+ "Ethereal",
+ "Ghost",
+ "Spike",
+ "Dusk",
+ "Raven",
+ "Spectral",
+ "Burning",
+ "Verdant",
+ "Copper",
+ "Velvet",
+ "Falcon",
+ "Enigma",
+ "Glowing",
+ "Silvered",
+ "Molten",
+ "Radiant",
+ "Astral",
+ "Wild",
+ "Flame",
+ "Amethyst",
+ "Aurora",
+ "Shadowy",
+ "Solar",
+ "Lunar",
+ "Whisperwind",
+ "Fading",
+ "Titan",
+ "Dawn",
+ "Crystalline",
+ "Jeweled",
+ "Sylvan",
+ "Twisted",
+ "Ebon",
+ "Thorn",
+ "Cerulean",
+ "Halcyon",
+ "Infernal",
+ "Storm",
+ "Eldritch",
+ "Sapphire",
+ "Crimson",
+ "Tranquil",
+ "Paved",
+];
+
+const descriptors = [
+ "Great",
+ "Shrouded",
+ "Sacred",
+ "Fabled",
+ "Frosty",
+ "Winding",
+ "Echoing",
+ "Serpentine",
+ "Breezy",
+ "Misty",
+ "Rustic",
+ "Silent",
+ "Cobbled",
+ "Cracked",
+ "Shaky",
+ "Obscure",
+];
+
+const suffixes: Record> = {
+ roads: { road: 7, route: 3, way: 2, highway: 1 },
+ trails: { trail: 4, path: 1, track: 1, pass: 1 },
+ searoutes: { "sea route": 5, lane: 2, passage: 1, seaway: 1 },
+};
+
+export interface Route {
+ i: number;
+ group: "roads" | "trails" | "searoutes";
+ feature: number;
+ points: number[][];
+ cells?: number[];
+ merged?: boolean;
+}
+
+class RoutesModule {
+ buildLinks(routes: Route[]): Record> {
+ const links: Record> = {};
+
+ for (const { points, i: routeId } of routes) {
+ const cells = points.map((p) => p[2]);
+
+ for (let i = 0; i < cells.length - 1; i++) {
+ const cellId = cells[i];
+ const nextCellId = cells[i + 1];
+
+ if (cellId !== nextCellId) {
+ if (!links[cellId]) links[cellId] = {};
+ links[cellId][nextCellId] = routeId;
+
+ if (!links[nextCellId]) links[nextCellId] = {};
+ links[nextCellId][cellId] = routeId;
+ }
+ }
+ }
+
+ return links;
+ }
+
+ private sortBurgsByFeature(burgs: Burg[]) {
+ const burgsByFeature: Record = {};
+ const capitalsByFeature: Record = {};
+ const portsByFeature: Record = {};
+
+ const addBurg = (
+ collection: Record,
+ feature: number,
+ burg: Burg,
+ ) => {
+ if (!collection[feature]) collection[feature] = [];
+ collection[feature].push(burg);
+ };
+
+ for (const burg of burgs) {
+ if (burg.i && !burg.removed) {
+ const { feature, capital, port } = burg;
+ addBurg(burgsByFeature, feature as number, burg);
+ if (capital) addBurg(capitalsByFeature, feature as number, burg);
+ if (port) addBurg(portsByFeature, port as number, burg);
+ }
+ }
+
+ return { burgsByFeature, capitalsByFeature, portsByFeature };
+ }
+
+ // Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation
+ // this gives us an aproximation of a desired road network, i.e. connections between burgs
+ // code from https://observablehq.com/@mbostock/urquhart-graph
+ private calculateUrquhartEdges(points: Point[]) {
+ const score = (p0: number, p1: number) =>
+ distanceSquared(points[p0], points[p1]);
+
+ const { halfedges, triangles } = Delaunator.from(points);
+ const n = triangles.length;
+
+ const removed = new Uint8Array(n);
+ const edges = [];
+
+ for (let e = 0; e < n; e += 3) {
+ const p0 = triangles[e],
+ p1 = triangles[e + 1],
+ p2 = triangles[e + 2];
+
+ const p01 = score(p0, p1),
+ p12 = score(p1, p2),
+ p20 = score(p2, p0);
+
+ removed[
+ p20 > p01 && p20 > p12
+ ? Math.max(e + 2, halfedges[e + 2])
+ : p12 > p01 && p12 > p20
+ ? Math.max(e + 1, halfedges[e + 1])
+ : Math.max(e, halfedges[e])
+ ] = 1;
+ }
+
+ for (let e = 0; e < n; ++e) {
+ if (e > halfedges[e] && !removed[e]) {
+ const t0 = triangles[e];
+ const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1];
+ edges.push([t0, t1]);
+ }
+ }
+
+ return edges;
+ }
+
+ private createCostEvaluator({
+ isWater,
+ connections,
+ }: {
+ isWater: boolean;
+ connections: Map;
+ }) {
+ function getLandPathCost(current: number, next: number) {
+ if (pack.cells.h[next] < 20) return Infinity; // ignore water cells
+
+ const habitability = biomesData.habitability[pack.cells.biome[next]];
+ if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier)
+
+ const distanceCost = distanceSquared(
+ pack.cells.p[current],
+ pack.cells.p[next],
+ );
+ const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
+ const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3];
+ const connectionModifier = connections.has(`${current}-${next}`)
+ ? 0.5
+ : 1;
+ const burgModifier = pack.cells.burg[next] ? 1 : 3;
+
+ const pathCost =
+ distanceCost *
+ habitabilityModifier *
+ heightModifier *
+ connectionModifier *
+ burgModifier;
+ return pathCost;
+ }
+
+ function getWaterPathCost(current: number, next: number) {
+ if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells
+ if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP)
+ return Infinity; // ignore too cold cells
+
+ const distanceCost = distanceSquared(
+ pack.cells.p[current],
+ pack.cells.p[next],
+ );
+ const typeModifier =
+ ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] ||
+ ROUTE_TYPE_MODIFIERS.default;
+ const connectionModifier = connections.has(`${current}-${next}`)
+ ? 0.5
+ : 1;
+
+ const pathCost = distanceCost * typeModifier * connectionModifier;
+ return pathCost;
+ }
+ return isWater ? getWaterPathCost : getLandPathCost;
+ }
+
+ private getRouteSegments(
+ pathCells: number[],
+ connections: Map,
+ ) {
+ const segments = [];
+ let segment = [];
+
+ for (let i = 0; i < pathCells.length; i++) {
+ const cellId = pathCells[i];
+ const nextCellId = pathCells[i + 1];
+ const isConnected =
+ connections.has(`${cellId}-${nextCellId}`) ||
+ connections.has(`${nextCellId}-${cellId}`);
+
+ if (isConnected) {
+ if (segment.length) {
+ // segment stepped into existing segment
+ segment.push(pathCells[i]);
+ segments.push(segment);
+ segment = [];
+ }
+ continue;
+ }
+
+ segment.push(pathCells[i]);
+ }
+
+ if (segment.length > 1) segments.push(segment);
+
+ return segments;
+ }
+
+ private findPathSegments({
+ isWater,
+ connections,
+ start,
+ exit,
+ }: {
+ isWater: boolean;
+ connections: Map;
+ start: number;
+ exit: number;
+ }) {
+ const getCost = this.createCostEvaluator({ isWater, connections });
+ const pathCells = findPath(
+ start,
+ (current) => current === exit,
+ getCost,
+ pack,
+ );
+ if (!pathCells) return [];
+ const segments = this.getRouteSegments(pathCells, connections);
+ return segments;
+ }
+
+ private generateMainRoads(connections: Map) {
+ TIME && console.time("generateMainRoads");
+ const { capitalsByFeature } = this.sortBurgsByFeature(pack.burgs);
+ const mainRoads: Route[] = [];
+
+ for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) {
+ const points = featureCapitals.map((burg) => [burg.x, burg.y] as Point);
+ const urquhartEdges = this.calculateUrquhartEdges(points);
+ urquhartEdges.forEach(([fromId, toId]) => {
+ const start = featureCapitals[fromId].cell;
+ const exit = featureCapitals[toId].cell;
+
+ const segments = this.findPathSegments({
+ isWater: false,
+ connections,
+ start,
+ exit,
+ });
+ for (const segment of segments) {
+ this.addConnections(segment, connections);
+ mainRoads.push({ feature: Number(key), cells: segment } as Route);
+ }
+ });
+ }
+
+ TIME && console.timeEnd("generateMainRoads");
+ return mainRoads;
+ }
+
+ private addConnections(segment: number[], connections: Map) {
+ for (let i = 0; i < segment.length; i++) {
+ const cellId = segment[i];
+ const nextCellId = segment[i + 1];
+ if (nextCellId) {
+ connections.set(`${cellId}-${nextCellId}`, true);
+ connections.set(`${nextCellId}-${cellId}`, true);
+ }
+ }
+ }
+
+ private generateTrails(connections: Map) {
+ TIME && console.time("generateTrails");
+ const { burgsByFeature } = this.sortBurgsByFeature(pack.burgs);
+ const trails: Route[] = [];
+
+ for (const [key, featureBurgs] of Object.entries(burgsByFeature)) {
+ const points = featureBurgs.map((burg) => [burg.x, burg.y] as Point);
+ const urquhartEdges = this.calculateUrquhartEdges(points);
+ urquhartEdges.forEach(([fromId, toId]) => {
+ const start = featureBurgs[fromId].cell;
+ const exit = featureBurgs[toId].cell;
+
+ const segments = this.findPathSegments({
+ isWater: false,
+ connections,
+ start,
+ exit,
+ });
+ for (const segment of segments) {
+ this.addConnections(segment, connections);
+ trails.push({ feature: Number(key), cells: segment } as Route);
+ }
+ });
+ }
+
+ TIME && console.timeEnd("generateTrails");
+ return trails;
+ }
+
+ private generateSeaRoutes(connections: Map) {
+ TIME && console.time("generateSeaRoutes");
+ const { portsByFeature } = this.sortBurgsByFeature(pack.burgs);
+ const seaRoutes: Route[] = [];
+
+ for (const [featureId, featurePorts] of Object.entries(portsByFeature)) {
+ const points = featurePorts.map((burg) => [burg.x, burg.y] as Point);
+ const urquhartEdges = this.calculateUrquhartEdges(points);
+
+ urquhartEdges.forEach(([fromId, toId]) => {
+ const start = featurePorts[fromId].cell;
+ const exit = featurePorts[toId].cell;
+ const segments = this.findPathSegments({
+ isWater: true,
+ connections,
+ start,
+ exit,
+ });
+ for (const segment of segments) {
+ this.addConnections(segment, connections);
+ seaRoutes.push({
+ feature: Number(featureId),
+ cells: segment,
+ } as Route);
+ }
+ });
+ }
+
+ TIME && console.timeEnd("generateSeaRoutes");
+ return seaRoutes;
+ }
+
+ private preparePointsArray(): Point[] {
+ const { cells, burgs } = pack;
+ return cells.p.map(([x, y], cellId) => {
+ const burgId = cells.burg[cellId];
+ if (burgId) return [burgs[burgId].x, burgs[burgId].y];
+ return [x, y];
+ });
+ }
+
+ private getPoints(group: string, cells: number[], points: Point[]) {
+ const data = cells.map((cellId) => [...points[cellId], cellId]);
+
+ // resolve sharp angles
+ if (group !== "searoutes") {
+ for (let i = 1; i < cells.length - 1; i++) {
+ const cellId = cells[i];
+ if (pack.cells.burg[cellId]) continue;
+
+ const [prevX, prevY] = data[i - 1];
+ const [currX, currY] = data[i];
+ const [nextX, nextY] = data[i + 1];
+
+ const dAx = prevX - currX;
+ const dAy = prevY - currY;
+ const dBx = nextX - currX;
+ const dBy = nextY - currY;
+ const angle = Math.abs(
+ (Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) /
+ Math.PI,
+ );
+
+ if (angle < ROUTES_SHARP_ANGLE) {
+ const middleX = (prevX + nextX) / 2;
+ const middleY = (prevY + nextY) / 2;
+ let newX: number, newY: number;
+
+ if (angle < ROUTES_VERY_SHARP_ANGLE) {
+ newX = rn((currX + middleX * 2) / 3, 2);
+ newY = rn((currY + middleY * 2) / 3, 2);
+ } else {
+ newX = rn((currX + middleX) / 2, 2);
+ newY = rn((currY + middleY) / 2, 2);
+ }
+
+ if (findClosestCell(newX, newY, undefined, pack) === cellId) {
+ data[i] = [newX, newY, cellId];
+ points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes
+ }
+ }
+ }
+ }
+
+ return data; // [[x, y, cell], [x, y, cell]];
+ }
+
+ // merge routes so that the last cell of one route is the first cell of the next route
+ private mergeRoutes(routes: Route[]): Route[] {
+ let routesMerged = 0;
+
+ for (let i = 0; i < routes.length; i++) {
+ const thisRoute = routes[i];
+ if (thisRoute.merged) continue;
+
+ for (let j = i + 1; j < routes.length; j++) {
+ const nextRoute = routes[j];
+ if (nextRoute.merged) continue;
+
+ if (nextRoute.cells!.at(0) === thisRoute.cells!.at(-1)) {
+ routesMerged++;
+ thisRoute.cells = thisRoute.cells!.concat(nextRoute.cells!.slice(1));
+ nextRoute.merged = true;
+ }
+ }
+ }
+
+ return routesMerged > 1 ? this.mergeRoutes(routes) : routes;
+ }
+ private createRoutesData(routes: Route[], connections: Map) {
+ const mainRoads = this.generateMainRoads(connections);
+ const trails = this.generateTrails(connections);
+ const seaRoutes = this.generateSeaRoutes(connections);
+ const pointsArray = this.preparePointsArray();
+
+ for (const { feature, cells, merged } of this.mergeRoutes(mainRoads)) {
+ if (merged) continue;
+ const points = this.getPoints("roads", cells!, pointsArray);
+ routes.push({ i: routes.length, group: "roads", feature, points });
+ }
+
+ for (const { feature, cells, merged } of this.mergeRoutes(trails)) {
+ if (merged) continue;
+ const points = this.getPoints("trails", cells!, pointsArray);
+ routes.push({ i: routes.length, group: "trails", feature, points });
+ }
+
+ for (const { feature, cells, merged } of this.mergeRoutes(seaRoutes)) {
+ if (merged) continue;
+ const points = this.getPoints("searoutes", cells!, pointsArray);
+ routes.push({ i: routes.length, group: "searoutes", feature, points });
+ }
+
+ return routes;
+ }
+
+ generate(lockedRoutes: Route[] = []) {
+ const connections = new Map();
+ lockedRoutes.forEach((route: Route) => {
+ this.addConnections(
+ route.points.map((p) => p[2]),
+ connections,
+ );
+ });
+
+ pack.routes = this.createRoutesData(lockedRoutes, connections);
+ pack.cells.routes = this.buildLinks(pack.routes);
+ }
+
+ // utility functions
+ isConnected(cellId: number): boolean {
+ const routes = pack.cells.routes;
+ return routes[cellId] && Object.keys(routes[cellId]).length > 0;
+ }
+
+ getNextId() {
+ return pack.routes.length
+ ? Math.max(...pack.routes.map((r) => r.i)) + 1
+ : 0;
+ }
+
+ // connect cell with routes system by land
+ connect(cellId: number): Route | undefined {
+ const getCost = this.createCostEvaluator({
+ isWater: false,
+ connections: new Map(),
+ });
+ const isExit = (c: number) => isLand(c, pack) && this.isConnected(c);
+ const pathCells = findPath(cellId, isExit, getCost, pack);
+ if (!pathCells) return;
+
+ const pointsArray = this.preparePointsArray();
+ const points = this.getPoints("trails", pathCells, pointsArray);
+ const feature = pack.cells.f[cellId];
+ const routeId = this.getNextId();
+ const newRoute = { i: routeId, group: "trails", feature, points };
+ pack.routes.push(newRoute as Route);
+
+ const addConnection = (from: number, to: number, routeId: number) => {
+ const routes = pack.cells.routes;
+
+ if (!routes[from]) routes[from] = {};
+ routes[from][to] = routeId;
+
+ if (!routes[to]) routes[to] = {};
+ routes[to][from] = routeId;
+ };
+
+ for (let i = 0; i < pathCells.length; i++) {
+ const currentCell = pathCells[i];
+ const nextCellId = pathCells[i + 1];
+ if (nextCellId) addConnection(currentCell, nextCellId, routeId);
+ }
+
+ return newRoute as Route;
+ }
+
+ areConnected(from: number, to: number): boolean {
+ const routeId = pack.cells.routes[from]?.[to];
+ return routeId !== undefined;
+ }
+
+ getRoute(from: number, to: number) {
+ const routeId = pack.cells.routes[from]?.[to];
+ if (routeId === undefined) return null;
+
+ const route = pack.routes.find((route) => route.i === routeId);
+ if (!route) return null;
+
+ return route;
+ }
+
+ hasRoad(cellId: number): boolean {
+ const connections = pack.cells.routes[cellId];
+ if (!connections) return false;
+
+ return Object.values(connections).some((routeId) => {
+ const route = pack.routes.find((route) => route.i === routeId);
+ if (!route) return false;
+ return route.group === "roads";
+ });
+ }
+
+ isCrossroad(cellId: number): boolean {
+ const connections = pack.cells.routes[cellId];
+ if (!connections) return false;
+ if (Object.keys(connections).length > 3) return true;
+ const roadConnections = Object.values(connections).filter((routeId) => {
+ const route = pack.routes.find((route) => route.i === routeId);
+ return route?.group === "roads";
+ });
+ return roadConnections.length > 2;
+ }
+
+ remove(route: Route) {
+ const routes = pack.cells.routes;
+
+ for (const point of route.points) {
+ const from = point[2];
+ if (!routes[from]) continue;
+
+ for (const [to, routeId] of Object.entries(routes[from])) {
+ if (routeId === route.i) {
+ delete routes[from][parseInt(to, 10)];
+ delete routes[parseInt(to, 10)][from];
+ }
+ }
+ }
+
+ pack.routes = pack.routes.filter((r) => r.i !== route.i);
+ viewbox.select(`#route${route.i}`).remove();
+ }
+
+ getConnectivityRate(cellId: number): number {
+ const connections = pack.cells.routes[cellId];
+ if (!connections) return 0;
+
+ const connectivityRateMap = {
+ roads: 0.2,
+ trails: 0.1,
+ searoutes: 0.2,
+ default: 0.1,
+ };
+
+ const connectivity = Object.values(connections).reduce((acc, routeId) => {
+ const route = pack.routes.find((route) => route.i === routeId);
+ if (!route) return acc;
+ const rate =
+ connectivityRateMap[route.group] || connectivityRateMap.default;
+ return acc + rate;
+ }, 0.8);
+
+ return connectivity;
+ }
+
+ generateName({
+ group,
+ points,
+ }: {
+ group: string;
+ points: number[][];
+ }): string {
+ if (points.length < 4) return "Unnamed route segment";
+
+ function getBurgName() {
+ const priority = [
+ points.at(-1),
+ points.at(0),
+ points.slice(1, -1).reverse(),
+ ];
+ for (const [_x, _y, cellId] of priority as [number, number, number][]) {
+ const burgId = pack.cells.burg[cellId as number];
+ if (burgId) return getAdjective(pack.burgs[burgId].name!);
+ }
+ return null;
+ }
+
+ const model = rw(models[group]);
+ const suffix = rw(suffixes[group]);
+
+ const burgName = getBurgName();
+ if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`;
+ if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`;
+ if (model === "the_descriptor_prefix_suffix")
+ return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`;
+ if (model === "the_descriptor_burg_suffix" && burgName)
+ return `The ${ra(descriptors)} ${burgName} ${suffix}`;
+ return "Unnamed route";
+ }
+
+ getPath({ group, points }: { group: string; points: number[][] }): string {
+ const lineGen = line();
+ const ROUTE_CURVES: Record = {
+ roads: curveCatmullRom.alpha(0.1),
+ trails: curveCatmullRom.alpha(0.1),
+ searoutes: curveCatmullRom.alpha(0.5),
+ default: curveCatmullRom.alpha(0.1),
+ };
+ lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default);
+ const path = round(lineGen(points.map((p) => [p[0], p[1]])) as string, 1);
+ return path;
+ }
+
+ getLength(routeId: number): number {
+ const path = routes.select(`#route${routeId}`).node() as SVGPathElement;
+ return path.getTotalLength();
+ }
+}
+
+window.Routes = new RoutesModule();
diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts
index a9e0ff04..33a31bd7 100644
--- a/src/types/PackedGraph.ts
+++ b/src/types/PackedGraph.ts
@@ -3,6 +3,7 @@ import type { Culture } from "../modules/cultures-generator";
import type { PackedGraphFeature } from "../modules/features";
import type { Province } from "../modules/provinces-generator";
import type { River } from "../modules/river-generator";
+import type { Route } from "../modules/routes-generator";
import type { State } from "../modules/states-generator";
type TypedArray =
@@ -22,6 +23,7 @@ export interface PackedGraph {
p: [number, number][]; // cell polygon points
b: boolean[]; // cell is on border
h: TypedArray; // cell heights
+ /** Terrain type */
t: TypedArray; // cell terrain types
r: TypedArray; // river id passing through cell
f: TypedArray; // feature id occupying cell
@@ -39,6 +41,7 @@ export interface PackedGraph {
state: number[]; // cell state id
area: TypedArray; // cell area
province: TypedArray; // cell province id
+ routes: Record>;
};
vertices: {
i: number[]; // vertex indices
@@ -53,6 +56,7 @@ export interface PackedGraph {
burgs: Burg[];
states: State[];
cultures: Culture[];
+ routes: Route[];
religions: any[];
provinces: Province[];
}
diff --git a/src/types/global.ts b/src/types/global.ts
index fc4cfba9..d0e7fe70 100644
--- a/src/types/global.ts
+++ b/src/types/global.ts
@@ -30,6 +30,8 @@ declare global {
var rivers: Selection;
var oceanLayers: Selection;
var emblems: Selection;
+ var viewbox: Selection;
+ var routes: Selection;
var biomesData: {
i: number[];
name: string[];
diff --git a/tests/e2e/layers.spec.ts-snapshots/routes.html b/tests/e2e/layers.spec.ts-snapshots/routes.html
index 16e6f5ec..5c7688c3 100644
--- a/tests/e2e/layers.spec.ts-snapshots/routes.html
+++ b/tests/e2e/layers.spec.ts-snapshots/routes.html
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file