mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-18 02:01:22 +01:00
Urquhart routes (#1072)
* feat: routes generation * feat: routes rendering * feat: searoutes fix, changing reGraph * feat: searoute - change pathfinding algo * feat: routes - cleanup code * feat: routes - change data format * feat: routes - add routes to json export * feat: edit routes - start * feat: edit routes - main * feat: edit routes - EP * feat: edit routes - remove route * feat: route - generate names * feat: route - continue * Refactor route merging logic for improved performance * feat: routes - show name in tooltip * feat: routes - create route dialog * feat: update data on control point remove * feat: routes editor - split route * feat: add join route functionality to routes editor * feat: Add join route functionality to routes editor * feat: Update join route tooltip in routes editor * feat: routes overview - sort by length * feat: routes overview - fix distanceScale value * feat: routes overview - create route * Refactor getMiddlePoint function to getCloseToEdgePoint * feat: routes - change data format, fix issues * feat: routes - regenerateRoutes * feat: routes - add route on burg creation * chore - remove merge conflict markers * chore - remove merge conflict markers * feat: routes name - no unnamed burg names * feat: routes - lock routes * fix: routes - split routes * feat: routes - tip correction * feat: routes - auto-update part 1 * feat: routes - return old rePacj logic to not break auto-update * feat: routes - auto-update - add connections --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com>
This commit is contained in:
parent
c6dd331eb6
commit
f19b891421
47 changed files with 2462 additions and 1032 deletions
|
|
@ -1,24 +1,8 @@
|
|||
"use strict";
|
||||
|
||||
const MIN_LAND_HEIGHT = 20;
|
||||
|
||||
const names = [
|
||||
"Marine",
|
||||
"Hot desert",
|
||||
"Cold desert",
|
||||
"Savanna",
|
||||
"Grassland",
|
||||
"Tropical seasonal forest",
|
||||
"Temperate deciduous forest",
|
||||
"Tropical rainforest",
|
||||
"Temperate rainforest",
|
||||
"Taiga",
|
||||
"Tundra",
|
||||
"Glacier",
|
||||
"Wetland"
|
||||
];
|
||||
|
||||
window.Biomes = (function () {
|
||||
const MIN_LAND_HEIGHT = 20;
|
||||
|
||||
const getDefault = () => {
|
||||
const name = [
|
||||
"Marine",
|
||||
|
|
@ -52,7 +36,7 @@ window.Biomes = (function () {
|
|||
"#0b9131"
|
||||
];
|
||||
const habitability = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12];
|
||||
const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 150];
|
||||
const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250];
|
||||
const icons = [
|
||||
{},
|
||||
{dune: 3, cactus: 6, deadTree: 1},
|
||||
|
|
|
|||
|
|
@ -6,27 +6,20 @@ window.BurgsAndStates = (() => {
|
|||
const n = cells.i.length;
|
||||
|
||||
cells.burg = new Uint16Array(n); // cell burg
|
||||
cells.road = new Uint16Array(n); // cell road power
|
||||
cells.crossroad = new Uint16Array(n); // cell crossroad power
|
||||
|
||||
const burgs = (pack.burgs = placeCapitals());
|
||||
pack.states = createStates();
|
||||
const capitalRoutes = Routes.getRoads();
|
||||
|
||||
placeTowns();
|
||||
expandStates();
|
||||
normalizeStates();
|
||||
const townRoutes = Routes.getTrails();
|
||||
specifyBurgs();
|
||||
|
||||
const oceanRoutes = Routes.getSearoutes();
|
||||
|
||||
collectStatistics();
|
||||
assignColors();
|
||||
|
||||
generateCampaigns();
|
||||
generateDiplomacy();
|
||||
Routes.draw(capitalRoutes, townRoutes, oceanRoutes);
|
||||
drawBurgs();
|
||||
|
||||
function placeCapitals() {
|
||||
|
|
@ -138,9 +131,8 @@ window.BurgsAndStates = (() => {
|
|||
while (burgsAdded < burgsNumber && spacing > 1) {
|
||||
for (let i = 0; burgsAdded < burgsNumber && i < sorted.length; i++) {
|
||||
if (cells.burg[sorted[i]]) continue;
|
||||
const cell = sorted[i],
|
||||
x = cells.p[cell][0],
|
||||
y = cells.p[cell][1];
|
||||
const cell = sorted[i];
|
||||
const [x, y] = cells.p[cell];
|
||||
const s = spacing * gauss(1, 0.3, 0.2, 2, 2); // randomize to make placement not uniform
|
||||
if (burgsTree.find(x, y, s) !== undefined) continue; // to close to existing burg
|
||||
const burg = burgs.length;
|
||||
|
|
@ -183,12 +175,12 @@ window.BurgsAndStates = (() => {
|
|||
} else b.port = 0;
|
||||
|
||||
// define burg population (keep urbanization at about 10% rate)
|
||||
b.population = rn(Math.max((cells.s[i] + cells.road[i] / 2) / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
|
||||
b.population = rn(Math.max(cells.s[i] / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
|
||||
if (b.capital) b.population = rn(b.population * 1.3, 3); // increase capital population
|
||||
|
||||
if (b.port) {
|
||||
b.population = b.population * 1.3; // increase port population
|
||||
const [x, y] = getMiddlePoint(i, haven);
|
||||
const [x, y] = getCloseToEdgePoint(i, haven);
|
||||
b.x = x;
|
||||
b.y = y;
|
||||
}
|
||||
|
|
@ -229,6 +221,23 @@ window.BurgsAndStates = (() => {
|
|||
TIME && console.timeEnd("specifyBurgs");
|
||||
};
|
||||
|
||||
function getCloseToEdgePoint(cell1, cell2) {
|
||||
const {cells, vertices} = pack;
|
||||
|
||||
const [x0, y0] = cells.p[cell1];
|
||||
|
||||
const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2));
|
||||
const [x1, y1] = vertices.p[commonVertices[0]];
|
||||
const [x2, y2] = vertices.p[commonVertices[1]];
|
||||
const xEdge = (x1 + x2) / 2;
|
||||
const yEdge = (y1 + y2) / 2;
|
||||
|
||||
const x = rn(x0 + 0.95 * (xEdge - x0), 2);
|
||||
const y = rn(y0 + 0.95 * (yEdge - y0), 2);
|
||||
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
const getType = (i, port) => {
|
||||
const cells = pack.cells;
|
||||
if (port) return "Naval";
|
||||
|
|
@ -244,11 +253,11 @@ window.BurgsAndStates = (() => {
|
|||
return "Generic";
|
||||
};
|
||||
|
||||
const defineBurgFeatures = newburg => {
|
||||
const defineBurgFeatures = burg => {
|
||||
const {cells} = pack;
|
||||
|
||||
pack.burgs
|
||||
.filter(b => (newburg ? b.i == newburg.i : b.i && !b.removed && !b.lock))
|
||||
.filter(b => (burg ? b.i == burg.i : b.i && !b.removed && !b.lock))
|
||||
.forEach(b => {
|
||||
const pop = b.population;
|
||||
b.citadel = Number(b.capital || (pop > 50 && P(0.75)) || (pop > 15 && P(0.5)) || P(0.1));
|
||||
|
|
|
|||
|
|
@ -860,4 +860,59 @@ export function resolveVersionConflicts(version) {
|
|||
shiftCompass();
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 1.99) {
|
||||
// v1.99.00 changed routes generation algorithm and data format
|
||||
delete cells.road;
|
||||
delete cells.crossroad;
|
||||
|
||||
pack.routes = [];
|
||||
const POINT_DISTANCE = grid.spacing * 0.75;
|
||||
|
||||
routes.selectAll("g").each(function () {
|
||||
const group = this.id;
|
||||
if (!group) return;
|
||||
|
||||
for (const node of this.querySelectorAll("path")) {
|
||||
const totalLength = node.getTotalLength();
|
||||
if (!totalLength) debugger;
|
||||
const increment = totalLength / Math.ceil(totalLength / POINT_DISTANCE);
|
||||
const points = [];
|
||||
|
||||
for (let i = 0; i <= totalLength + 0.1; i += increment) {
|
||||
const point = node.getPointAtLength(i);
|
||||
const x = rn(point.x, 2);
|
||||
const y = rn(point.y, 2);
|
||||
const cellId = findCell(x, y);
|
||||
points.push([x, y, cellId]);
|
||||
}
|
||||
|
||||
if (points.length < 2) return;
|
||||
|
||||
const secondCellId = points[1][2];
|
||||
const feature = pack.cells.f[secondCellId];
|
||||
|
||||
pack.routes.push({i: pack.routes.length, group, feature, points});
|
||||
}
|
||||
});
|
||||
|
||||
routes.selectAll("path").remove();
|
||||
if (layerIsOn("toggleRoutes")) drawRoutes();
|
||||
|
||||
const links = (pack.cells.routes = {});
|
||||
for (const route of pack.routes) {
|
||||
for (let i = 0; i < route.points.length - 1; i++) {
|
||||
const cellId = route.points[i][2];
|
||||
const nextCellId = route.points[i + 1][2];
|
||||
|
||||
if (cellId !== nextCellId) {
|
||||
if (!links[cellId]) links[cellId] = {};
|
||||
links[cellId][nextCellId] = route.i;
|
||||
|
||||
if (!links[nextCellId]) links[nextCellId] = {};
|
||||
links[nextCellId][cellId] = route.i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -966,7 +966,7 @@ function dragStateBrush() {
|
|||
const p = d3.mouse(this);
|
||||
moveCircle(p[0], p[1], r);
|
||||
|
||||
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
|
||||
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])];
|
||||
const selection = found.filter(isLand);
|
||||
if (selection) changeStateForSelection(selection);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ function getMinimalDataJson() {
|
|||
provinces: pack.provinces,
|
||||
religions: pack.religions,
|
||||
rivers: pack.rivers,
|
||||
markers: pack.markers
|
||||
markers: pack.markers,
|
||||
routes: pack.routes
|
||||
};
|
||||
return JSON.stringify({info, settings, mapCoordinates, pack: packData, biomesData, notes, nameBases});
|
||||
}
|
||||
|
|
@ -85,7 +86,7 @@ function getMapInfo() {
|
|||
function getSettings() {
|
||||
return {
|
||||
distanceUnit: distanceUnitInput.value,
|
||||
distanceScale: distanceScaleInput.value,
|
||||
distanceScale,
|
||||
areaUnit: areaUnit.value,
|
||||
heightUnit: heightUnit.value,
|
||||
heightExponent: heightExponentInput.value,
|
||||
|
|
@ -106,7 +107,7 @@ function getSettings() {
|
|||
}
|
||||
|
||||
function getPackCellsData() {
|
||||
const dataArrays = {
|
||||
const data = {
|
||||
v: pack.cells.v,
|
||||
c: pack.cells.c,
|
||||
p: pack.cells.p,
|
||||
|
|
@ -125,8 +126,7 @@ function getPackCellsData() {
|
|||
pop: Array.from(pack.cells.pop),
|
||||
culture: Array.from(pack.cells.culture),
|
||||
burg: Array.from(pack.cells.burg),
|
||||
road: Array.from(pack.cells.road),
|
||||
crossroad: Array.from(pack.cells.crossroad),
|
||||
routes: pack.cells.routes,
|
||||
state: Array.from(pack.cells.state),
|
||||
religion: Array.from(pack.cells.religion),
|
||||
province: Array.from(pack.cells.province)
|
||||
|
|
@ -135,29 +135,28 @@ function getPackCellsData() {
|
|||
return {
|
||||
cells: Array.from(pack.cells.i).map(cellId => ({
|
||||
i: cellId,
|
||||
v: dataArrays.v[cellId],
|
||||
c: dataArrays.c[cellId],
|
||||
p: dataArrays.p[cellId],
|
||||
g: dataArrays.g[cellId],
|
||||
h: dataArrays.h[cellId],
|
||||
area: dataArrays.area[cellId],
|
||||
f: dataArrays.f[cellId],
|
||||
t: dataArrays.t[cellId],
|
||||
haven: dataArrays.haven[cellId],
|
||||
harbor: dataArrays.harbor[cellId],
|
||||
fl: dataArrays.fl[cellId],
|
||||
r: dataArrays.r[cellId],
|
||||
conf: dataArrays.conf[cellId],
|
||||
biome: dataArrays.biome[cellId],
|
||||
s: dataArrays.s[cellId],
|
||||
pop: dataArrays.pop[cellId],
|
||||
culture: dataArrays.culture[cellId],
|
||||
burg: dataArrays.burg[cellId],
|
||||
road: dataArrays.road[cellId],
|
||||
crossroad: dataArrays.crossroad[cellId],
|
||||
state: dataArrays.state[cellId],
|
||||
religion: dataArrays.religion[cellId],
|
||||
province: dataArrays.province[cellId]
|
||||
v: data.v[cellId],
|
||||
c: data.c[cellId],
|
||||
p: data.p[cellId],
|
||||
g: data.g[cellId],
|
||||
h: data.h[cellId],
|
||||
area: data.area[cellId],
|
||||
f: data.f[cellId],
|
||||
t: data.t[cellId],
|
||||
haven: data.haven[cellId],
|
||||
harbor: data.harbor[cellId],
|
||||
fl: data.fl[cellId],
|
||||
r: data.r[cellId],
|
||||
conf: data.conf[cellId],
|
||||
biome: data.biome[cellId],
|
||||
s: data.s[cellId],
|
||||
pop: data.pop[cellId],
|
||||
culture: data.culture[cellId],
|
||||
burg: data.burg[cellId],
|
||||
routes: data.routes[cellId],
|
||||
state: data.state[cellId],
|
||||
religion: data.religion[cellId],
|
||||
province: data.province[cellId]
|
||||
})),
|
||||
vertices: Array.from(pack.vertices.p).map((_, vertexId) => ({
|
||||
i: vertexId,
|
||||
|
|
@ -172,7 +171,8 @@ function getPackCellsData() {
|
|||
provinces: pack.provinces,
|
||||
religions: pack.religions,
|
||||
rivers: pack.rivers,
|
||||
markers: pack.markers
|
||||
markers: pack.markers,
|
||||
routes: pack.routes
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import {rollups} from "../../../utils/functionUtils.js";
|
||||
|
||||
const entitiesMap = {
|
||||
states: {
|
||||
label: "State",
|
||||
|
|
|
|||
|
|
@ -471,17 +471,23 @@ function saveGeoJSON_Cells() {
|
|||
}
|
||||
|
||||
function saveGeoJSON_Routes() {
|
||||
const json = {type: "FeatureCollection", features: []};
|
||||
|
||||
routes.selectAll("g > path").each(function () {
|
||||
const coordinates = getRoutePoints(this);
|
||||
const id = this.id;
|
||||
const type = this.parentElement.id;
|
||||
|
||||
const feature = {type: "Feature", geometry: {type: "LineString", coordinates}, properties: {id, type}};
|
||||
json.features.push(feature);
|
||||
const {cells, burgs} = pack;
|
||||
let points = cells.p.map(([x, y], cellId) => {
|
||||
const burgId = cells.burg[cellId];
|
||||
if (burgId) return [burgs[burgId].x, burgs[burgId].y];
|
||||
return [x, y];
|
||||
});
|
||||
|
||||
const features = pack.routes.map(route => {
|
||||
const coordinates = route.points || getRoutePoints(route, points);
|
||||
return {
|
||||
type: "Feature",
|
||||
geometry: {type: "LineString", coordinates},
|
||||
properties: {id: route.id, group: route.group}
|
||||
};
|
||||
});
|
||||
const json = {type: "FeatureCollection", features};
|
||||
|
||||
const fileName = getFileName("Routes") + ".geojson";
|
||||
downloadFile(JSON.stringify(json), fileName, "application/json");
|
||||
}
|
||||
|
|
@ -525,17 +531,6 @@ function getCellCoordinates(vertices) {
|
|||
return [coordinates.concat([coordinates[0]])];
|
||||
}
|
||||
|
||||
function getRoutePoints(node) {
|
||||
let points = [];
|
||||
const l = node.getTotalLength();
|
||||
const increment = l / Math.ceil(l / 2);
|
||||
for (let i = 0; i <= l; i += increment) {
|
||||
const p = node.getPointAtLength(i);
|
||||
points.push(getCoordinates(p.x, p.y, 4));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function getRiverPoints(node) {
|
||||
let points = [];
|
||||
const l = node.getTotalLength() / 2; // half-length
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
pack.provinces = data[30] ? JSON.parse(data[30]) : [0];
|
||||
pack.rivers = data[32] ? JSON.parse(data[32]) : [];
|
||||
pack.markers = data[35] ? JSON.parse(data[35]) : [];
|
||||
pack.routes = data[37] ? JSON.parse(data[37]) : [];
|
||||
|
||||
const cells = pack.cells;
|
||||
cells.biome = Uint8Array.from(data[16].split(","));
|
||||
|
|
@ -388,12 +389,13 @@ async function parseLoadedData(data, mapVersion) {
|
|||
cells.fl = Uint16Array.from(data[20].split(","));
|
||||
cells.pop = Float32Array.from(data[21].split(","));
|
||||
cells.r = Uint16Array.from(data[22].split(","));
|
||||
cells.road = Uint16Array.from(data[23].split(","));
|
||||
// data[23] for deprecated cells.road
|
||||
cells.s = Uint16Array.from(data[24].split(","));
|
||||
cells.state = Uint16Array.from(data[25].split(","));
|
||||
cells.religion = data[26] ? Uint16Array.from(data[26].split(",")) : new Uint16Array(cells.i.length);
|
||||
cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(cells.i.length);
|
||||
cells.crossroad = data[28] ? Uint16Array.from(data[28].split(",")) : new Uint16Array(cells.i.length);
|
||||
// data[28] for deprecated cells.crossroad
|
||||
cells.routes = data[36] ? JSON.parse(data[36]) : {};
|
||||
|
||||
if (data[31]) {
|
||||
const namesDL = data[31].split("/");
|
||||
|
|
@ -461,7 +463,7 @@ async function parseLoadedData(data, mapVersion) {
|
|||
{
|
||||
// dynamically import and run auto-update script
|
||||
const versionNumber = parseFloat(params[0]);
|
||||
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.98.06");
|
||||
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.99.00");
|
||||
resolveVersionConflicts(versionNumber);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function prepareMapData() {
|
|||
const params = [version, license, dateString, seed, graphWidth, graphHeight, mapId].join("|");
|
||||
const settings = [
|
||||
distanceUnitInput.value,
|
||||
distanceScaleInput.value,
|
||||
distanceScale,
|
||||
areaUnit.value,
|
||||
heightUnit.value,
|
||||
heightExponentInput.value,
|
||||
|
|
@ -98,6 +98,8 @@ function prepareMapData() {
|
|||
const provinces = JSON.stringify(pack.provinces);
|
||||
const rivers = JSON.stringify(pack.rivers);
|
||||
const markers = JSON.stringify(pack.markers);
|
||||
const cellRoutes = JSON.stringify(pack.cells.routes);
|
||||
const routes = JSON.stringify(pack.routes);
|
||||
|
||||
// store name array only if not the same as default
|
||||
const defaultNB = Names.getNameBases();
|
||||
|
|
@ -136,19 +138,21 @@ function prepareMapData() {
|
|||
pack.cells.fl,
|
||||
pop,
|
||||
pack.cells.r,
|
||||
pack.cells.road,
|
||||
[], // deprecated pack.cells.road
|
||||
pack.cells.s,
|
||||
pack.cells.state,
|
||||
pack.cells.religion,
|
||||
pack.cells.province,
|
||||
pack.cells.crossroad,
|
||||
[], // deprecated pack.cells.crossroad
|
||||
religions,
|
||||
provinces,
|
||||
namesData,
|
||||
rivers,
|
||||
rulersString,
|
||||
fonts,
|
||||
markers
|
||||
markers,
|
||||
cellRoutes,
|
||||
routes
|
||||
].join("\r\n");
|
||||
return mapData;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ window.Markers = (function () {
|
|||
{type: "water-sources", icon: "💧", min: 1, each: 1000, multiplier: 1, list: listWaterSources, add: addWaterSource},
|
||||
{type: "mines", icon: "⛏️", dx: 48, px: 13, min: 1, each: 15, multiplier: 1, list: listMines, add: addMine},
|
||||
{type: "bridges", icon: "🌉", px: 14, min: 1, each: 5, multiplier: 1, list: listBridges, add: addBridge},
|
||||
{type: "inns", icon: "🍻", px: 14, min: 1, each: 100, multiplier: 1, list: listInns, add: addInn},
|
||||
{type: "inns", icon: "🍻", px: 14, min: 1, each: 10, multiplier: 1, list: listInns, add: addInn},
|
||||
{type: "lighthouses", icon: "🚨", px: 14, min: 1, each: 2, multiplier: 1, list: listLighthouses, add: addLighthouse},
|
||||
{type: "waterfalls", icon: "⟱", dy: 54, px: 16, min: 1, each: 5, multiplier: 1, list: listWaterfalls, add: addWaterfall},
|
||||
{type: "battlefields", icon: "⚔️", dy: 52, min: 50, each: 700, multiplier: 1, list: listBattlefields, add: addBattlefield},
|
||||
|
|
@ -279,7 +279,8 @@ window.Markers = (function () {
|
|||
}
|
||||
|
||||
function listInns({cells}) {
|
||||
return cells.i.filter(i => !occupied[i] && cells.h[i] >= 20 && cells.road[i] > 4 && cells.pop[i] > 10);
|
||||
const crossRoads = cells.i.filter(i => !occupied[i] && cells.pop[i] > 5 && Routes.isCrossroad(i));
|
||||
return crossRoads;
|
||||
}
|
||||
|
||||
function addInn(id, cell) {
|
||||
|
|
@ -542,7 +543,7 @@ window.Markers = (function () {
|
|||
|
||||
function listLighthouses({cells}) {
|
||||
return cells.i.filter(
|
||||
i => !occupied[i] && cells.harbor[i] > 6 && cells.c[i].some(c => cells.h[c] < 20 && cells.road[c])
|
||||
i => !occupied[i] && cells.harbor[i] > 6 && cells.c[i].some(c => cells.h[c] < 20 && Routes.isConnected(c))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -642,7 +643,7 @@ window.Markers = (function () {
|
|||
|
||||
function listSeaMonsters({cells, features}) {
|
||||
return cells.i.filter(
|
||||
i => !occupied[i] && cells.h[i] < 20 && cells.road[i] && features[cells.f[i]].type === "ocean"
|
||||
i => !occupied[i] && cells.h[i] < 20 && Routes.isConnected(i) && features[cells.f[i]].type === "ocean"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -792,7 +793,7 @@ window.Markers = (function () {
|
|||
cells.religion[i] &&
|
||||
cells.biome[i] === 1 &&
|
||||
cells.pop[i] > 1 &&
|
||||
cells.road[i]
|
||||
Routes.isConnected(i)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -807,7 +808,7 @@ window.Markers = (function () {
|
|||
}
|
||||
|
||||
function listBrigands({cells}) {
|
||||
return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.road[i] > 4);
|
||||
return cells.i.filter(i => !occupied[i] && cells.culture[i] && Routes.hasRoad(i));
|
||||
}
|
||||
|
||||
function addBrigands(id, cell) {
|
||||
|
|
@ -867,7 +868,7 @@ window.Markers = (function () {
|
|||
|
||||
// Pirates spawn on sea routes
|
||||
function listPirates({cells}) {
|
||||
return cells.i.filter(i => !occupied[i] && cells.h[i] < 20 && cells.road[i]);
|
||||
return cells.i.filter(i => !occupied[i] && cells.h[i] < 20 && Routes.isConnected(i));
|
||||
}
|
||||
|
||||
function addPirates(id, cell) {
|
||||
|
|
@ -961,7 +962,7 @@ window.Markers = (function () {
|
|||
}
|
||||
|
||||
function listCircuses({cells}) {
|
||||
return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.h[i] >= 20 && pack.cells.road[i]);
|
||||
return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.h[i] >= 20 && Routes.isConnected(i));
|
||||
}
|
||||
|
||||
function addCircuse(id, cell) {
|
||||
|
|
@ -1254,16 +1255,16 @@ window.Markers = (function () {
|
|||
|
||||
const name = `${toponym} ${type}`;
|
||||
const legend = ra([
|
||||
"A foreboding necropolis shrouded in perpetual darkness, where eerie whispers echo through the winding corridors and spectral guardians stand watch over the tombs of long-forgotten souls",
|
||||
"A towering necropolis adorned with macabre sculptures and guarded by formidable undead sentinels. Its ancient halls house the remains of fallen heroes, entombed alongside their cherished relics",
|
||||
"This ethereal necropolis seems suspended between the realms of the living and the dead. Wisps of mist dance around the tombstones, while haunting melodies linger in the air, commemorating the departed",
|
||||
"Rising from the desolate landscape, this sinister necropolis is a testament to necromantic power. Its skeletal spires cast ominous shadows, concealing forbidden knowledge and arcane secrets",
|
||||
"An eerie necropolis where nature intertwines with death. Overgrown tombstones are entwined by thorny vines, and mournful spirits wander among the fading petals of once-vibrant flowers",
|
||||
"A labyrinthine necropolis where each step echoes with haunting murmurs. The walls are adorned with ancient runes, and restless spirits guide or hinder those who dare to delve into its depths",
|
||||
"This cursed necropolis is veiled in perpetual twilight, perpetuating a sense of impending doom. Dark enchantments shroud the tombs, and the moans of anguished souls resound through its crumbling halls",
|
||||
"A sprawling necropolis built within a labyrinthine network of catacombs. Its halls are lined with countless alcoves, each housing the remains of the departed, while the distant sound of rattling bones fills the air",
|
||||
"A desolate necropolis where an eerie stillness reigns. Time seems frozen amidst the decaying mausoleums, and the silence is broken only by the whispers of the wind and the rustle of tattered banners",
|
||||
"A foreboding necropolis perched atop a jagged cliff, overlooking a desolate wasteland. Its towering walls harbor restless spirits, and the imposing gates bear the marks of countless battles and ancient curses"
|
||||
"A foreboding necropolis shrouded in perpetual darkness, where eerie whispers echo through the winding corridors and spectral guardians stand watch over the tombs of long-forgotten souls.",
|
||||
"A towering necropolis adorned with macabre sculptures and guarded by formidable undead sentinels. Its ancient halls house the remains of fallen heroes, entombed alongside their cherished relics.",
|
||||
"This ethereal necropolis seems suspended between the realms of the living and the dead. Wisps of mist dance around the tombstones, while haunting melodies linger in the air, commemorating the departed.",
|
||||
"Rising from the desolate landscape, this sinister necropolis is a testament to necromantic power. Its skeletal spires cast ominous shadows, concealing forbidden knowledge and arcane secrets.",
|
||||
"An eerie necropolis where nature intertwines with death. Overgrown tombstones are entwined by thorny vines, and mournful spirits wander among the fading petals of once-vibrant flowers.",
|
||||
"A labyrinthine necropolis where each step echoes with haunting murmurs. The walls are adorned with ancient runes, and restless spirits guide or hinder those who dare to delve into its depths.",
|
||||
"This cursed necropolis is veiled in perpetual twilight, perpetuating a sense of impending doom. Dark enchantments shroud the tombs, and the moans of anguished souls resound through its crumbling halls.",
|
||||
"A sprawling necropolis built within a labyrinthine network of catacombs. Its halls are lined with countless alcoves, each housing the remains of the departed, while the distant sound of rattling bones fills the air.",
|
||||
"A desolate necropolis where an eerie stillness reigns. Time seems frozen amidst the decaying mausoleums, and the silence is broken only by the whispers of the wind and the rustle of tattered banners.",
|
||||
"A foreboding necropolis perched atop a jagged cliff, overlooking a desolate wasteland. Its towering walls harbor restless spirits, and the imposing gates bear the marks of countless battles and ancient curses."
|
||||
]);
|
||||
|
||||
notes.push({id, name, legend});
|
||||
|
|
|
|||
|
|
@ -692,7 +692,7 @@ window.Religions = (function () {
|
|||
|
||||
// growth algorithm to assign cells to religions
|
||||
function expandReligions(religions) {
|
||||
const cells = pack.cells;
|
||||
const {cells, routes} = pack;
|
||||
const religionIds = spreadFolkReligions(religions);
|
||||
|
||||
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
|
||||
|
|
@ -700,8 +700,6 @@ window.Religions = (function () {
|
|||
|
||||
const maxExpansionCost = (cells.i.length / 20) * neutralInput.value; // limit cost for organized religions growth
|
||||
|
||||
const biomePassageCost = cellId => biomesData.cost[cells.biome[cellId]];
|
||||
|
||||
religions
|
||||
.filter(r => r.i && !r.lock && r.type !== "Folk" && !r.removed)
|
||||
.forEach(r => {
|
||||
|
|
@ -712,11 +710,6 @@ window.Religions = (function () {
|
|||
|
||||
const religionsMap = new Map(religions.map(r => [r.i, r]));
|
||||
|
||||
const isMainRoad = cellId => cells.road[cellId] - cells.crossroad[cellId] > 4;
|
||||
const isTrail = cellId => cells.h[cellId] > 19 && cells.road[cellId] - cells.crossroad[cellId] === 1;
|
||||
const isSeaRoute = cellId => cells.h[cellId] < 20 && cells.road[cellId];
|
||||
const isWater = cellId => cells.h[cellId] < 20;
|
||||
|
||||
while (queue.length) {
|
||||
const {e: cellId, p, r, s: state} = queue.dequeue();
|
||||
const {culture, expansion, expansionism} = religionsMap.get(r);
|
||||
|
|
@ -728,7 +721,7 @@ window.Religions = (function () {
|
|||
|
||||
const cultureCost = culture !== cells.culture[nextCell] ? 10 : 0;
|
||||
const stateCost = state !== cells.state[nextCell] ? 10 : 0;
|
||||
const passageCost = getPassageCost(nextCell);
|
||||
const passageCost = getPassageCost(cellId, nextCell);
|
||||
|
||||
const cellCost = cultureCost + stateCost + passageCost;
|
||||
const totalCost = p + 10 + cellCost / expansionism;
|
||||
|
|
@ -745,11 +738,18 @@ window.Religions = (function () {
|
|||
|
||||
return religionIds;
|
||||
|
||||
function getPassageCost(cellId) {
|
||||
if (isWater(cellId)) return isSeaRoute ? 50 : 500;
|
||||
if (isMainRoad(cellId)) return 1;
|
||||
const biomeCost = biomePassageCost(cellId);
|
||||
return isTrail(cellId) ? biomeCost / 1.5 : biomeCost;
|
||||
function getPassageCost(cellId, nextCellId) {
|
||||
const route = Routes.getRoute(cellId, nextCellId);
|
||||
if (isWater(cellId)) return route ? 50 : 500;
|
||||
|
||||
const biomePassageCost = biomesData.cost[cells.biome[nextCellId]];
|
||||
|
||||
if (route) {
|
||||
if (route.group === "roads") return 1;
|
||||
return biomePassageCost / 3; // trails and other routes
|
||||
}
|
||||
|
||||
return biomePassageCost;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,269 +1,743 @@
|
|||
const ROUTES_SHARP_ANGLE = 135;
|
||||
const ROUTES_VERY_SHARP_ANGLE = 115;
|
||||
|
||||
window.Routes = (function () {
|
||||
const getRoads = function () {
|
||||
TIME && console.time("generateMainRoads");
|
||||
const cells = pack.cells;
|
||||
const burgs = pack.burgs.filter(b => b.i && !b.removed);
|
||||
const capitals = burgs.filter(b => b.capital).sort((a, b) => a.population - b.population);
|
||||
function generate(lockedRoutes = []) {
|
||||
const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs);
|
||||
|
||||
if (capitals.length < 2) return []; // not enough capitals to build main roads
|
||||
const paths = []; // array to store path segments
|
||||
const connections = new Map();
|
||||
lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2])));
|
||||
|
||||
for (const b of capitals) {
|
||||
const connect = capitals.filter(c => c.feature === b.feature && c !== b);
|
||||
for (const t of connect) {
|
||||
const [from, exit] = findLandPath(b.cell, t.cell, true);
|
||||
const segments = restorePath(b.cell, exit, "main", from);
|
||||
segments.forEach(s => paths.push(s));
|
||||
}
|
||||
}
|
||||
const mainRoads = generateMainRoads();
|
||||
const trails = generateTrails();
|
||||
const seaRoutes = generateSeaRoutes();
|
||||
|
||||
cells.i.forEach(i => (cells.s[i] += cells.road[i] / 2)); // add roads to suitability score
|
||||
TIME && console.timeEnd("generateMainRoads");
|
||||
return paths;
|
||||
};
|
||||
pack.routes = createRoutesData(lockedRoutes);
|
||||
pack.cells.routes = buildLinks(pack.routes);
|
||||
|
||||
const getTrails = function () {
|
||||
TIME && console.time("generateTrails");
|
||||
const cells = pack.cells;
|
||||
const burgs = pack.burgs.filter(b => b.i && !b.removed);
|
||||
function sortBurgsByFeature(burgs) {
|
||||
const burgsByFeature = {};
|
||||
const capitalsByFeature = {};
|
||||
const portsByFeature = {};
|
||||
|
||||
if (burgs.length < 2) return []; // not enough burgs to build trails
|
||||
const addBurg = (object, feature, burg) => {
|
||||
if (!object[feature]) object[feature] = [];
|
||||
object[feature].push(burg);
|
||||
};
|
||||
|
||||
let paths = []; // array to store path segments
|
||||
for (const f of pack.features.filter(f => f.land)) {
|
||||
const isle = burgs.filter(b => b.feature === f.i); // burgs on island
|
||||
if (isle.length < 2) continue;
|
||||
|
||||
isle.forEach(function (b, i) {
|
||||
let path = [];
|
||||
if (!i) {
|
||||
// build trail from the first burg on island
|
||||
// to the farthest one on the same island or the closest road
|
||||
const farthest = d3.scan(isle, (a, c) => (c.y - b.y) ** 2 + (c.x - b.x) ** 2 - ((a.y - b.y) ** 2 + (a.x - b.x) ** 2));
|
||||
const to = isle[farthest].cell;
|
||||
if (cells.road[to]) return;
|
||||
const [from, exit] = findLandPath(b.cell, to, true);
|
||||
path = restorePath(b.cell, exit, "small", from);
|
||||
} else {
|
||||
// build trail from all other burgs to the closest road on the same island
|
||||
if (cells.road[b.cell]) return;
|
||||
const [from, exit] = findLandPath(b.cell, null, true);
|
||||
if (exit === null) return;
|
||||
path = restorePath(b.cell, exit, "small", from);
|
||||
}
|
||||
if (path) paths = paths.concat(path);
|
||||
});
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("generateTrails");
|
||||
return paths;
|
||||
};
|
||||
|
||||
const getSearoutes = function () {
|
||||
TIME && console.time("generateSearoutes");
|
||||
const {cells, burgs, features} = pack;
|
||||
const allPorts = burgs.filter(b => b.port > 0 && !b.removed);
|
||||
|
||||
if (!allPorts.length) return [];
|
||||
|
||||
const bodies = new Set(allPorts.map(b => b.port)); // water features with ports
|
||||
let paths = []; // array to store path segments
|
||||
const connected = []; // store cell id of connected burgs
|
||||
|
||||
bodies.forEach(f => {
|
||||
const ports = allPorts.filter(b => b.port === f); // all ports on the same feature
|
||||
if (!ports.length) return;
|
||||
|
||||
if (features[f]?.border) addOverseaRoute(f, ports[0]);
|
||||
|
||||
// get inner-map routes
|
||||
for (let s = 0; s < ports.length; s++) {
|
||||
const source = ports[s].cell;
|
||||
if (connected[source]) continue;
|
||||
|
||||
for (let t = s + 1; t < ports.length; t++) {
|
||||
const target = ports[t].cell;
|
||||
if (connected[target]) continue;
|
||||
|
||||
const [from, exit, passable] = findOceanPath(target, source, true);
|
||||
if (!passable) continue;
|
||||
|
||||
const path = restorePath(target, exit, "ocean", from);
|
||||
paths = paths.concat(path);
|
||||
|
||||
connected[source] = 1;
|
||||
connected[target] = 1;
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function addOverseaRoute(f, port) {
|
||||
const {x, y, cell: source} = port;
|
||||
const dist = p => Math.abs(p[0] - x) + Math.abs(p[1] - y);
|
||||
const [x1, y1] = [
|
||||
[0, y],
|
||||
[x, 0],
|
||||
[graphWidth, y],
|
||||
[x, graphHeight]
|
||||
].sort((a, b) => dist(a) - dist(b))[0];
|
||||
const target = findCell(x1, y1);
|
||||
return {burgsByFeature, capitalsByFeature, portsByFeature};
|
||||
}
|
||||
|
||||
if (cells.f[target] === f && cells.h[target] < 20) {
|
||||
const [from, exit, passable] = findOceanPath(target, source, true);
|
||||
function generateMainRoads() {
|
||||
TIME && console.time("generateMainRoads");
|
||||
const mainRoads = [];
|
||||
|
||||
if (passable) {
|
||||
const path = restorePath(target, exit, "ocean", from);
|
||||
paths = paths.concat(path);
|
||||
last(path).push([x1, y1]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("generateSearoutes");
|
||||
return paths;
|
||||
};
|
||||
function findPathSegments({isWater, connections, start, exit}) {
|
||||
const from = findPath(isWater, start, exit, connections);
|
||||
if (!from) return [];
|
||||
|
||||
const draw = function (main, small, water) {
|
||||
TIME && console.time("drawRoutes");
|
||||
const {cells, burgs} = pack;
|
||||
const {burg, p} = cells;
|
||||
|
||||
const getBurgCoords = b => [burgs[b].x, burgs[b].y];
|
||||
const getPathPoints = cells => cells.map(i => (Array.isArray(i) ? i : burg[i] ? getBurgCoords(burg[i]) : p[i]));
|
||||
const getPath = segment => round(lineGen(getPathPoints(segment)), 1);
|
||||
const getPathsHTML = (paths, type) => paths.map((path, i) => `<path id="${type}${i}" d="${getPath(path)}" />`).join("");
|
||||
|
||||
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
|
||||
roads.html(getPathsHTML(main, "road"));
|
||||
trails.html(getPathsHTML(small, "trail"));
|
||||
|
||||
lineGen.curve(d3.curveBundle.beta(1));
|
||||
searoutes.html(getPathsHTML(water, "searoute"));
|
||||
|
||||
TIME && console.timeEnd("drawRoutes");
|
||||
};
|
||||
|
||||
const regenerate = function () {
|
||||
routes.selectAll("path").remove();
|
||||
pack.cells.road = new Uint16Array(pack.cells.i.length);
|
||||
pack.cells.crossroad = new Uint16Array(pack.cells.i.length);
|
||||
const main = getRoads();
|
||||
const small = getTrails();
|
||||
const water = getSearoutes();
|
||||
draw(main, small, water);
|
||||
};
|
||||
|
||||
return {getRoads, getTrails, getSearoutes, draw, regenerate};
|
||||
|
||||
// Find a land path to a specific cell (exit), to a closest road (toRoad), or to all reachable cells (null, null)
|
||||
function findLandPath(start, exit = null, toRoad = null) {
|
||||
const cells = pack.cells;
|
||||
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
|
||||
const cost = [],
|
||||
from = [];
|
||||
queue.queue({e: start, p: 0});
|
||||
|
||||
while (queue.length) {
|
||||
const next = queue.dequeue(),
|
||||
n = next.e,
|
||||
p = next.p;
|
||||
if (toRoad && cells.road[n]) return [from, n];
|
||||
|
||||
for (const c of cells.c[n]) {
|
||||
if (cells.h[c] < 20) continue; // ignore water cells
|
||||
const stateChangeCost = cells.state && cells.state[c] !== cells.state[n] ? 400 : 0; // trails tend to lay within the same state
|
||||
const habitability = biomesData.habitability[cells.biome[c]];
|
||||
if (!habitability) continue; // avoid inhabitable cells (eg. lava, glacier)
|
||||
const habitedCost = habitability ? Math.max(100 - habitability, 0) : 400; // routes tend to lay within populated areas
|
||||
const heightChangeCost = Math.abs(cells.h[c] - cells.h[n]) * 10; // routes tend to avoid elevation changes
|
||||
const heightCost = cells.h[c] > 80 ? cells.h[c] : 0; // routes tend to avoid mountainous areas
|
||||
const cellCoast = 10 + stateChangeCost + habitedCost + heightChangeCost + heightCost;
|
||||
const totalCost = p + (cells.road[c] || cells.burg[c] ? cellCoast / 3 : cellCoast);
|
||||
|
||||
if (from[c] || totalCost >= cost[c]) continue;
|
||||
from[c] = n;
|
||||
if (c === exit) return [from, exit];
|
||||
cost[c] = totalCost;
|
||||
queue.queue({e: c, p: totalCost});
|
||||
}
|
||||
const pathCells = restorePath(start, exit, from);
|
||||
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 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;
|
||||
}
|
||||
return [from, exit];
|
||||
}
|
||||
|
||||
function restorePath(start, end, type, from) {
|
||||
const cells = pack.cells;
|
||||
const path = []; // to store all segments;
|
||||
let segment = [],
|
||||
current = end,
|
||||
prev = end;
|
||||
const score = type === "main" ? 5 : 1; // to increase road score at cell
|
||||
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];
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "ocean" || !cells.road[prev]) segment.push(end);
|
||||
if (!cells.road[prev]) cells.road[prev] = score;
|
||||
function getPoints(group, cells, points) {
|
||||
const data = cells.map(cellId => [...points[cellId], cellId]);
|
||||
|
||||
for (let i = 0, limit = 1000; i < limit; i++) {
|
||||
if (!from[current]) break;
|
||||
current = from[current];
|
||||
// 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;
|
||||
|
||||
if (cells.road[current]) {
|
||||
if (segment.length) {
|
||||
segment.push(current);
|
||||
path.push(segment);
|
||||
if (segment[0] !== end) {
|
||||
cells.road[segment[0]] += score;
|
||||
cells.crossroad[segment[0]] += score;
|
||||
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 (current !== start) {
|
||||
cells.road[current] += score;
|
||||
cells.crossroad[current] += score;
|
||||
|
||||
if (findCell(newX, newY) === cellId) {
|
||||
data[i] = [newX, newY, cellId];
|
||||
points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes
|
||||
}
|
||||
}
|
||||
segment = [];
|
||||
prev = current;
|
||||
} else {
|
||||
if (prev) segment.push(prev);
|
||||
prev = null;
|
||||
segment.push(current);
|
||||
}
|
||||
|
||||
cells.road[current] += score;
|
||||
if (current === start) break;
|
||||
}
|
||||
|
||||
if (segment.length > 1) path.push(segment);
|
||||
return data; // [[x, y, cell], [x, y, cell]];
|
||||
}
|
||||
|
||||
const MIN_PASSABLE_SEA_TEMP = -4;
|
||||
const TYPE_MODIFIERS = {
|
||||
"-1": 1, // coastline
|
||||
"-2": 1.8, // sea
|
||||
"-3": 4, // open sea
|
||||
"-4": 6, // ocean
|
||||
default: 8 // far ocean
|
||||
};
|
||||
|
||||
function findPath(isWater, start, exit, connections) {
|
||||
const {temp} = grid.cells;
|
||||
const {cells} = pack;
|
||||
|
||||
const from = [];
|
||||
const cost = [];
|
||||
const queue = new FlatQueue();
|
||||
queue.push(start, 0);
|
||||
|
||||
return isWater ? findWaterPath() : findLandPath();
|
||||
|
||||
function findLandPath() {
|
||||
while (queue.length) {
|
||||
const priority = queue.peekValue();
|
||||
const next = queue.pop();
|
||||
|
||||
for (const neibCellId of cells.c[next]) {
|
||||
if (neibCellId === exit) {
|
||||
from[neibCellId] = next;
|
||||
return from;
|
||||
}
|
||||
|
||||
if (cells.h[neibCellId] < 20) continue; // ignore water cells
|
||||
const habitability = biomesData.habitability[cells.biome[neibCellId]];
|
||||
if (!habitability) continue; // inhabitable cells are not passable (eg. lava, glacier)
|
||||
|
||||
const distanceCost = dist2(cells.p[next], cells.p[neibCellId]);
|
||||
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
|
||||
const heightModifier = 1 + Math.max(cells.h[neibCellId] - 25, 25) / 25; // [1, 3];
|
||||
const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 2;
|
||||
const burgModifier = cells.burg[neibCellId] ? 1 : 3;
|
||||
|
||||
const cellsCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier;
|
||||
const totalCost = priority + cellsCost;
|
||||
|
||||
if (totalCost >= cost[neibCellId]) continue;
|
||||
from[neibCellId] = next;
|
||||
cost[neibCellId] = totalCost;
|
||||
queue.push(neibCellId, totalCost);
|
||||
}
|
||||
}
|
||||
|
||||
return null; // path is not found
|
||||
}
|
||||
|
||||
function findWaterPath() {
|
||||
while (queue.length) {
|
||||
const priority = queue.peekValue();
|
||||
const next = queue.pop();
|
||||
|
||||
for (const neibCellId of cells.c[next]) {
|
||||
if (neibCellId === exit) {
|
||||
from[neibCellId] = next;
|
||||
return from;
|
||||
}
|
||||
|
||||
if (cells.h[neibCellId] >= 20) continue; // ignore land cells
|
||||
if (temp[cells.g[neibCellId]] < MIN_PASSABLE_SEA_TEMP) continue; // ignore too cold cells
|
||||
|
||||
const distanceCost = dist2(cells.p[next], cells.p[neibCellId]);
|
||||
const typeModifier = TYPE_MODIFIERS[cells.t[neibCellId]] || TYPE_MODIFIERS.default;
|
||||
const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 2;
|
||||
|
||||
const cellsCost = distanceCost * typeModifier * connectionModifier;
|
||||
const totalCost = priority + cellsCost;
|
||||
|
||||
if (totalCost >= cost[neibCellId]) continue;
|
||||
from[neibCellId] = next;
|
||||
cost[neibCellId] = totalCost;
|
||||
queue.push(neibCellId, totalCost);
|
||||
}
|
||||
}
|
||||
|
||||
return null; // path is not found
|
||||
}
|
||||
}
|
||||
|
||||
function restorePath(start, end, from) {
|
||||
const cells = [];
|
||||
|
||||
let current = end;
|
||||
let prev = end;
|
||||
|
||||
while (current !== start) {
|
||||
cells.push(current);
|
||||
prev = from[current];
|
||||
current = prev;
|
||||
}
|
||||
|
||||
cells.push(current);
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (isConnected(cellId)) return;
|
||||
|
||||
const {cells, routes} = pack;
|
||||
|
||||
const path = findConnectionPath(cellId);
|
||||
if (!path) return;
|
||||
|
||||
const pathCells = restorePath(...path);
|
||||
const pointsArray = preparePointsArray();
|
||||
const points = getPoints("trails", pathCells, pointsArray);
|
||||
const feature = cells.f[cellId];
|
||||
|
||||
const routeId = Math.max(...routes.map(route => route.i)) + 1;
|
||||
const newRoute = {i: routeId, group: "trails", feature, points};
|
||||
routes.push(newRoute);
|
||||
|
||||
for (let i = 0; i < pathCells.length; i++) {
|
||||
const cellId = pathCells[i];
|
||||
const nextCellId = pathCells[i + 1];
|
||||
if (nextCellId) addConnection(cellId, nextCellId, routeId);
|
||||
}
|
||||
|
||||
return newRoute;
|
||||
|
||||
function findConnectionPath(start) {
|
||||
const from = [];
|
||||
const cost = [];
|
||||
const queue = new FlatQueue();
|
||||
queue.push(start, 0);
|
||||
|
||||
while (queue.length) {
|
||||
const priority = queue.peekValue();
|
||||
const next = queue.pop();
|
||||
|
||||
for (const neibCellId of cells.c[next]) {
|
||||
if (isConnected(neibCellId)) {
|
||||
from[neibCellId] = next;
|
||||
return [start, neibCellId, from];
|
||||
}
|
||||
|
||||
if (cells.h[neibCellId] < 20) continue; // ignore water cells
|
||||
const habitability = biomesData.habitability[cells.biome[neibCellId]];
|
||||
if (!habitability) continue; // inhabitable cells are not passable (eg. lava, glacier)
|
||||
|
||||
const distanceCost = dist2(cells.p[next], cells.p[neibCellId]);
|
||||
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
|
||||
const heightModifier = 1 + Math.max(cells.h[neibCellId] - 25, 25) / 25; // [1, 3];
|
||||
|
||||
const cellsCost = distanceCost * habitabilityModifier * heightModifier;
|
||||
const totalCost = priority + cellsCost;
|
||||
|
||||
if (totalCost >= cost[neibCellId]) continue;
|
||||
from[neibCellId] = next;
|
||||
cost[neibCellId] = totalCost;
|
||||
queue.push(neibCellId, totalCost);
|
||||
}
|
||||
}
|
||||
|
||||
return null; // path is not found
|
||||
}
|
||||
|
||||
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;
|
||||
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];
|
||||
return routeId === undefined ? null : pack.routes[routeId];
|
||||
}
|
||||
|
||||
function hasRoad(cellId) {
|
||||
const connections = pack.cells.routes[cellId];
|
||||
if (!connections) return false;
|
||||
return Object.values(connections).some(routeId => pack.routes[routeId].group === "roads");
|
||||
}
|
||||
|
||||
function isCrossroad(cellId) {
|
||||
const connections = pack.cells.routes[cellId];
|
||||
if (!connections) return false;
|
||||
return (
|
||||
Object.keys(connections).length > 3 ||
|
||||
Object.values(connections).filter(routeId => pack.routes[routeId].group === "roads").length > 2
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// find water paths
|
||||
function findOceanPath(start, exit = null, toRoute = null) {
|
||||
const cells = pack.cells,
|
||||
temp = grid.cells.temp;
|
||||
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
|
||||
const cost = [],
|
||||
from = [];
|
||||
queue.queue({e: start, p: 0});
|
||||
function getLength(routeId) {
|
||||
const path = routes.select("#route" + routeId).node();
|
||||
return path.getTotalLength();
|
||||
}
|
||||
|
||||
while (queue.length) {
|
||||
const next = queue.dequeue(),
|
||||
n = next.e,
|
||||
p = next.p;
|
||||
if (toRoute && n !== start && cells.road[n]) return [from, n, true];
|
||||
function remove(route) {
|
||||
const routes = pack.cells.routes;
|
||||
|
||||
for (const c of cells.c[n]) {
|
||||
if (c === exit) {
|
||||
from[c] = n;
|
||||
return [from, exit, true];
|
||||
for (const point of route.points) {
|
||||
const from = point[2];
|
||||
|
||||
for (const [to, routeId] of Object.entries(routes[from])) {
|
||||
if (routeId === route.i) {
|
||||
delete routes[from][to];
|
||||
delete routes[to][from];
|
||||
}
|
||||
if (cells.h[c] >= 20) continue; // ignore land cells
|
||||
if (temp[cells.g[c]] <= -5) continue; // ignore cells with term <= -5
|
||||
const dist2 = (cells.p[c][1] - cells.p[n][1]) ** 2 + (cells.p[c][0] - cells.p[n][0]) ** 2;
|
||||
const totalCost = p + (cells.road[c] ? 1 + dist2 / 2 : dist2 + (cells.t[c] ? 1 : 100));
|
||||
|
||||
if (from[c] || totalCost >= cost[c]) continue;
|
||||
(from[c] = n), (cost[c] = totalCost);
|
||||
queue.queue({e: c, p: totalCost});
|
||||
}
|
||||
}
|
||||
return [from, exit, false];
|
||||
|
||||
pack.routes = pack.routes.filter(r => r.i !== route.i);
|
||||
viewbox
|
||||
.select("#routes")
|
||||
.select("#route" + route.i)
|
||||
.remove();
|
||||
}
|
||||
|
||||
return {
|
||||
generate,
|
||||
connect,
|
||||
isConnected,
|
||||
areConnected,
|
||||
getRoute,
|
||||
hasRoad,
|
||||
isCrossroad,
|
||||
generateName,
|
||||
getPath,
|
||||
getLength,
|
||||
remove
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -145,8 +145,6 @@ window.Submap = (function () {
|
|||
cells.state = new Uint16Array(pn);
|
||||
cells.burg = new Uint16Array(pn);
|
||||
cells.religion = new Uint16Array(pn);
|
||||
cells.road = new Uint16Array(pn);
|
||||
cells.crossroad = new Uint16Array(pn);
|
||||
cells.province = new Uint16Array(pn);
|
||||
|
||||
stage("Resampling culture, state and religion map.");
|
||||
|
|
@ -272,8 +270,8 @@ window.Submap = (function () {
|
|||
|
||||
BurgsAndStates.drawBurgs();
|
||||
|
||||
stage("Regenerating road network.");
|
||||
Routes.regenerate();
|
||||
stage("Regenerating routes network.");
|
||||
regenerateRoutes();
|
||||
|
||||
drawStates();
|
||||
drawBorders();
|
||||
|
|
@ -397,7 +395,7 @@ window.Submap = (function () {
|
|||
return;
|
||||
}
|
||||
DEBUG && console.info(`Moving ${b.name} from ${cityCell} to ${newCell} near ${neighbor}.`);
|
||||
[b.x, b.y] = b.port ? getMiddlePoint(newCell, neighbor) : cells.p[newCell];
|
||||
[b.x, b.y] = b.port ? getCloseToEdgePoint(newCell, neighbor) : cells.p[newCell];
|
||||
if (b.port) b.port = cells.f[neighbor]; // copy feature number
|
||||
b.cell = newCell;
|
||||
if (b.port && !isWater(pack, neighbor)) console.error("betrayal! negihbor must be water!", b);
|
||||
|
|
@ -409,6 +407,23 @@ window.Submap = (function () {
|
|||
});
|
||||
}
|
||||
|
||||
function getCloseToEdgePoint(cell1, cell2) {
|
||||
const {cells, vertices} = pack;
|
||||
|
||||
const [x0, y0] = cells.p[cell1];
|
||||
|
||||
const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2));
|
||||
const [x1, y1] = vertices.p[commonVertices[0]];
|
||||
const [x2, y2] = vertices.p[commonVertices[1]];
|
||||
const xEdge = (x1 + x2) / 2;
|
||||
const yEdge = (y1 + y2) / 2;
|
||||
|
||||
const x = rn(x0 + 0.95 * (xEdge - x0), 2);
|
||||
const y = rn(y0 + 0.95 * (yEdge - y0), 2);
|
||||
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
// export
|
||||
return {resample, findNearest};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -37,12 +37,22 @@ class Battle {
|
|||
|
||||
// add listeners
|
||||
document.getElementById("battleType").addEventListener("click", ev => this.toggleChange(ev));
|
||||
document.getElementById("battleType").nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changeType(ev));
|
||||
document.getElementById("battleNameShow").addEventListener("click", () => Battle.prototype.context.showNameSection());
|
||||
document.getElementById("battleNamePlace").addEventListener("change", ev => (Battle.prototype.context.place = ev.target.value));
|
||||
document
|
||||
.getElementById("battleType")
|
||||
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changeType(ev));
|
||||
document
|
||||
.getElementById("battleNameShow")
|
||||
.addEventListener("click", () => Battle.prototype.context.showNameSection());
|
||||
document
|
||||
.getElementById("battleNamePlace")
|
||||
.addEventListener("change", ev => (Battle.prototype.context.place = ev.target.value));
|
||||
document.getElementById("battleNameFull").addEventListener("change", ev => Battle.prototype.context.changeName(ev));
|
||||
document.getElementById("battleNameCulture").addEventListener("click", () => Battle.prototype.context.generateName("culture"));
|
||||
document.getElementById("battleNameRandom").addEventListener("click", () => Battle.prototype.context.generateName("random"));
|
||||
document
|
||||
.getElementById("battleNameCulture")
|
||||
.addEventListener("click", () => Battle.prototype.context.generateName("culture"));
|
||||
document
|
||||
.getElementById("battleNameRandom")
|
||||
.addEventListener("click", () => Battle.prototype.context.generateName("random"));
|
||||
document.getElementById("battleNameHide").addEventListener("click", this.hideNameSection);
|
||||
document.getElementById("battleAddRegiment").addEventListener("click", this.addSide);
|
||||
document.getElementById("battleRoll").addEventListener("click", () => Battle.prototype.context.randomize());
|
||||
|
|
@ -52,11 +62,19 @@ class Battle {
|
|||
document.getElementById("battleWiki").addEventListener("click", () => wiki("Battle-Simulator"));
|
||||
|
||||
document.getElementById("battlePhase_attackers").addEventListener("click", ev => this.toggleChange(ev));
|
||||
document.getElementById("battlePhase_attackers").nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "attackers"));
|
||||
document
|
||||
.getElementById("battlePhase_attackers")
|
||||
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "attackers"));
|
||||
document.getElementById("battlePhase_defenders").addEventListener("click", ev => this.toggleChange(ev));
|
||||
document.getElementById("battlePhase_defenders").nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "defenders"));
|
||||
document.getElementById("battleDie_attackers").addEventListener("click", () => Battle.prototype.context.rollDie("attackers"));
|
||||
document.getElementById("battleDie_defenders").addEventListener("click", () => Battle.prototype.context.rollDie("defenders"));
|
||||
document
|
||||
.getElementById("battlePhase_defenders")
|
||||
.nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "defenders"));
|
||||
document
|
||||
.getElementById("battleDie_attackers")
|
||||
.addEventListener("click", () => Battle.prototype.context.rollDie("attackers"));
|
||||
document
|
||||
.getElementById("battleDie_defenders")
|
||||
.addEventListener("click", () => Battle.prototype.context.rollDie("defenders"));
|
||||
}
|
||||
|
||||
defineType() {
|
||||
|
|
@ -82,8 +100,12 @@ class Battle {
|
|||
document.getElementById("battleType").className = "icon-button-" + this.type;
|
||||
|
||||
const sideSpecific = document.getElementById("battlePhases_" + this.type + "_attackers");
|
||||
const attackers = sideSpecific ? sideSpecific.content : document.getElementById("battlePhases_" + this.type).content;
|
||||
const defenders = sideSpecific ? document.getElementById("battlePhases_" + this.type + "_defenders").content : attackers;
|
||||
const attackers = sideSpecific
|
||||
? sideSpecific.content
|
||||
: document.getElementById("battlePhases_" + this.type).content;
|
||||
const defenders = sideSpecific
|
||||
? document.getElementById("battlePhases_" + this.type + "_defenders").content
|
||||
: attackers;
|
||||
|
||||
document.getElementById("battlePhase_attackers").nextElementSibling.innerHTML = "";
|
||||
document.getElementById("battlePhase_defenders").nextElementSibling.innerHTML = "";
|
||||
|
|
@ -139,26 +161,37 @@ class Battle {
|
|||
regiment.survivors = Object.assign({}, regiment.u);
|
||||
|
||||
const state = pack.states[regiment.state];
|
||||
const distance = (Math.hypot(this.y - regiment.by, this.x - regiment.bx) * distanceScaleInput.value) | 0; // distance between regiment and its base
|
||||
const distance = (Math.hypot(this.y - regiment.by, this.x - regiment.bx) * distanceScale) | 0; // distance between regiment and its base
|
||||
const color = state.color[0] === "#" ? state.color : "#999";
|
||||
const icon = `<svg width="1.4em" height="1.4em" style="margin-bottom: -.6em; stroke: #333">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="${color}"></rect>
|
||||
<text x="0" y="1.04em" style="">${regiment.icon}</text></svg>`;
|
||||
const body = `<tbody id="battle${state.i}-${regiment.i}">`;
|
||||
|
||||
let initial = `<tr class="battleInitial"><td>${icon}</td><td class="regiment" data-tip="${regiment.name}">${regiment.name.slice(0, 24)}</td>`;
|
||||
let casualties = `<tr class="battleCasualties"><td></td><td data-tip="${state.fullName}">${state.fullName.slice(0, 26)}</td>`;
|
||||
let initial = `<tr class="battleInitial"><td>${icon}</td><td class="regiment" data-tip="${
|
||||
regiment.name
|
||||
}">${regiment.name.slice(0, 24)}</td>`;
|
||||
let casualties = `<tr class="battleCasualties"><td></td><td data-tip="${state.fullName}">${state.fullName.slice(
|
||||
0,
|
||||
26
|
||||
)}</td>`;
|
||||
let survivors = `<tr class="battleSurvivors"><td></td><td data-tip="Supply line length, affects morale">Distance to base: ${distance} ${distanceUnitInput.value}</td>`;
|
||||
|
||||
for (const u of options.military) {
|
||||
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">${regiment.u[u.name] || 0}</td>`;
|
||||
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">${
|
||||
regiment.u[u.name] || 0
|
||||
}</td>`;
|
||||
casualties += `<td data-tip="Casualties" style="width: 2.5em; text-align: center; color: red">0</td>`;
|
||||
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${regiment.u[u.name] || 0}</td>`;
|
||||
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${
|
||||
regiment.u[u.name] || 0
|
||||
}</td>`;
|
||||
}
|
||||
|
||||
initial += `<td data-tip="Initial forces" style="width: 2.5em; text-align: center">${regiment.a || 0}</td></tr>`;
|
||||
casualties += `<td data-tip="Casualties" style="width: 2.5em; text-align: center; color: red">0</td></tr>`;
|
||||
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${regiment.a || 0}</td></tr>`;
|
||||
survivors += `<td data-tip="Survivors" style="width: 2.5em; text-align: center; color: green">${
|
||||
regiment.a || 0
|
||||
}</td></tr>`;
|
||||
|
||||
const div = side === "attackers" ? battleAttackers : battleDefenders;
|
||||
div.innerHTML += body + initial + casualties + survivors + "</tbody>";
|
||||
|
|
@ -173,17 +206,23 @@ class Battle {
|
|||
.filter(s => s.military && !s.removed)
|
||||
.map(s => s.military)
|
||||
.flat();
|
||||
const distance = reg => rn(Math.hypot(context.y - reg.y, context.x - reg.x) * distanceScaleInput.value) + " " + distanceUnitInput.value;
|
||||
const isAdded = reg => context.defenders.regiments.some(r => r === reg) || context.attackers.regiments.some(r => r === reg);
|
||||
const distance = reg =>
|
||||
rn(Math.hypot(context.y - reg.y, context.x - reg.x) * distanceScale) + " " + distanceUnitInput.value;
|
||||
const isAdded = reg =>
|
||||
context.defenders.regiments.some(r => r === reg) || context.attackers.regiments.some(r => r === reg);
|
||||
|
||||
body.innerHTML = regiments
|
||||
.map(r => {
|
||||
const s = pack.states[r.state],
|
||||
added = isAdded(r),
|
||||
dist = added ? "0 " + distanceUnitInput.value : distance(r);
|
||||
return `<div ${added ? "class='inactive'" : ""} data-s=${s.i} data-i=${r.i} data-state=${s.name} data-regiment=${r.name}
|
||||
return `<div ${added ? "class='inactive'" : ""} data-s=${s.i} data-i=${r.i} data-state=${
|
||||
s.name
|
||||
} data-regiment=${r.name}
|
||||
data-total=${r.a} data-distance=${dist} data-tip="Click to select regiment">
|
||||
<svg width=".9em" height=".9em" style="margin-bottom:-1px; stroke: #333"><rect x="0" y="0" width="100%" height="100%" fill="${s.color}" ></svg>
|
||||
<svg width=".9em" height=".9em" style="margin-bottom:-1px; stroke: #333"><rect x="0" y="0" width="100%" height="100%" fill="${
|
||||
s.color
|
||||
}" ></svg>
|
||||
<div style="width:6em">${s.name.slice(0, 11)}</div>
|
||||
<div style="width:1.2em">${r.icon}</div>
|
||||
<div style="width:13em">${r.name.slice(0, 24)}</div>
|
||||
|
|
@ -267,7 +306,10 @@ class Battle {
|
|||
}
|
||||
|
||||
generateName(type) {
|
||||
const place = type === "culture" ? Names.getCulture(pack.cells.culture[this.cell], null, null, "") : Names.getBase(rand(nameBases.length - 1));
|
||||
const place =
|
||||
type === "culture"
|
||||
? Names.getCulture(pack.cells.culture[this.cell], null, null, "")
|
||||
: Names.getBase(rand(nameBases.length - 1));
|
||||
document.getElementById("battleNamePlace").value = this.place = place;
|
||||
document.getElementById("battleNameFull").value = this.name = this.defineName();
|
||||
$("#battleScreen").dialog({title: this.name});
|
||||
|
|
@ -286,35 +328,161 @@ class Battle {
|
|||
calculateStrength(side) {
|
||||
const scheme = {
|
||||
// field battle phases
|
||||
skirmish: {melee: 0.2, ranged: 2.4, mounted: 0.1, machinery: 3, naval: 1, armored: 0.2, aviation: 1.8, magical: 1.8}, // ranged excel
|
||||
skirmish: {
|
||||
melee: 0.2,
|
||||
ranged: 2.4,
|
||||
mounted: 0.1,
|
||||
machinery: 3,
|
||||
naval: 1,
|
||||
armored: 0.2,
|
||||
aviation: 1.8,
|
||||
magical: 1.8
|
||||
}, // ranged excel
|
||||
melee: {melee: 2, ranged: 1.2, mounted: 1.5, machinery: 0.5, naval: 0.2, armored: 2, aviation: 0.8, magical: 0.8}, // melee excel
|
||||
pursue: {melee: 1, ranged: 1, mounted: 4, machinery: 0.05, naval: 1, armored: 1, aviation: 1.5, magical: 0.6}, // mounted excel
|
||||
retreat: {melee: 0.1, ranged: 0.01, mounted: 0.5, machinery: 0.01, naval: 0.2, armored: 0.1, aviation: 0.8, magical: 0.05}, // reduced
|
||||
retreat: {
|
||||
melee: 0.1,
|
||||
ranged: 0.01,
|
||||
mounted: 0.5,
|
||||
machinery: 0.01,
|
||||
naval: 0.2,
|
||||
armored: 0.1,
|
||||
aviation: 0.8,
|
||||
magical: 0.05
|
||||
}, // reduced
|
||||
|
||||
// naval battle phases
|
||||
shelling: {melee: 0, ranged: 0.2, mounted: 0, machinery: 2, naval: 2, armored: 0, aviation: 0.1, magical: 0.5}, // naval and machinery excel
|
||||
boarding: {melee: 1, ranged: 0.5, mounted: 0.5, machinery: 0, naval: 0.5, armored: 0.4, aviation: 0, magical: 0.2}, // melee excel
|
||||
boarding: {
|
||||
melee: 1,
|
||||
ranged: 0.5,
|
||||
mounted: 0.5,
|
||||
machinery: 0,
|
||||
naval: 0.5,
|
||||
armored: 0.4,
|
||||
aviation: 0,
|
||||
magical: 0.2
|
||||
}, // melee excel
|
||||
chase: {melee: 0, ranged: 0.15, mounted: 0, machinery: 1, naval: 1, armored: 0, aviation: 0.15, magical: 0.5}, // reduced
|
||||
withdrawal: {melee: 0, ranged: 0.02, mounted: 0, machinery: 0.5, naval: 0.1, armored: 0, aviation: 0.1, magical: 0.3}, // reduced
|
||||
withdrawal: {
|
||||
melee: 0,
|
||||
ranged: 0.02,
|
||||
mounted: 0,
|
||||
machinery: 0.5,
|
||||
naval: 0.1,
|
||||
armored: 0,
|
||||
aviation: 0.1,
|
||||
magical: 0.3
|
||||
}, // reduced
|
||||
|
||||
// siege phases
|
||||
blockade: {melee: 0.25, ranged: 0.25, mounted: 0.2, machinery: 0.5, naval: 0.2, armored: 0.1, aviation: 0.25, magical: 0.25}, // no active actions
|
||||
sheltering: {melee: 0.3, ranged: 0.5, mounted: 0.2, machinery: 0.5, naval: 0.2, armored: 0.1, aviation: 0.25, magical: 0.25}, // no active actions
|
||||
blockade: {
|
||||
melee: 0.25,
|
||||
ranged: 0.25,
|
||||
mounted: 0.2,
|
||||
machinery: 0.5,
|
||||
naval: 0.2,
|
||||
armored: 0.1,
|
||||
aviation: 0.25,
|
||||
magical: 0.25
|
||||
}, // no active actions
|
||||
sheltering: {
|
||||
melee: 0.3,
|
||||
ranged: 0.5,
|
||||
mounted: 0.2,
|
||||
machinery: 0.5,
|
||||
naval: 0.2,
|
||||
armored: 0.1,
|
||||
aviation: 0.25,
|
||||
magical: 0.25
|
||||
}, // no active actions
|
||||
sortie: {melee: 2, ranged: 0.5, mounted: 1.2, machinery: 0.2, naval: 0.1, armored: 0.5, aviation: 1, magical: 1}, // melee excel
|
||||
bombardment: {melee: 0.2, ranged: 0.5, mounted: 0.2, machinery: 3, naval: 1, armored: 0.5, aviation: 1, magical: 1}, // machinery excel
|
||||
storming: {melee: 1, ranged: 0.6, mounted: 0.5, machinery: 1, naval: 0.1, armored: 0.1, aviation: 0.5, magical: 0.5}, // melee excel
|
||||
bombardment: {
|
||||
melee: 0.2,
|
||||
ranged: 0.5,
|
||||
mounted: 0.2,
|
||||
machinery: 3,
|
||||
naval: 1,
|
||||
armored: 0.5,
|
||||
aviation: 1,
|
||||
magical: 1
|
||||
}, // machinery excel
|
||||
storming: {
|
||||
melee: 1,
|
||||
ranged: 0.6,
|
||||
mounted: 0.5,
|
||||
machinery: 1,
|
||||
naval: 0.1,
|
||||
armored: 0.1,
|
||||
aviation: 0.5,
|
||||
magical: 0.5
|
||||
}, // melee excel
|
||||
defense: {melee: 2, ranged: 3, mounted: 1, machinery: 1, naval: 0.1, armored: 1, aviation: 0.5, magical: 1}, // ranged excel
|
||||
looting: {melee: 1.6, ranged: 1.6, mounted: 0.5, machinery: 0.2, naval: 0.02, armored: 0.2, aviation: 0.1, magical: 0.3}, // melee excel
|
||||
surrendering: {melee: 0.1, ranged: 0.1, mounted: 0.05, machinery: 0.01, naval: 0.01, armored: 0.02, aviation: 0.01, magical: 0.03}, // reduced
|
||||
looting: {
|
||||
melee: 1.6,
|
||||
ranged: 1.6,
|
||||
mounted: 0.5,
|
||||
machinery: 0.2,
|
||||
naval: 0.02,
|
||||
armored: 0.2,
|
||||
aviation: 0.1,
|
||||
magical: 0.3
|
||||
}, // melee excel
|
||||
surrendering: {
|
||||
melee: 0.1,
|
||||
ranged: 0.1,
|
||||
mounted: 0.05,
|
||||
machinery: 0.01,
|
||||
naval: 0.01,
|
||||
armored: 0.02,
|
||||
aviation: 0.01,
|
||||
magical: 0.03
|
||||
}, // reduced
|
||||
|
||||
// ambush phases
|
||||
surprise: {melee: 2, ranged: 2.4, mounted: 1, machinery: 1, naval: 1, armored: 1, aviation: 0.8, magical: 1.2}, // increased
|
||||
shock: {melee: 0.5, ranged: 0.5, mounted: 0.5, machinery: 0.4, naval: 0.3, armored: 0.1, aviation: 0.4, magical: 0.5}, // reduced
|
||||
shock: {
|
||||
melee: 0.5,
|
||||
ranged: 0.5,
|
||||
mounted: 0.5,
|
||||
machinery: 0.4,
|
||||
naval: 0.3,
|
||||
armored: 0.1,
|
||||
aviation: 0.4,
|
||||
magical: 0.5
|
||||
}, // reduced
|
||||
|
||||
// langing phases
|
||||
landing: {melee: 0.8, ranged: 0.6, mounted: 0.6, machinery: 0.5, naval: 0.5, armored: 0.5, aviation: 0.5, magical: 0.6}, // reduced
|
||||
flee: {melee: 0.1, ranged: 0.01, mounted: 0.5, machinery: 0.01, naval: 0.5, armored: 0.1, aviation: 0.2, magical: 0.05}, // reduced
|
||||
waiting: {melee: 0.05, ranged: 0.5, mounted: 0.05, machinery: 0.5, naval: 2, armored: 0.05, aviation: 0.5, magical: 0.5}, // reduced
|
||||
landing: {
|
||||
melee: 0.8,
|
||||
ranged: 0.6,
|
||||
mounted: 0.6,
|
||||
machinery: 0.5,
|
||||
naval: 0.5,
|
||||
armored: 0.5,
|
||||
aviation: 0.5,
|
||||
magical: 0.6
|
||||
}, // reduced
|
||||
flee: {
|
||||
melee: 0.1,
|
||||
ranged: 0.01,
|
||||
mounted: 0.5,
|
||||
machinery: 0.01,
|
||||
naval: 0.5,
|
||||
armored: 0.1,
|
||||
aviation: 0.2,
|
||||
magical: 0.05
|
||||
}, // reduced
|
||||
waiting: {
|
||||
melee: 0.05,
|
||||
ranged: 0.5,
|
||||
mounted: 0.05,
|
||||
machinery: 0.5,
|
||||
naval: 2,
|
||||
armored: 0.05,
|
||||
aviation: 0.5,
|
||||
magical: 0.5
|
||||
}, // reduced
|
||||
|
||||
// air battle phases
|
||||
maneuvering: {melee: 0, ranged: 0.1, mounted: 0, machinery: 0.2, naval: 0, armored: 0, aviation: 1, magical: 0.2}, // aviation
|
||||
|
|
@ -324,7 +492,8 @@ class Battle {
|
|||
const forces = this.getJoinedForces(this[side].regiments);
|
||||
const phase = this[side].phase;
|
||||
const adjuster = Math.max(populationRate / 10, 10); // population adjuster, by default 100
|
||||
this[side].power = d3.sum(options.military.map(u => (forces[u.name] || 0) * u.power * scheme[phase][u.type])) / adjuster;
|
||||
this[side].power =
|
||||
d3.sum(options.military.map(u => (forces[u.name] || 0) * u.power * scheme[phase][u.type])) / adjuster;
|
||||
const UIvalue = this[side].power ? Math.max(this[side].power | 0, 1) : 0;
|
||||
document.getElementById("battlePower_" + side).innerHTML = UIvalue;
|
||||
}
|
||||
|
|
@ -723,11 +892,13 @@ class Battle {
|
|||
|
||||
const status = battleStatus[+P(0.7)];
|
||||
const result = `The ${this.getTypeName(this.type)} ended in ${status}`;
|
||||
const legend = `${this.name} took place in ${options.year} ${options.eraShort}. It was fought between ${getSide(this.attackers.regiments, 1)} and ${getSide(
|
||||
this.defenders.regiments,
|
||||
0
|
||||
)}. ${result}.
|
||||
\r\nAttackers losses: ${getLosses(this.attackers.casualties)}%, defenders losses: ${getLosses(this.defenders.casualties)}%`;
|
||||
const legend = `${this.name} took place in ${options.year} ${options.eraShort}. It was fought between ${getSide(
|
||||
this.attackers.regiments,
|
||||
1
|
||||
)} and ${getSide(this.defenders.regiments, 0)}. ${result}.
|
||||
\r\nAttackers losses: ${getLosses(this.attackers.casualties)}%, defenders losses: ${getLosses(
|
||||
this.defenders.casualties
|
||||
)}%`;
|
||||
notes.push({id: `marker${i}`, name: this.name, legend});
|
||||
|
||||
tip(`${this.name} is over. ${result}`, true, "success", 4000);
|
||||
|
|
|
|||
|
|
@ -390,7 +390,7 @@ function editBiomes() {
|
|||
const p = d3.mouse(this);
|
||||
moveCircle(p[0], p[1], r);
|
||||
|
||||
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
|
||||
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])];
|
||||
const selection = found.filter(isLand);
|
||||
if (selection) changeBiomeForSelection(selection);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
|
|||
});
|
||||
byId("burgsLockAll").addEventListener("click", toggleLockAll);
|
||||
byId("burgsRemoveAll").addEventListener("click", triggerAllBurgsRemove);
|
||||
byId("burgsInvertLock").addEventListener("click", invertLock);
|
||||
|
||||
function refreshBurgsEditor() {
|
||||
updateFilter();
|
||||
|
|
@ -279,7 +278,8 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
|
|||
|
||||
function addBurgOnClick() {
|
||||
const point = d3.mouse(this);
|
||||
const cell = findCell(point[0], point[1]);
|
||||
const cell = findCell(...point);
|
||||
|
||||
if (pack.cells.h[cell] < 20)
|
||||
return tip("You cannot place state into the water. Please click on a land cell", false, "error");
|
||||
if (pack.cells.burg[cell])
|
||||
|
|
@ -603,11 +603,6 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
|
|||
burgsOverviewAddLines();
|
||||
}
|
||||
|
||||
function invertLock() {
|
||||
pack.burgs = pack.burgs.map(burg => ({...burg, lock: !burg.lock}));
|
||||
burgsOverviewAddLines();
|
||||
}
|
||||
|
||||
function toggleLockAll() {
|
||||
const activeBurgs = pack.burgs.filter(b => b.i && !b.removed);
|
||||
const allLocked = activeBurgs.every(burg => burg.lock);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ function clicked() {
|
|||
|
||||
if (grand.id === "emblems") editEmblem();
|
||||
else if (parent.id === "rivers") editRiver(el.id);
|
||||
else if (grand.id === "routes") editRoute();
|
||||
else if (grand.id === "routes") editRoute(el.id);
|
||||
else if (el.tagName === "tspan" && grand.parentNode.parentNode.id === "labels") editLabel();
|
||||
else if (grand.id === "burgLabels") editBurg();
|
||||
else if (grand.id === "burgIcons") editBurg();
|
||||
|
|
@ -132,27 +132,43 @@ function applySorting(headers) {
|
|||
}
|
||||
|
||||
function addBurg(point) {
|
||||
const cells = pack.cells;
|
||||
const x = rn(point[0], 2),
|
||||
y = rn(point[1], 2);
|
||||
const cell = findCell(x, point[1]);
|
||||
const i = pack.burgs.length;
|
||||
const culture = cells.culture[cell];
|
||||
const name = Names.getCulture(culture);
|
||||
const state = cells.state[cell];
|
||||
const feature = cells.f[cell];
|
||||
const {cells, states} = pack;
|
||||
const x = rn(point[0], 2);
|
||||
const y = rn(point[1], 2);
|
||||
|
||||
const temple = pack.states[state].form === "Theocracy";
|
||||
const population = Math.max((cells.s[cell] + cells.road[cell]) / 3 + i / 1000 + (cell % 100) / 1000, 0.1);
|
||||
const type = BurgsAndStates.getType(cell, false);
|
||||
const cellId = findCell(x, y);
|
||||
const i = pack.burgs.length;
|
||||
const culture = cells.culture[cellId];
|
||||
const name = Names.getCulture(culture);
|
||||
const state = cells.state[cellId];
|
||||
const feature = cells.f[cellId];
|
||||
|
||||
const population = Math.max(cells.s[cellId] / 3 + i / 1000 + (cellId % 100) / 1000, 0.1);
|
||||
const type = BurgsAndStates.getType(cellId, false);
|
||||
|
||||
// generate emblem
|
||||
const coa = COA.generate(pack.states[state].coa, 0.25, null, type);
|
||||
const coa = COA.generate(states[state].coa, 0.25, null, type);
|
||||
coa.shield = COA.getShield(culture, state);
|
||||
COArenderer.add("burg", i, coa, x, y);
|
||||
|
||||
pack.burgs.push({name, cell, x, y, state, i, culture, feature, capital: 0, port: 0, temple, population, coa, type});
|
||||
cells.burg[cell] = i;
|
||||
const burg = {
|
||||
name,
|
||||
cell: cellId,
|
||||
x,
|
||||
y,
|
||||
state,
|
||||
i,
|
||||
culture,
|
||||
feature,
|
||||
capital: 0,
|
||||
port: 0,
|
||||
temple: 0,
|
||||
population,
|
||||
coa,
|
||||
type
|
||||
};
|
||||
pack.burgs.push(burg);
|
||||
cells.burg[cellId] = i;
|
||||
|
||||
const townSize = burgIcons.select("#towns").attr("size") || 0.5;
|
||||
burgIcons
|
||||
|
|
@ -173,7 +189,17 @@ function addBurg(point) {
|
|||
.attr("dy", `${townSize * -1.5}px`)
|
||||
.text(name);
|
||||
|
||||
BurgsAndStates.defineBurgFeatures(pack.burgs[i]);
|
||||
BurgsAndStates.defineBurgFeatures(burg);
|
||||
|
||||
const newRoute = Routes.connect(cellId);
|
||||
if (newRoute && layerIsOn("toggleRoutes")) {
|
||||
routes
|
||||
.select("#" + newRoute.group)
|
||||
.append("path")
|
||||
.attr("d", Routes.getPath(newRoute))
|
||||
.attr("id", "route" + newRoute.i);
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
|
|
@ -327,8 +353,7 @@ function createMfcgLink(burg) {
|
|||
const citadel = +burg.citadel;
|
||||
const urban_castle = +(citadel && each(2)(i));
|
||||
|
||||
const hub = +cells.road[cell] > 50;
|
||||
|
||||
const hub = Routes.isCrossroad(cell);
|
||||
const walls = +burg.walls;
|
||||
const plaza = +burg.plaza;
|
||||
const temple = +burg.temple;
|
||||
|
|
@ -372,10 +397,12 @@ function createVillageGeneratorLink(burg) {
|
|||
else if (cells.r[cell]) tags.push("river");
|
||||
else if (pop < 200 && each(4)(cell)) tags.push("pond");
|
||||
|
||||
const roadsAround = cells.c[cell].filter(c => cells.h[c] >= 20 && cells.road[c]).length;
|
||||
if (roadsAround > 1) tags.push("highway");
|
||||
else if (roadsAround === 1) tags.push("dead end");
|
||||
else tags.push("isolated");
|
||||
const connections = pack.cells.routes[cell] || {};
|
||||
const roads = Object.values(connections).filter(routeId => {
|
||||
const route = pack.routes[routeId];
|
||||
return route.group === "roads" || route.group === "trails";
|
||||
}).length;
|
||||
tags.push(roads > 1 ? "highway" : roads === 1 ? "dead end" : "isolated");
|
||||
|
||||
const biome = cells.biome[cell];
|
||||
const arableBiomes = cells.r[cell] ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8];
|
||||
|
|
@ -1174,7 +1201,6 @@ function getAreaUnit(squareMark = "²") {
|
|||
}
|
||||
|
||||
function getArea(rawArea) {
|
||||
const distanceScale = byId("distanceScaleInput")?.value;
|
||||
return rawArea * distanceScale ** 2;
|
||||
}
|
||||
|
||||
|
|
@ -1225,7 +1251,7 @@ function refreshAllEditors() {
|
|||
// dynamically loaded editors
|
||||
async function editStates() {
|
||||
if (customization) return;
|
||||
const Editor = await import("../dynamic/editors/states-editor.js?v=1.97.06");
|
||||
const Editor = await import("../dynamic/editors/states-editor.js?v=1.99.00");
|
||||
Editor.open();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,43 +1,14 @@
|
|||
"use strict";
|
||||
|
||||
function showEPForRoute(node) {
|
||||
const points = [];
|
||||
debug
|
||||
.select("#controlPoints")
|
||||
.selectAll("circle")
|
||||
.each(function () {
|
||||
const i = findCell(this.getAttribute("cx"), this.getAttribute("cy"));
|
||||
points.push(i);
|
||||
});
|
||||
|
||||
const routeLen = node.getTotalLength() * distanceScaleInput.value;
|
||||
showElevationProfile(points, routeLen, false);
|
||||
}
|
||||
|
||||
function showEPForRiver(node) {
|
||||
const points = [];
|
||||
debug
|
||||
.select("#controlPoints")
|
||||
.selectAll("circle")
|
||||
.each(function () {
|
||||
const i = findCell(this.getAttribute("cx"), this.getAttribute("cy"));
|
||||
points.push(i);
|
||||
});
|
||||
|
||||
const riverLen = (node.getTotalLength() / 2) * distanceScaleInput.value;
|
||||
showElevationProfile(points, riverLen, true);
|
||||
}
|
||||
|
||||
// data is an array of cell indexes, routeLen is the distance (in actual metres/feet), isRiver should be true for rivers, false otherwise
|
||||
function showElevationProfile(data, routeLen, isRiver) {
|
||||
// data is an array of cell indexes, routeLen is the distance (in actual metres/feet), isRiver should be true for rivers, false otherwise
|
||||
document.getElementById("epScaleRange").addEventListener("change", draw);
|
||||
document.getElementById("epCurve").addEventListener("change", draw);
|
||||
document.getElementById("epSave").addEventListener("click", downloadCSV);
|
||||
byId("epScaleRange").on("change", draw);
|
||||
byId("epCurve").on("change", draw);
|
||||
byId("epSave").on("click", downloadCSV);
|
||||
|
||||
$("#elevationProfile").dialog({
|
||||
title: "Elevation profile",
|
||||
resizable: false,
|
||||
width: window.width,
|
||||
close: closeElevationProfile,
|
||||
position: {my: "left top", at: "left+20 bottom-500", of: window, collision: "fit"}
|
||||
});
|
||||
|
|
@ -45,18 +16,20 @@ function showElevationProfile(data, routeLen, isRiver) {
|
|||
// prevent river graphs from showing rivers as flowing uphill - remember the general slope
|
||||
let slope = 0;
|
||||
if (isRiver) {
|
||||
if (pack.cells.h[data[0]] < pack.cells.h[data[data.length - 1]]) {
|
||||
const firstCellHeight = pack.cells.h[data.at(0)];
|
||||
const lastCellHeight = pack.cells.h[data.at(-1)];
|
||||
if (firstCellHeight < lastCellHeight) {
|
||||
slope = 1; // up-hill
|
||||
} else if (pack.cells.h[data[0]] > pack.cells.h[data[data.length - 1]]) {
|
||||
} else if (firstCellHeight > lastCellHeight) {
|
||||
slope = -1; // down-hill
|
||||
}
|
||||
}
|
||||
|
||||
const chartWidth = window.innerWidth - 180,
|
||||
chartHeight = 300; // height of our land/sea profile, excluding the biomes data below
|
||||
const xOffset = 80,
|
||||
yOffset = 80; // this is our drawing starting point from top-left (y = 0) of SVG
|
||||
const biomesHeight = 40;
|
||||
const chartWidth = window.innerWidth - 200;
|
||||
const chartHeight = 300;
|
||||
const xOffset = 80;
|
||||
const yOffset = 80;
|
||||
const biomesHeight = 10;
|
||||
|
||||
let lastBurgIndex = 0;
|
||||
let lastBurgCell = 0;
|
||||
|
|
@ -174,7 +147,7 @@ function showElevationProfile(data, routeLen, isRiver) {
|
|||
chartData.points.push([xscale(i) + xOffset, yscale(chartData.height[i]) + yOffset]);
|
||||
}
|
||||
|
||||
document.getElementById("elevationGraph").innerHTML = "";
|
||||
byId("elevationGraph").innerHTML = "";
|
||||
|
||||
const chart = d3
|
||||
.select("#elevationGraph")
|
||||
|
|
@ -313,7 +286,7 @@ function showElevationProfile(data, routeLen, isRiver) {
|
|||
.attr("x", x)
|
||||
.attr("y", y)
|
||||
.attr("width", xscale(1))
|
||||
.attr("height", 15)
|
||||
.attr("height", biomesHeight)
|
||||
.attr("data-tip", dataTip);
|
||||
}
|
||||
|
||||
|
|
@ -388,7 +361,7 @@ function showElevationProfile(data, routeLen, isRiver) {
|
|||
.attr("x", x1)
|
||||
.attr("y", y1)
|
||||
.attr("text-anchor", "middle");
|
||||
document.getElementById("ep" + b).innerHTML = pack.burgs[b].name;
|
||||
byId("ep" + b).innerHTML = pack.burgs[b].name;
|
||||
|
||||
// arrow from burg name to graph line
|
||||
g.append("path")
|
||||
|
|
@ -413,10 +386,10 @@ function showElevationProfile(data, routeLen, isRiver) {
|
|||
}
|
||||
|
||||
function closeElevationProfile() {
|
||||
document.getElementById("epScaleRange").removeEventListener("change", draw);
|
||||
document.getElementById("epCurve").removeEventListener("change", draw);
|
||||
document.getElementById("epSave").removeEventListener("click", downloadCSV);
|
||||
document.getElementById("elevationGraph").innerHTML = "";
|
||||
byId("epScaleRange").removeEventListener("change", draw);
|
||||
byId("epCurve").removeEventListener("change", draw);
|
||||
byId("epSave").removeEventListener("click", downloadCSV);
|
||||
byId("elevationGraph").innerHTML = "";
|
||||
modules.elevation = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,9 +67,9 @@ function showDataTip(event) {
|
|||
function showElementLockTip(event) {
|
||||
const locked = event?.target?.classList?.contains("icon-lock");
|
||||
if (locked) {
|
||||
tip("Click to unlock the element and allow it to be changed by regeneration tools");
|
||||
tip("Locked. Click to unlock the element and allow it to be changed by regeneration tools");
|
||||
} else {
|
||||
tip("Click to lock the element and prevent changes to it by regeneration tools");
|
||||
tip("Unlocked. Click to lock the element and prevent changes to it by regeneration tools");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +151,12 @@ function showMapTooltip(point, e, i, g) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (group === "routes") return tip("Click to edit the Route");
|
||||
if (group === "routes") {
|
||||
const routeId = +e.target.id.slice(5);
|
||||
const name = pack.routes[routeId]?.name;
|
||||
if (name) return tip(`${name}. Click to edit the Route`);
|
||||
return tip("Click to edit the Route");
|
||||
}
|
||||
|
||||
if (group === "terrain") return tip("Click to edit the Relief Icon");
|
||||
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ function editHeightmap(options) {
|
|||
Cultures.expand();
|
||||
|
||||
BurgsAndStates.generate();
|
||||
Routes.generate();
|
||||
Religions.generate();
|
||||
BurgsAndStates.defineStateForms();
|
||||
BurgsAndStates.generateProvinces();
|
||||
|
|
@ -281,8 +282,7 @@ function editHeightmap(options) {
|
|||
const l = grid.cells.i.length;
|
||||
const biome = new Uint8Array(l);
|
||||
const pop = new Uint16Array(l);
|
||||
const road = new Uint16Array(l);
|
||||
const crossroad = new Uint16Array(l);
|
||||
const routes = {};
|
||||
const s = new Uint16Array(l);
|
||||
const burg = new Uint16Array(l);
|
||||
const state = new Uint16Array(l);
|
||||
|
|
@ -300,8 +300,7 @@ function editHeightmap(options) {
|
|||
biome[g] = pack.cells.biome[i];
|
||||
culture[g] = pack.cells.culture[i];
|
||||
pop[g] = pack.cells.pop[i];
|
||||
road[g] = pack.cells.road[i];
|
||||
crossroad[g] = pack.cells.crossroad[i];
|
||||
routes[g] = pack.cells.routes[i];
|
||||
s[g] = pack.cells.s[i];
|
||||
state[g] = pack.cells.state[i];
|
||||
province[g] = pack.cells.province[i];
|
||||
|
|
@ -353,8 +352,7 @@ function editHeightmap(options) {
|
|||
// assign saved pack data from grid back to pack
|
||||
const n = pack.cells.i.length;
|
||||
pack.cells.pop = new Float32Array(n);
|
||||
pack.cells.road = new Uint16Array(n);
|
||||
pack.cells.crossroad = new Uint16Array(n);
|
||||
pack.cells.routes = {};
|
||||
pack.cells.s = new Uint16Array(n);
|
||||
pack.cells.burg = new Uint16Array(n);
|
||||
pack.cells.state = new Uint16Array(n);
|
||||
|
|
@ -389,8 +387,7 @@ function editHeightmap(options) {
|
|||
if (!isLand) continue;
|
||||
pack.cells.culture[i] = culture[g];
|
||||
pack.cells.pop[i] = pop[g];
|
||||
pack.cells.road[i] = road[g];
|
||||
pack.cells.crossroad[i] = crossroad[g];
|
||||
pack.cells.routes[i] = routes[g];
|
||||
pack.cells.s[i] = s[g];
|
||||
pack.cells.state[i] = state[g];
|
||||
pack.cells.province[i] = province[g];
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ function handleKeyup(event) {
|
|||
else if (shift && code === "KeyO") editNotes();
|
||||
else if (shift && code === "KeyA") overviewCharts();
|
||||
else if (shift && code === "KeyT") overviewBurgs();
|
||||
else if (shift && code === "KeyU") overviewRoutes();
|
||||
else if (shift && code === "KeyV") overviewRivers();
|
||||
else if (shift && code === "KeyM") overviewMilitary();
|
||||
else if (shift && code === "KeyK") overviewMarkers();
|
||||
|
|
@ -57,7 +58,7 @@ function handleKeyup(event) {
|
|||
else if (key === "!") toggleAddBurg();
|
||||
else if (key === "@") toggleAddLabel();
|
||||
else if (key === "#") toggleAddRiver();
|
||||
else if (key === "$") toggleAddRoute();
|
||||
else if (key === "$") createRoute();
|
||||
else if (key === "%") toggleAddMarker();
|
||||
else if (alt && code === "KeyB") console.table(pack.burgs);
|
||||
else if (alt && code === "KeyS") console.table(pack.states);
|
||||
|
|
|
|||
|
|
@ -48,8 +48,7 @@ function editLake() {
|
|||
document.getElementById("lakeArea").value = si(getArea(l.area)) + " " + getAreaUnit();
|
||||
|
||||
const length = d3.polygonLength(l.vertices.map(v => pack.vertices.p[v]));
|
||||
document.getElementById("lakeShoreLength").value =
|
||||
si(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
|
||||
document.getElementById("lakeShoreLength").value = si(length * distanceScale) + " " + distanceUnitInput.value;
|
||||
|
||||
const lakeCells = Array.from(cells.i.filter(i => cells.f[i] === l.i));
|
||||
const heights = lakeCells.map(i => cells.h[i]);
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ function restoreLayers() {
|
|||
if (layerIsOn("toggleGrid")) drawGrid();
|
||||
if (layerIsOn("toggleCoordinates")) drawCoordinates();
|
||||
if (layerIsOn("toggleCompass")) compass.style("display", "block");
|
||||
if (layerIsOn("toggleRoutes")) drawRoutes();
|
||||
if (layerIsOn("toggleTemp")) drawTemp();
|
||||
if (layerIsOn("togglePrec")) drawPrec();
|
||||
if (layerIsOn("togglePopulation")) drawPopulation();
|
||||
|
|
@ -392,7 +393,6 @@ function drawTemp() {
|
|||
const start = findStart(i, t);
|
||||
if (!start) continue;
|
||||
used[i] = 1;
|
||||
//debug.append("circle").attr("r", 3).attr("cx", vertices.p[start][0]).attr("cy", vertices.p[start][1]).attr("fill", "red").attr("stroke", "black").attr("stroke-width", .3);
|
||||
|
||||
const chain = connectVertices(start, t); // vertices chain to form a path
|
||||
const relaxed = chain.filter((v, i) => i % 4 === 0 || vertices.c[v].some(c => c >= n));
|
||||
|
|
@ -1622,18 +1622,33 @@ function drawRivers() {
|
|||
function toggleRoutes(event) {
|
||||
if (!layerIsOn("toggleRoutes")) {
|
||||
turnButtonOn("toggleRoutes");
|
||||
$("#routes").fadeIn();
|
||||
drawRoutes();
|
||||
if (event && isCtrlClick(event)) editStyle("routes");
|
||||
} else {
|
||||
if (event && isCtrlClick(event)) {
|
||||
editStyle("routes");
|
||||
return;
|
||||
}
|
||||
$("#routes").fadeOut();
|
||||
if (event && isCtrlClick(event)) return editStyle("routes");
|
||||
routes.selectAll("path").remove();
|
||||
turnButtonOff("toggleRoutes");
|
||||
}
|
||||
}
|
||||
|
||||
function drawRoutes() {
|
||||
TIME && console.time("drawRoutes");
|
||||
const routePaths = {};
|
||||
|
||||
for (const route of pack.routes) {
|
||||
const {i, group} = route;
|
||||
if (!routePaths[group]) routePaths[group] = [];
|
||||
routePaths[group].push(`<path id="route${i}" d="${Routes.getPath(route)}"/>`);
|
||||
}
|
||||
|
||||
routes.selectAll("path").remove();
|
||||
for (const group in routePaths) {
|
||||
routes.select("#" + group).html(routePaths[group].join(""));
|
||||
}
|
||||
|
||||
TIME && console.timeEnd("drawRoutes");
|
||||
}
|
||||
|
||||
function toggleMilitary() {
|
||||
if (!layerIsOn("toggleMilitary")) {
|
||||
turnButtonOn("toggleMilitary");
|
||||
|
|
@ -1758,7 +1773,6 @@ function toggleScaleBar(event) {
|
|||
function drawScaleBar(scaleBar, scaleLevel) {
|
||||
if (!scaleBar.size() || scaleBar.style("display") === "none") return;
|
||||
|
||||
const distanceScale = +distanceScaleInput.value;
|
||||
const unit = distanceUnitInput.value;
|
||||
const size = +scaleBar.attr("data-bar-size");
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class Measurer {
|
|||
}
|
||||
|
||||
getDash() {
|
||||
return rn(30 / distanceScaleInput.value, 2);
|
||||
return rn(30 / distanceScale, 2);
|
||||
}
|
||||
|
||||
drag() {
|
||||
|
|
@ -205,7 +205,7 @@ class Ruler extends Measurer {
|
|||
|
||||
updateLabel() {
|
||||
const length = this.getLength();
|
||||
const text = rn(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
|
||||
const text = rn(length * distanceScale) + " " + distanceUnitInput.value;
|
||||
const [x, y] = last(this.points);
|
||||
this.el.select("text").attr("x", x).attr("y", y).text(text);
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ class Opisometer extends Measurer {
|
|||
|
||||
updateLabel() {
|
||||
const length = this.el.select("path").node().getTotalLength();
|
||||
const text = rn(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
|
||||
const text = rn(length * distanceScale) + " " + distanceUnitInput.value;
|
||||
const [x, y] = last(this.points);
|
||||
this.el.select("text").attr("x", x).attr("y", y).text(text);
|
||||
}
|
||||
|
|
@ -475,7 +475,7 @@ class RouteOpisometer extends Measurer {
|
|||
|
||||
updateLabel() {
|
||||
const length = this.el.select("path").node().getTotalLength();
|
||||
const text = rn(length * distanceScaleInput.value) + " " + distanceUnitInput.value;
|
||||
const text = rn(length * distanceScale) + " " + distanceUnitInput.value;
|
||||
const [x, y] = last(this.points);
|
||||
this.el.select("text").attr("x", x).attr("y", y).text(text);
|
||||
}
|
||||
|
|
@ -486,9 +486,7 @@ class RouteOpisometer extends Measurer {
|
|||
const cells = pack.cells;
|
||||
|
||||
const c = findCell(mousePoint[0], mousePoint[1]);
|
||||
if (!cells.road[c] && !d3.event.sourceEvent.shiftKey) {
|
||||
return;
|
||||
}
|
||||
if (!Routes.isConnected(c) && !d3.event.sourceEvent.shiftKey) return;
|
||||
|
||||
context.trackCell(c, rigth);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -545,6 +545,7 @@ function applyStoredOptions() {
|
|||
lock(key);
|
||||
|
||||
if (key === "points") changeCellsDensity(+value);
|
||||
if (key === "distanceScale") distanceScale = +value;
|
||||
|
||||
// add saved style presets to options
|
||||
if (key.slice(0, 5) === "style") applyOption(stylePreset, key, key.slice(5));
|
||||
|
|
@ -605,7 +606,8 @@ function randomizeOptions() {
|
|||
|
||||
// 'Units Editor' settings
|
||||
const US = navigator.language === "en-US";
|
||||
if (randomize || !locked("distanceScale")) distanceScaleOutput.value = distanceScaleInput.value = gauss(3, 1, 1, 5);
|
||||
if (randomize || !locked("distanceScale"))
|
||||
distanceScale = distanceScaleOutput.value = distanceScaleInput.value = gauss(3, 1, 1, 5);
|
||||
if (!stored("distanceUnit")) distanceUnitInput.value = US ? "mi" : "km";
|
||||
if (!stored("heightUnit")) heightUnit.value = US ? "ft" : "m";
|
||||
if (!stored("temperatureScale")) temperatureScale.value = US ? "°F" : "°C";
|
||||
|
|
|
|||
|
|
@ -886,7 +886,7 @@ function editProvinces() {
|
|||
const p = d3.mouse(this);
|
||||
moveCircle(p[0], p[1], r);
|
||||
|
||||
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)];
|
||||
const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])];
|
||||
const selection = found.filter(isLand);
|
||||
if (selection) changeForSelection(selection);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ function editRiver(id) {
|
|||
closeDialogs(".stable");
|
||||
if (!layerIsOn("toggleRivers")) toggleRivers();
|
||||
|
||||
document.getElementById("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
|
||||
byId("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
|
||||
if (!layerIsOn("toggleCells")) toggleCells();
|
||||
|
||||
elSelected = d3.select("#" + id).on("click", addControlPoint);
|
||||
|
||||
tip("Drag control points to change the river course. Click on point to remove it. Click on river to add additional control point. For major changes please create a new river instead", true);
|
||||
tip(
|
||||
"Drag control points to change the river course. Click on point to remove it. Click on river to add additional control point. For major changes please create a new river instead",
|
||||
true
|
||||
);
|
||||
debug.append("g").attr("id", "controlCells");
|
||||
debug.append("g").attr("id", "controlPoints");
|
||||
|
||||
|
|
@ -33,18 +36,18 @@ function editRiver(id) {
|
|||
modules.editRiver = true;
|
||||
|
||||
// add listeners
|
||||
document.getElementById("riverCreateSelectingCells").addEventListener("click", createRiver);
|
||||
document.getElementById("riverEditStyle").addEventListener("click", () => editStyle("rivers"));
|
||||
document.getElementById("riverElevationProfile").addEventListener("click", showElevationProfile);
|
||||
document.getElementById("riverLegend").addEventListener("click", editRiverLegend);
|
||||
document.getElementById("riverRemove").addEventListener("click", removeRiver);
|
||||
document.getElementById("riverName").addEventListener("input", changeName);
|
||||
document.getElementById("riverType").addEventListener("input", changeType);
|
||||
document.getElementById("riverNameCulture").addEventListener("click", generateNameCulture);
|
||||
document.getElementById("riverNameRandom").addEventListener("click", generateNameRandom);
|
||||
document.getElementById("riverMainstem").addEventListener("change", changeParent);
|
||||
document.getElementById("riverSourceWidth").addEventListener("input", changeSourceWidth);
|
||||
document.getElementById("riverWidthFactor").addEventListener("input", changeWidthFactor);
|
||||
byId("riverCreateSelectingCells").on("click", createRiver);
|
||||
byId("riverEditStyle").on("click", () => editStyle("rivers"));
|
||||
byId("riverElevationProfile").on("click", showRiverElevationProfile);
|
||||
byId("riverLegend").on("click", editRiverLegend);
|
||||
byId("riverRemove").on("click", removeRiver);
|
||||
byId("riverName").on("input", changeName);
|
||||
byId("riverType").on("input", changeType);
|
||||
byId("riverNameCulture").on("click", generateNameCulture);
|
||||
byId("riverNameRandom").on("click", generateNameRandom);
|
||||
byId("riverMainstem").on("change", changeParent);
|
||||
byId("riverSourceWidth").on("input", changeSourceWidth);
|
||||
byId("riverWidthFactor").on("input", changeWidthFactor);
|
||||
|
||||
function getRiver() {
|
||||
const riverId = +elSelected.attr("id").slice(5);
|
||||
|
|
@ -55,10 +58,10 @@ function editRiver(id) {
|
|||
function updateRiverData() {
|
||||
const r = getRiver();
|
||||
|
||||
document.getElementById("riverName").value = r.name;
|
||||
document.getElementById("riverType").value = r.type;
|
||||
byId("riverName").value = r.name;
|
||||
byId("riverType").value = r.type;
|
||||
|
||||
const parentSelect = document.getElementById("riverMainstem");
|
||||
const parentSelect = byId("riverMainstem");
|
||||
parentSelect.options.length = 0;
|
||||
const parent = r.parent || r.i;
|
||||
const sortedRivers = pack.rivers.slice().sort((a, b) => (a.name > b.name ? 1 : -1));
|
||||
|
|
@ -66,11 +69,11 @@ function editRiver(id) {
|
|||
const opt = new Option(river.name, river.i, false, river.i === parent);
|
||||
parentSelect.options.add(opt);
|
||||
});
|
||||
document.getElementById("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
|
||||
byId("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
|
||||
|
||||
document.getElementById("riverDischarge").value = r.discharge + " m³/s";
|
||||
document.getElementById("riverSourceWidth").value = r.sourceWidth;
|
||||
document.getElementById("riverWidthFactor").value = r.widthFactor;
|
||||
byId("riverDischarge").value = r.discharge + " m³/s";
|
||||
byId("riverSourceWidth").value = r.sourceWidth;
|
||||
byId("riverWidthFactor").value = r.widthFactor;
|
||||
|
||||
updateRiverLength(r);
|
||||
updateRiverWidth(r);
|
||||
|
|
@ -78,8 +81,8 @@ function editRiver(id) {
|
|||
|
||||
function updateRiverLength(river) {
|
||||
river.length = rn(elSelected.node().getTotalLength() / 2, 2);
|
||||
const lengthUI = `${rn(river.length * distanceScaleInput.value)} ${distanceUnitInput.value}`;
|
||||
document.getElementById("riverLength").value = lengthUI;
|
||||
const lengthUI = `${rn(river.length * distanceScale)} ${distanceUnitInput.value}`;
|
||||
byId("riverLength").value = lengthUI;
|
||||
}
|
||||
|
||||
function updateRiverWidth(river) {
|
||||
|
|
@ -88,8 +91,8 @@ function editRiver(id) {
|
|||
const meanderedPoints = addMeandering(cells);
|
||||
river.width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, sourceWidth));
|
||||
|
||||
const width = `${rn(river.width * distanceScaleInput.value, 3)} ${distanceUnitInput.value}`;
|
||||
document.getElementById("riverWidth").value = width;
|
||||
const width = `${rn(river.width * distanceScale, 3)} ${distanceUnitInput.value}`;
|
||||
byId("riverWidth").value = width;
|
||||
}
|
||||
|
||||
function drawControlPoints(points) {
|
||||
|
|
@ -163,7 +166,7 @@ function editRiver(id) {
|
|||
elSelected.attr("d", path);
|
||||
|
||||
updateRiverLength(river);
|
||||
if (modules.elevation) showEPForRiver(elSelected.node());
|
||||
if (byId("elevationProfile").offsetParent) showRiverElevationProfile();
|
||||
}
|
||||
|
||||
function addControlPoint() {
|
||||
|
|
@ -209,7 +212,7 @@ function editRiver(id) {
|
|||
const r = getRiver();
|
||||
r.parent = +this.value;
|
||||
r.basin = pack.rivers.find(river => river.i === r.parent).basin;
|
||||
document.getElementById("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
|
||||
byId("riverBasin").value = pack.rivers.find(river => river.i === r.basin).name;
|
||||
}
|
||||
|
||||
function changeSourceWidth() {
|
||||
|
|
@ -226,9 +229,14 @@ function editRiver(id) {
|
|||
redrawRiver();
|
||||
}
|
||||
|
||||
function showElevationProfile() {
|
||||
modules.elevation = true;
|
||||
showEPForRiver(elSelected.node());
|
||||
function showRiverElevationProfile() {
|
||||
const points = debug
|
||||
.selectAll("#controlPoints > *")
|
||||
.data()
|
||||
.map(([x, y]) => findCell(x, y));
|
||||
const river = getRiver();
|
||||
const riverLen = rn(river.length * distanceScale);
|
||||
showElevationProfile(points, riverLen, true);
|
||||
}
|
||||
|
||||
function editRiverLegend() {
|
||||
|
|
@ -266,8 +274,8 @@ function editRiver(id) {
|
|||
unselect();
|
||||
clearMainTip();
|
||||
|
||||
const forced = +document.getElementById("toggleCells").dataset.forced;
|
||||
document.getElementById("toggleCells").dataset.forced = 0;
|
||||
const forced = +byId("toggleCells").dataset.forced;
|
||||
byId("toggleCells").dataset.forced = 0;
|
||||
if (forced && layerIsOn("toggleCells")) toggleCells();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"use strict";
|
||||
|
||||
function overviewRivers() {
|
||||
if (customization) return;
|
||||
closeDialogs("#riversOverview, .stable");
|
||||
|
|
@ -34,8 +35,8 @@ function overviewRivers() {
|
|||
|
||||
for (const r of pack.rivers) {
|
||||
const discharge = r.discharge + " m³/s";
|
||||
const length = rn(r.length * distanceScaleInput.value) + " " + unit;
|
||||
const width = rn(r.width * distanceScaleInput.value, 3) + " " + unit;
|
||||
const length = rn(r.length * distanceScale) + " " + unit;
|
||||
const width = rn(r.width * distanceScale, 3) + " " + unit;
|
||||
const basin = pack.rivers.find(river => river.i === r.basin)?.name;
|
||||
|
||||
lines += /* html */ `<div
|
||||
|
|
@ -49,7 +50,7 @@ function overviewRivers() {
|
|||
data-basin="${basin}"
|
||||
>
|
||||
<span data-tip="Click to focus on river" class="icon-dot-circled pointer"></span>
|
||||
<div data-tip="River name" class="riverName">${r.name}</div>
|
||||
<div data-tip="River name" style="margin-left: 0.4em;" class="riverName">${r.name}</div>
|
||||
<div data-tip="River type name" class="riverType">${r.type}</div>
|
||||
<div data-tip="River discharge (flux power)" class="biomeArea">${discharge}</div>
|
||||
<div data-tip="River length from source to mouth" class="biomeArea">${length}</div>
|
||||
|
|
@ -66,16 +67,18 @@ function overviewRivers() {
|
|||
const averageDischarge = rn(d3.mean(pack.rivers.map(r => r.discharge)));
|
||||
riversFooterDischarge.innerHTML = averageDischarge + " m³/s";
|
||||
const averageLength = rn(d3.mean(pack.rivers.map(r => r.length)));
|
||||
riversFooterLength.innerHTML = averageLength * distanceScaleInput.value + " " + unit;
|
||||
riversFooterLength.innerHTML = averageLength * distanceScale + " " + unit;
|
||||
const averageWidth = rn(d3.mean(pack.rivers.map(r => r.width)), 3);
|
||||
riversFooterWidth.innerHTML = rn(averageWidth * distanceScaleInput.value, 3) + " " + unit;
|
||||
riversFooterWidth.innerHTML = rn(averageWidth * distanceScale, 3) + " " + unit;
|
||||
|
||||
// add listeners
|
||||
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseenter", ev => riverHighlightOn(ev)));
|
||||
body.querySelectorAll("div.states").forEach(el => el.addEventListener("mouseleave", ev => riverHighlightOff(ev)));
|
||||
body.querySelectorAll("div > span.icon-dot-circled").forEach(el => el.addEventListener("click", zoomToRiver));
|
||||
body.querySelectorAll("div > span.icon-pencil").forEach(el => el.addEventListener("click", openRiverEditor));
|
||||
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.addEventListener("click", triggerRiverRemove));
|
||||
body
|
||||
.querySelectorAll("div > span.icon-trash-empty")
|
||||
.forEach(el => el.addEventListener("click", triggerRiverRemove));
|
||||
|
||||
applySorting(riversHeader);
|
||||
}
|
||||
|
|
@ -110,7 +113,18 @@ function overviewRivers() {
|
|||
} else {
|
||||
rivers.attr("data-basin", "hightlighted");
|
||||
const basins = [...new Set(pack.rivers.map(r => r.basin))];
|
||||
const colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"];
|
||||
const colors = [
|
||||
"#1f77b4",
|
||||
"#ff7f0e",
|
||||
"#2ca02c",
|
||||
"#d62728",
|
||||
"#9467bd",
|
||||
"#8c564b",
|
||||
"#e377c2",
|
||||
"#7f7f7f",
|
||||
"#bcbd22",
|
||||
"#17becf"
|
||||
];
|
||||
|
||||
basins.forEach((b, i) => {
|
||||
const color = colors[i % colors.length];
|
||||
|
|
@ -129,8 +143,8 @@ function overviewRivers() {
|
|||
body.querySelectorAll(":scope > div").forEach(function (el) {
|
||||
const d = el.dataset;
|
||||
const discharge = d.discharge + " m³/s";
|
||||
const length = rn(d.length * distanceScaleInput.value) + " " + distanceUnitInput.value;
|
||||
const width = rn(d.width * distanceScaleInput.value, 3) + " " + distanceUnitInput.value;
|
||||
const length = rn(d.length * distanceScale) + " " + distanceUnitInput.value;
|
||||
const width = rn(d.width * distanceScale, 3) + " " + distanceUnitInput.value;
|
||||
data += [d.id, d.name, d.type, discharge, length, width, d.basin].join(",") + "\n";
|
||||
});
|
||||
|
||||
|
|
|
|||
85
modules/ui/route-group-editor.js
Normal file
85
modules/ui/route-group-editor.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use strict";
|
||||
|
||||
function editRouteGroups() {
|
||||
if (customization) return;
|
||||
if (!layerIsOn("toggleRoutes")) toggleRoutes();
|
||||
|
||||
addLines();
|
||||
|
||||
$("#routeGroupsEditor").dialog({
|
||||
title: "Edit Route groups",
|
||||
resizable: false,
|
||||
position: {my: "left top", at: "left+10 top+140", of: "#map"}
|
||||
});
|
||||
|
||||
if (modules.editRouteGroups) return;
|
||||
modules.editRouteGroups = true;
|
||||
|
||||
// add listeners
|
||||
byId("routeGroupsEditorAdd").addEventListener("click", addGroup);
|
||||
byId("routeGroupsEditorBody").on("click", ev => {
|
||||
const group = ev.target.parentNode.dataset.id;
|
||||
if (ev.target.classList.contains("editStyle")) editStyle("routes", group);
|
||||
else if (ev.target.classList.contains("removeGroup")) removeGroup(group);
|
||||
});
|
||||
|
||||
function addLines() {
|
||||
byId("routeGroupsEditorBody").innerHTML = "";
|
||||
|
||||
const lines = Array.from(routes.selectAll("g")._groups[0]).map(el => {
|
||||
const count = el.children.length;
|
||||
return /* html */ `<div data-id="${el.id}" class="states" style="display: flex; justify-content: space-between;">
|
||||
<span>${el.id} (${count})</span>
|
||||
<div style="width: auto; display: flex; gap: 0.4em;">
|
||||
<span data-tip="Edit style" class="editStyle icon-brush pointer" style="font-size: smaller;"></span>
|
||||
<span data-tip="Remove group" class="removeGroup icon-trash pointer"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
byId("routeGroupsEditorBody").innerHTML = lines.join("");
|
||||
}
|
||||
|
||||
const DEFAULT_GROUPS = ["roads", "trails", "searoutes"];
|
||||
|
||||
function addGroup() {
|
||||
prompt("Type group name", {default: "route-group-new"}, v => {
|
||||
let group = v
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "_")
|
||||
.replace(/[^\w\s]/gi, "");
|
||||
|
||||
if (!group) return tip("Invalid group name", false, "error");
|
||||
if (!group.startsWith("route-")) group = "route-" + group;
|
||||
if (byId(group)) return tip("Element with this name already exists. Provide a unique name", false, "error");
|
||||
if (Number.isFinite(+group.charAt(0))) return tip("Group name should start with a letter", false, "error");
|
||||
|
||||
routes
|
||||
.append("g")
|
||||
.attr("id", group)
|
||||
.attr("stroke", "#000000")
|
||||
.attr("stroke-width", 0.5)
|
||||
.attr("stroke-dasharray", "1 0.5")
|
||||
.attr("stroke-linecap", "butt");
|
||||
byId("routeGroup")?.options.add(new Option(group, group));
|
||||
addLines();
|
||||
|
||||
byId("routeCreatorGroupSelect").options.add(new Option(group, group));
|
||||
});
|
||||
}
|
||||
|
||||
function removeGroup(group) {
|
||||
confirmationDialog({
|
||||
title: "Remove route group",
|
||||
message:
|
||||
"Are you sure you want to remove the entire route group? All routes in this group will be removed. This action can't be reverted.",
|
||||
confirm: "Remove",
|
||||
onConfirm: () => {
|
||||
const routes = pack.routes.filter(r => r.group === group);
|
||||
routes.forEach(r => Routes.remove(r));
|
||||
if (DEFAULT_GROUPS.includes(group)) routes.select(`#${group}`).remove();
|
||||
addLines();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
140
modules/ui/routes-creator.js
Normal file
140
modules/ui/routes-creator.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"use strict";
|
||||
|
||||
function createRoute(defaultGroup) {
|
||||
if (customization) return;
|
||||
closeDialogs();
|
||||
if (!layerIsOn("toggleRoutes")) toggleRoutes();
|
||||
|
||||
byId("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
|
||||
if (!layerIsOn("toggleCells")) toggleCells();
|
||||
|
||||
tip("Click to add route point, click again to remove", true);
|
||||
debug.append("g").attr("id", "controlCells");
|
||||
debug.append("g").attr("id", "controlPoints");
|
||||
viewbox.style("cursor", "crosshair").on("click", onClick);
|
||||
|
||||
createRoute.points = [];
|
||||
const body = byId("routeCreatorBody");
|
||||
|
||||
// update route groups
|
||||
byId("routeCreatorGroupSelect").innerHTML = Array.from(routes.selectAll("g")._groups[0]).map(el => {
|
||||
const selected = defaultGroup || "roads";
|
||||
return `<option value="${el.id}" ${el.id === selected ? "selected" : ""}>${el.id}</option>`;
|
||||
});
|
||||
|
||||
$("#routeCreator").dialog({
|
||||
title: "Create Route",
|
||||
resizable: false,
|
||||
position: {my: "left top", at: "left+10 top+10", of: "#map"},
|
||||
close: closeRouteCreator
|
||||
});
|
||||
|
||||
if (modules.createRoute) return;
|
||||
modules.createRoute = true;
|
||||
|
||||
// add listeners
|
||||
byId("routeCreatorGroupSelect").on("change", () => drawRoute(createRoute.points));
|
||||
byId("routeCreatorGroupEdit").on("click", editRouteGroups);
|
||||
byId("routeCreatorComplete").on("click", completeCreation);
|
||||
byId("routeCreatorCancel").on("click", () => $("#routeCreator").dialog("close"));
|
||||
body.on("click", ev => {
|
||||
if (ev.target.classList.contains("icon-trash-empty")) removePoint(ev.target.parentNode.dataset.point);
|
||||
});
|
||||
|
||||
function onClick() {
|
||||
const [x, y] = d3.mouse(this);
|
||||
const cellId = findCell(x, y);
|
||||
const point = [rn(x, 2), rn(y, 2), cellId];
|
||||
createRoute.points.push(point);
|
||||
|
||||
drawRoute(createRoute.points);
|
||||
|
||||
body.innerHTML += `<div class="editorLine" style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 1em;" data-point="${point.join(
|
||||
"-"
|
||||
)}">
|
||||
<span><b>Cell</b>: ${cellId}</span>
|
||||
<span><b>X</b>: ${point[0]}</span>
|
||||
<span><b>Y</b>: ${point[1]}</span>
|
||||
<span data-tip="Remove the point" class="icon-trash-empty pointer"></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function removePoint(pointString) {
|
||||
createRoute.points = createRoute.points.filter(p => p.join("-") !== pointString);
|
||||
drawRoute(createRoute.points);
|
||||
body.querySelector(`[data-point='${pointString}']`)?.remove();
|
||||
}
|
||||
|
||||
function drawRoute(points) {
|
||||
debug
|
||||
.select("#controlCells")
|
||||
.selectAll("polygon")
|
||||
.data(points)
|
||||
.join("polygon")
|
||||
.attr("points", p => getPackPolygon(p[2]))
|
||||
.attr("class", "current");
|
||||
|
||||
debug
|
||||
.select("#controlPoints")
|
||||
.selectAll("circle")
|
||||
.data(points)
|
||||
.join("circle")
|
||||
.attr("cx", d => d[0])
|
||||
.attr("cy", d => d[1])
|
||||
.attr("r", 0.6);
|
||||
|
||||
const group = byId("routeCreatorGroupSelect").value;
|
||||
|
||||
routes.select("#routeTemp").remove();
|
||||
routes
|
||||
.select("#" + group)
|
||||
.append("path")
|
||||
.attr("d", Routes.getPath({group, points}))
|
||||
.attr("id", "routeTemp");
|
||||
}
|
||||
|
||||
function completeCreation() {
|
||||
const points = createRoute.points;
|
||||
if (points.length < 2) return tip("Add at least 2 points", false, "error");
|
||||
|
||||
const routeId = Math.max(...pack.routes.map(route => route.i)) + 1;
|
||||
const group = byId("routeCreatorGroupSelect").value;
|
||||
const feature = pack.cells.f[points[0][2]];
|
||||
const route = {points, group, feature, i: routeId};
|
||||
pack.routes.push(route);
|
||||
|
||||
const links = pack.cells.routes;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const point = points[i];
|
||||
const nextPoint = points[i + 1];
|
||||
|
||||
if (nextPoint) {
|
||||
const cellId = point[2];
|
||||
const nextId = nextPoint[2];
|
||||
|
||||
if (!links[cellId]) links[cellId] = {};
|
||||
links[cellId][nextId] = routeId;
|
||||
|
||||
if (!links[nextId]) links[nextId] = {};
|
||||
links[nextId][cellId] = routeId;
|
||||
}
|
||||
}
|
||||
|
||||
routes.select("#routeTemp").attr("id", "route" + routeId);
|
||||
editRoute("route" + routeId);
|
||||
}
|
||||
|
||||
function closeRouteCreator() {
|
||||
body.innerHTML = "";
|
||||
debug.select("#controlCells").remove();
|
||||
debug.select("#controlPoints").remove();
|
||||
routes.select("#routeTemp").remove();
|
||||
|
||||
restoreDefaultEvents();
|
||||
clearMainTip();
|
||||
|
||||
const forced = +byId("toggleCells").dataset.forced;
|
||||
byId("toggleCells").dataset.forced = 0;
|
||||
if (forced && layerIsOn("toggleCells")) toggleCells();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,308 +1,403 @@
|
|||
"use strict";
|
||||
|
||||
const CONTROL_POINST_DISTANCE = 10;
|
||||
|
||||
function editRoute(onClick) {
|
||||
function editRoute(id) {
|
||||
if (customization) return;
|
||||
if (!onClick && elSelected && d3.event.target.id === elSelected.attr("id")) return;
|
||||
if (elSelected && id === elSelected.attr("id")) return;
|
||||
closeDialogs(".stable");
|
||||
|
||||
if (!layerIsOn("toggleRoutes")) toggleRoutes();
|
||||
byId("toggleCells").dataset.forced = +!layerIsOn("toggleCells");
|
||||
if (!layerIsOn("toggleCells")) toggleCells();
|
||||
|
||||
elSelected = d3.select("#" + id).on("click", addControlPoint);
|
||||
|
||||
tip(
|
||||
"Drag control points to change the route. Click on point to remove it. Click on the route to add additional control point. For major changes please create a new route instead",
|
||||
true
|
||||
);
|
||||
debug.append("g").attr("id", "controlCells");
|
||||
debug.append("g").attr("id", "controlPoints");
|
||||
|
||||
{
|
||||
const route = getRoute();
|
||||
updateRouteData(route);
|
||||
drawControlPoints(route.points);
|
||||
drawCells(route.points);
|
||||
updateLockIcon();
|
||||
}
|
||||
|
||||
$("#routeEditor").dialog({
|
||||
title: "Edit Route",
|
||||
resizable: false,
|
||||
position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"},
|
||||
close: closeRoutesEditor
|
||||
position: {my: "left top", at: "left+10 top+10", of: "#map"},
|
||||
close: closeRouteEditor
|
||||
});
|
||||
|
||||
debug.append("g").attr("id", "controlPoints");
|
||||
const node = onClick ? elSelected.node() : d3.event.target;
|
||||
elSelected = d3.select(node).on("click", addInterimControlPoint);
|
||||
drawControlPoints(node);
|
||||
selectRouteGroup(node);
|
||||
|
||||
viewbox.on("touchmove mousemove", showEditorTips);
|
||||
if (onClick) toggleRouteCreationMode();
|
||||
|
||||
if (modules.editRoute) return;
|
||||
modules.editRoute = true;
|
||||
|
||||
// add listeners
|
||||
document.getElementById("routeGroupsShow").addEventListener("click", showGroupSection);
|
||||
document.getElementById("routeGroup").addEventListener("change", changeRouteGroup);
|
||||
document.getElementById("routeGroupAdd").addEventListener("click", toggleNewGroupInput);
|
||||
document.getElementById("routeGroupName").addEventListener("change", createNewGroup);
|
||||
document.getElementById("routeGroupRemove").addEventListener("click", removeRouteGroup);
|
||||
document.getElementById("routeGroupsHide").addEventListener("click", hideGroupSection);
|
||||
document.getElementById("routeElevationProfile").addEventListener("click", showElevationProfile);
|
||||
byId("routeCreateSelectingCells").on("click", showCreationDialog);
|
||||
byId("routeSplit").on("click", togglePressed);
|
||||
byId("routeJoin").on("click", openJoinRoutesDialog);
|
||||
byId("routeElevationProfile").on("click", showRouteElevationProfile);
|
||||
byId("routeLegend").on("click", editRouteLegend);
|
||||
byId("routeLock").on("click", toggleLockButton);
|
||||
byId("routeRemove").on("click", removeRoute);
|
||||
byId("routeName").on("input", changeName);
|
||||
byId("routeGroup").on("input", changeGroup);
|
||||
byId("routeGroupEdit").on("click", editRouteGroups);
|
||||
byId("routeEditStyle").on("click", editRouteGroupStyle);
|
||||
byId("routeGenerateName").on("click", generateName);
|
||||
|
||||
document.getElementById("routeEditStyle").addEventListener("click", editGroupStyle);
|
||||
document.getElementById("routeSplit").addEventListener("click", toggleRouteSplitMode);
|
||||
document.getElementById("routeLegend").addEventListener("click", editRouteLegend);
|
||||
document.getElementById("routeNew").addEventListener("click", toggleRouteCreationMode);
|
||||
document.getElementById("routeRemove").addEventListener("click", removeRoute);
|
||||
|
||||
function showEditorTips() {
|
||||
showMainTip();
|
||||
if (routeNew.classList.contains("pressed")) return;
|
||||
if (d3.event.target.id === elSelected.attr("id")) tip("Click to add a control point");
|
||||
else if (d3.event.target.parentNode.id === "controlPoints") tip("Drag to move, click to delete the control point");
|
||||
function getRoute() {
|
||||
const routeId = +elSelected.attr("id").slice(5);
|
||||
return pack.routes.find(route => route.i === routeId);
|
||||
}
|
||||
|
||||
function drawControlPoints(node) {
|
||||
const totalLength = node.getTotalLength();
|
||||
const increment = totalLength / Math.ceil(totalLength / CONTROL_POINST_DISTANCE);
|
||||
for (let i = 0; i <= totalLength; i += increment) {
|
||||
const point = node.getPointAtLength(i);
|
||||
addControlPoint([point.x, point.y]);
|
||||
}
|
||||
routeLength.innerHTML = rn(totalLength * distanceScaleInput.value) + " " + distanceUnitInput.value;
|
||||
function updateRouteData(route) {
|
||||
route.name = route.name || Routes.generateName(route);
|
||||
byId("routeName").value = route.name;
|
||||
|
||||
const routeGroup = byId("routeGroup");
|
||||
routeGroup.options.length = 0;
|
||||
routes.selectAll("g").each(function () {
|
||||
routeGroup.options.add(new Option(this.id, this.id, false, this.id === route.group));
|
||||
});
|
||||
|
||||
updateRouteLength(route);
|
||||
|
||||
const isWater = route.points.some(([x, y, cellId]) => pack.cells.h[cellId] < 20);
|
||||
byId("routeElevationProfile").style.display = isWater ? "none" : "inline-block";
|
||||
}
|
||||
|
||||
function addControlPoint(point, before = null) {
|
||||
debug
|
||||
.select("#controlPoints")
|
||||
.insert("circle", before)
|
||||
.attr("cx", point[0])
|
||||
.attr("cy", point[1])
|
||||
.attr("r", 0.6)
|
||||
.call(d3.drag().on("drag", dragControlPoint))
|
||||
.on("click", clickControlPoint);
|
||||
function updateRouteLength(route) {
|
||||
route.length = Routes.getLength(route.i);
|
||||
byId("routeLength").value = rn(route.length * distanceScale) + " " + distanceUnitInput.value;
|
||||
}
|
||||
|
||||
function addInterimControlPoint() {
|
||||
const point = d3.mouse(this);
|
||||
const controls = document.getElementById("controlPoints").querySelectorAll("circle");
|
||||
const points = Array.from(controls).map(circle => [+circle.getAttribute("cx"), +circle.getAttribute("cy")]);
|
||||
const index = getSegmentId(points, point, 2);
|
||||
addControlPoint(point, ":nth-child(" + (index + 1) + ")");
|
||||
|
||||
redrawRoute();
|
||||
}
|
||||
|
||||
function dragControlPoint() {
|
||||
this.setAttribute("cx", d3.event.x);
|
||||
this.setAttribute("cy", d3.event.y);
|
||||
redrawRoute();
|
||||
}
|
||||
|
||||
function redrawRoute() {
|
||||
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
|
||||
const points = [];
|
||||
function drawControlPoints(points) {
|
||||
debug
|
||||
.select("#controlPoints")
|
||||
.selectAll("circle")
|
||||
.each(function () {
|
||||
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
|
||||
});
|
||||
|
||||
elSelected.attr("d", round(lineGen(points)));
|
||||
const l = elSelected.node().getTotalLength();
|
||||
routeLength.innerHTML = rn(l * distanceScaleInput.value) + " " + distanceUnitInput.value;
|
||||
|
||||
if (modules.elevation) showEPForRoute(elSelected.node());
|
||||
.data(points)
|
||||
.join("circle")
|
||||
.attr("cx", d => d[0])
|
||||
.attr("cy", d => d[1])
|
||||
.attr("r", 0.6)
|
||||
.call(d3.drag().on("start", dragControlPoint))
|
||||
.on("click", handleControlPointClick);
|
||||
}
|
||||
|
||||
function showElevationProfile() {
|
||||
modules.elevation = true;
|
||||
showEPForRoute(elSelected.node());
|
||||
function drawCells(points) {
|
||||
debug
|
||||
.select("#controlCells")
|
||||
.selectAll("polygon")
|
||||
.data(points)
|
||||
.join("polygon")
|
||||
.attr("points", p => getPackPolygon(p[2]));
|
||||
}
|
||||
|
||||
function showGroupSection() {
|
||||
document.querySelectorAll("#routeEditor > button").forEach(el => (el.style.display = "none"));
|
||||
document.getElementById("routeGroupsSelection").style.display = "inline-block";
|
||||
}
|
||||
function dragControlPoint() {
|
||||
const route = getRoute();
|
||||
const initCell = d3.event.subject[2];
|
||||
const pointIndex = route.points.indexOf(d3.event.subject);
|
||||
|
||||
function hideGroupSection() {
|
||||
document.querySelectorAll("#routeEditor > button").forEach(el => (el.style.display = "inline-block"));
|
||||
document.getElementById("routeGroupsSelection").style.display = "none";
|
||||
document.getElementById("routeGroupName").style.display = "none";
|
||||
document.getElementById("routeGroupName").value = "";
|
||||
document.getElementById("routeGroup").style.display = "inline-block";
|
||||
}
|
||||
d3.event.on("drag", function () {
|
||||
this.setAttribute("cx", d3.event.x);
|
||||
this.setAttribute("cy", d3.event.y);
|
||||
|
||||
function selectRouteGroup(node) {
|
||||
const group = node.parentNode.id;
|
||||
const select = document.getElementById("routeGroup");
|
||||
select.options.length = 0; // remove all options
|
||||
const x = rn(d3.event.x, 2);
|
||||
const y = rn(d3.event.y, 2);
|
||||
const cellId = findCell(x, y);
|
||||
|
||||
routes.selectAll("g").each(function () {
|
||||
select.options.add(new Option(this.id, this.id, false, this.id === group));
|
||||
this.__data__ = route.points[pointIndex] = [x, y, cellId];
|
||||
redrawRoute(route);
|
||||
drawCells(route.points);
|
||||
});
|
||||
}
|
||||
|
||||
function changeRouteGroup() {
|
||||
document.getElementById(this.value).appendChild(elSelected.node());
|
||||
}
|
||||
d3.event.on("end", () => {
|
||||
const movedToCell = findCell(d3.event.x, d3.event.y);
|
||||
|
||||
function toggleNewGroupInput() {
|
||||
if (routeGroupName.style.display === "none") {
|
||||
routeGroupName.style.display = "inline-block";
|
||||
routeGroupName.focus();
|
||||
routeGroup.style.display = "none";
|
||||
} else {
|
||||
routeGroupName.style.display = "none";
|
||||
routeGroup.style.display = "inline-block";
|
||||
}
|
||||
}
|
||||
if (movedToCell !== initCell) {
|
||||
const prev = route.points[pointIndex - 1];
|
||||
if (prev) {
|
||||
removeConnection(initCell, prev[2]);
|
||||
addConnection(movedToCell, prev[2], route.i);
|
||||
}
|
||||
|
||||
function createNewGroup() {
|
||||
if (!this.value) {
|
||||
tip("Please provide a valid group name");
|
||||
return;
|
||||
}
|
||||
const group = this.value
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "_")
|
||||
.replace(/[^\w\s]/gi, "");
|
||||
|
||||
if (document.getElementById(group)) {
|
||||
tip("Element with this id already exists. Please provide a unique name", false, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(+group.charAt(0))) {
|
||||
tip("Group name should start with a letter", false, "error");
|
||||
return;
|
||||
}
|
||||
// just rename if only 1 element left
|
||||
const oldGroup = elSelected.node().parentNode;
|
||||
const basic = ["roads", "trails", "searoutes"].includes(oldGroup.id);
|
||||
if (!basic && oldGroup.childElementCount === 1) {
|
||||
document.getElementById("routeGroup").selectedOptions[0].remove();
|
||||
document.getElementById("routeGroup").options.add(new Option(group, group, false, true));
|
||||
oldGroup.id = group;
|
||||
toggleNewGroupInput();
|
||||
document.getElementById("routeGroupName").value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const newGroup = elSelected.node().parentNode.cloneNode(false);
|
||||
document.getElementById("routes").appendChild(newGroup);
|
||||
newGroup.id = group;
|
||||
document.getElementById("routeGroup").options.add(new Option(group, group, false, true));
|
||||
document.getElementById(group).appendChild(elSelected.node());
|
||||
|
||||
toggleNewGroupInput();
|
||||
document.getElementById("routeGroupName").value = "";
|
||||
}
|
||||
|
||||
function removeRouteGroup() {
|
||||
const group = elSelected.node().parentNode.id;
|
||||
const basic = ["roads", "trails", "searoutes"].includes(group);
|
||||
const count = elSelected.node().parentNode.childElementCount;
|
||||
alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${
|
||||
basic ? "all elements in the group" : "the entire route group"
|
||||
}? <br /><br />Routes to be
|
||||
removed: ${count}`;
|
||||
$("#alert").dialog({
|
||||
resizable: false,
|
||||
title: "Remove route group",
|
||||
buttons: {
|
||||
Remove: function () {
|
||||
$(this).dialog("close");
|
||||
$("#routeEditor").dialog("close");
|
||||
hideGroupSection();
|
||||
if (basic)
|
||||
routes
|
||||
.select("#" + group)
|
||||
.selectAll("path")
|
||||
.remove();
|
||||
else routes.select("#" + group).remove();
|
||||
},
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
const next = route.points[pointIndex + 1];
|
||||
if (next) {
|
||||
removeConnection(initCell, next[2]);
|
||||
addConnection(movedToCell, next[2], route.i);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editGroupStyle() {
|
||||
const g = elSelected.node().parentNode.id;
|
||||
editStyle("routes", g);
|
||||
function redrawRoute(route) {
|
||||
elSelected.attr("d", Routes.getPath(route));
|
||||
updateRouteLength(route);
|
||||
if (byId("elevationProfile").offsetParent) showRouteElevationProfile();
|
||||
}
|
||||
|
||||
function toggleRouteSplitMode() {
|
||||
document.getElementById("routeNew").classList.remove("pressed");
|
||||
function addControlPoint() {
|
||||
const route = getRoute();
|
||||
const [x, y] = d3.mouse(this);
|
||||
const cellId = findCell(x, y);
|
||||
|
||||
const point = [rn(x, 2), rn(y, 2), cellId];
|
||||
const isNewCell = !route.points.some(p => p[2] === cellId);
|
||||
|
||||
const index = getSegmentId(route.points, point, 2);
|
||||
route.points.splice(index, 0, point);
|
||||
|
||||
// check if added point is in new cell
|
||||
if (isNewCell) {
|
||||
const prev = route.points[index - 1];
|
||||
const next = route.points[index + 1];
|
||||
|
||||
if (!prev) ERROR && console.error("Can't add control point to the start of the route");
|
||||
if (!next) ERROR && console.error("Can't add control point to the end of the route");
|
||||
if (!prev || !next) return;
|
||||
|
||||
removeConnection(prev[2], next[2]);
|
||||
addConnection(prev[2], cellId, route.i);
|
||||
addConnection(cellId, next[2], route.i);
|
||||
|
||||
drawCells(route.points);
|
||||
}
|
||||
|
||||
drawControlPoints(route.points);
|
||||
redrawRoute(route);
|
||||
}
|
||||
|
||||
function handleControlPointClick() {
|
||||
const controlPoint = d3.select(this);
|
||||
|
||||
const point = controlPoint.datum();
|
||||
const route = getRoute();
|
||||
const index = route.points.indexOf(point);
|
||||
|
||||
const isSplitMode = byId("routeSplit").classList.contains("pressed");
|
||||
return isSplitMode ? splitRoute() : removeControlPoint(controlPoint);
|
||||
|
||||
function splitRoute() {
|
||||
const oldRoutePoints = route.points.slice(0, index + 1);
|
||||
const newRoutePoints = route.points.slice(index);
|
||||
|
||||
// update old route
|
||||
route.points = oldRoutePoints;
|
||||
drawControlPoints(route.points);
|
||||
drawCells(route.points);
|
||||
redrawRoute(route);
|
||||
|
||||
// create new route
|
||||
const newRoute = {
|
||||
i: Math.max(...pack.routes.map(route => route.i)) + 1,
|
||||
group: route.group,
|
||||
feature: route.feature,
|
||||
name: route.name,
|
||||
points: newRoutePoints
|
||||
};
|
||||
pack.routes.push(newRoute);
|
||||
|
||||
for (let i = 0; i < newRoute.points.length; i++) {
|
||||
const cellId = newRoute.points[i][2];
|
||||
const nextPoint = newRoute.points[i + 1];
|
||||
if (nextPoint) addConnection(cellId, nextPoint[2], newRoute.i);
|
||||
}
|
||||
|
||||
routes
|
||||
.select("#" + newRoute.group)
|
||||
.append("path")
|
||||
.attr("d", Routes.getPath(newRoute))
|
||||
.attr("id", "route" + newRoute.i);
|
||||
|
||||
byId("routeSplit").classList.remove("pressed");
|
||||
}
|
||||
|
||||
function removeControlPoint(controlPoint) {
|
||||
const isOnlyPointInCell = route.points.filter(p => p[2] === point[2]).length === 1;
|
||||
if (isOnlyPointInCell) {
|
||||
const prev = route.points[index - 1];
|
||||
const next = route.points[index + 1];
|
||||
if (prev) removeConnection(prev[2], point[2]);
|
||||
if (next) removeConnection(point[2], next[2]);
|
||||
if (prev && next) addConnection(prev[2], next[2], route.i);
|
||||
}
|
||||
|
||||
controlPoint.remove();
|
||||
route.points = route.points.filter(p => p !== point);
|
||||
|
||||
drawCells(route.points);
|
||||
redrawRoute(route);
|
||||
}
|
||||
}
|
||||
|
||||
function openJoinRoutesDialog() {
|
||||
const route = getRoute();
|
||||
const firstCell = route.points.at(0)[2];
|
||||
const lastCell = route.points.at(-1)[2];
|
||||
|
||||
const candidateRoutes = pack.routes.filter(r => {
|
||||
if (r.i === route.i) return false;
|
||||
if (r.group !== route.group) return false;
|
||||
if (r.points.at(0)[2] === lastCell) return true;
|
||||
if (r.points.at(-1)[2] === firstCell) return true;
|
||||
if (r.points.at(0)[2] === firstCell) return true;
|
||||
if (r.points.at(-1)[2] === lastCell) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (candidateRoutes.length) {
|
||||
const options = candidateRoutes.map(r => {
|
||||
r.name = r.name || Routes.generateName(r);
|
||||
r.length = r.length || Routes.getLength(r.i);
|
||||
const length = rn(r.length * distanceScale) + " " + distanceUnitInput.value;
|
||||
return `<option value="${r.i}">${r.name} (${length})</option>`;
|
||||
});
|
||||
alertMessage.innerHTML = /* html */ `<div>Route to join with:
|
||||
<select>${options.join("")}</select>
|
||||
</div>`;
|
||||
|
||||
$("#alert").dialog({
|
||||
title: "Join routes",
|
||||
width: fitContent(),
|
||||
position: {my: "left top", at: "left+10 top+150", of: "#map"},
|
||||
buttons: {
|
||||
Cancel: () => {
|
||||
$("#alert").dialog("close");
|
||||
},
|
||||
Join: () => {
|
||||
const selectedRouteId = +alertMessage.querySelector("select").value;
|
||||
const selectedRoute = pack.routes.find(r => r.i === selectedRouteId);
|
||||
joinRoutes(route, selectedRoute);
|
||||
tip("Routes joined", false, "success", 5000);
|
||||
$("#alert").dialog("close");
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tip("No routes to join with. Route must start or end at current route's start or end cell", false, "error", 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function joinRoutes(route, joinedRoute) {
|
||||
if (route.points.at(-1)[2] === joinedRoute.points.at(0)[2]) {
|
||||
// joinedRoute starts at the end of current route
|
||||
route.points = [...route.points, ...joinedRoute.points.slice(1)];
|
||||
} else if (route.points.at(0)[2] === joinedRoute.points.at(-1)[2]) {
|
||||
// joinedRoute ends at the start of current route
|
||||
route.points = [...joinedRoute.points, ...route.points.slice(1)];
|
||||
} else if (route.points.at(0)[2] === joinedRoute.points.at(0)[2]) {
|
||||
// joinedRoute and current route both start at the same cell
|
||||
route.points = [...route.points.reverse(), ...joinedRoute.points.slice(1)];
|
||||
} else if (route.points.at(-1)[2] === joinedRoute.points.at(-1)[2]) {
|
||||
// joinedRoute and current route both end at the same cell
|
||||
route.points = [...route.points, ...joinedRoute.points.reverse().slice(1)];
|
||||
}
|
||||
|
||||
for (let i = 0; i < route.points.length; i++) {
|
||||
const point = route.points[i];
|
||||
const nextPoint = route.points[i + 1];
|
||||
if (nextPoint) addConnection(point[2], nextPoint[2], route.i);
|
||||
}
|
||||
|
||||
Routes.remove(joinedRoute);
|
||||
drawControlPoints(route.points);
|
||||
redrawRoute(route);
|
||||
drawCells(route.points);
|
||||
}
|
||||
|
||||
function showCreationDialog() {
|
||||
const route = getRoute();
|
||||
createRoute(route.group);
|
||||
}
|
||||
|
||||
function togglePressed() {
|
||||
this.classList.toggle("pressed");
|
||||
}
|
||||
|
||||
function clickControlPoint() {
|
||||
if (routeSplit.classList.contains("pressed")) splitRoute(this);
|
||||
else {
|
||||
this.remove();
|
||||
redrawRoute();
|
||||
}
|
||||
function removeConnection(from, to) {
|
||||
const routes = pack.cells.routes;
|
||||
if (routes[from]) delete routes[from][to];
|
||||
if (routes[to]) delete routes[to][from];
|
||||
}
|
||||
|
||||
function splitRoute(clicked) {
|
||||
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
|
||||
const group = d3.select(elSelected.node().parentNode);
|
||||
routeSplit.classList.remove("pressed");
|
||||
function addConnection(from, to, routeId) {
|
||||
const routes = pack.cells.routes;
|
||||
|
||||
const points1 = [],
|
||||
points2 = [];
|
||||
let points = points1;
|
||||
debug
|
||||
.select("#controlPoints")
|
||||
.selectAll("circle")
|
||||
.each(function () {
|
||||
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
|
||||
if (this === clicked) {
|
||||
points = points2;
|
||||
points.push([this.getAttribute("cx"), this.getAttribute("cy")]);
|
||||
}
|
||||
this.remove();
|
||||
});
|
||||
if (!routes[from]) routes[from] = {};
|
||||
routes[from][to] = routeId;
|
||||
|
||||
elSelected.attr("d", round(lineGen(points1)));
|
||||
const id = getNextId("route");
|
||||
group.append("path").attr("id", id).attr("d", lineGen(points2));
|
||||
debug.select("#controlPoints").selectAll("circle").remove();
|
||||
drawControlPoints(elSelected.node());
|
||||
if (!routes[to]) routes[to] = {};
|
||||
routes[to][from] = routeId;
|
||||
}
|
||||
|
||||
function toggleRouteCreationMode() {
|
||||
document.getElementById("routeSplit").classList.remove("pressed");
|
||||
document.getElementById("routeNew").classList.toggle("pressed");
|
||||
if (document.getElementById("routeNew").classList.contains("pressed")) {
|
||||
tip("Click on map to add control points", true);
|
||||
viewbox.on("click", addPointOnClick).style("cursor", "crosshair");
|
||||
elSelected.on("click", null);
|
||||
} else {
|
||||
clearMainTip();
|
||||
viewbox.on("click", clicked).style("cursor", "default");
|
||||
elSelected.on("click", addInterimControlPoint).attr("data-new", null);
|
||||
}
|
||||
function changeName() {
|
||||
getRoute().name = this.value;
|
||||
}
|
||||
|
||||
function addPointOnClick() {
|
||||
// create new route
|
||||
if (!elSelected.attr("data-new")) {
|
||||
debug.select("#controlPoints").selectAll("circle").remove();
|
||||
const parent = elSelected.node().parentNode;
|
||||
const id = getNextId("route");
|
||||
elSelected = d3.select(parent).append("path").attr("id", id).attr("data-new", 1);
|
||||
}
|
||||
function changeGroup() {
|
||||
const group = this.value;
|
||||
byId(group).appendChild(elSelected.node());
|
||||
getRoute().group = group;
|
||||
}
|
||||
|
||||
addControlPoint(d3.mouse(this));
|
||||
redrawRoute();
|
||||
function generateName() {
|
||||
const route = getRoute();
|
||||
route.name = routeName.value = Routes.generateName(route);
|
||||
}
|
||||
|
||||
function showRouteElevationProfile() {
|
||||
const route = getRoute();
|
||||
const length = rn(route.length * distanceScale);
|
||||
showElevationProfile(
|
||||
route.points.map(p => p[2]),
|
||||
length,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function editRouteLegend() {
|
||||
const id = elSelected.attr("id");
|
||||
editNotes(id, id);
|
||||
const route = getRoute();
|
||||
editNotes(id, route.name);
|
||||
}
|
||||
|
||||
function editRouteGroupStyle() {
|
||||
const {group} = getRoute();
|
||||
editStyle("routes", group);
|
||||
}
|
||||
|
||||
function toggleLockButton() {
|
||||
const route = getRoute();
|
||||
route.lock = !route.lock;
|
||||
updateLockIcon();
|
||||
}
|
||||
|
||||
function updateLockIcon() {
|
||||
const route = getRoute();
|
||||
if (route.lock) {
|
||||
byId("routeLock").classList.remove("icon-lock-open");
|
||||
byId("routeLock").classList.add("icon-lock");
|
||||
} else {
|
||||
byId("routeLock").classList.remove("icon-lock");
|
||||
byId("routeLock").classList.add("icon-lock-open");
|
||||
}
|
||||
}
|
||||
|
||||
function removeRoute() {
|
||||
alertMessage.innerHTML = "Are you sure you want to remove the route?";
|
||||
alertMessage.innerHTML = "Are you sure you want to remove the route";
|
||||
$("#alert").dialog({
|
||||
resizable: false,
|
||||
width: "22em",
|
||||
title: "Remove route",
|
||||
buttons: {
|
||||
Remove: function () {
|
||||
Routes.remove(getRoute());
|
||||
$(this).dialog("close");
|
||||
elSelected.remove();
|
||||
$("#routeEditor").dialog("close");
|
||||
},
|
||||
Cancel: function () {
|
||||
|
|
@ -312,12 +407,16 @@ function editRoute(onClick) {
|
|||
});
|
||||
}
|
||||
|
||||
function closeRoutesEditor() {
|
||||
elSelected.attr("data-new", null).on("click", null);
|
||||
clearMainTip();
|
||||
routeSplit.classList.remove("pressed");
|
||||
routeNew.classList.remove("pressed");
|
||||
function closeRouteEditor() {
|
||||
debug.select("#controlPoints").remove();
|
||||
debug.select("#controlCells").remove();
|
||||
|
||||
elSelected.on("click", null);
|
||||
unselect();
|
||||
clearMainTip();
|
||||
|
||||
const forced = +byId("toggleCells").dataset.forced;
|
||||
byId("toggleCells").dataset.forced = 0;
|
||||
if (forced && layerIsOn("toggleCells")) toggleCells();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
187
modules/ui/routes-overview.js
Normal file
187
modules/ui/routes-overview.js
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"use strict";
|
||||
|
||||
function overviewRoutes() {
|
||||
if (customization) return;
|
||||
closeDialogs("#routesOverview, .stable");
|
||||
if (!layerIsOn("toggleRoutes")) toggleRoutes();
|
||||
|
||||
const body = byId("routesBody");
|
||||
routesOverviewAddLines();
|
||||
$("#routesOverview").dialog();
|
||||
|
||||
if (modules.overviewRoutes) return;
|
||||
modules.overviewRoutes = true;
|
||||
|
||||
$("#routesOverview").dialog({
|
||||
title: "Routes Overview",
|
||||
resizable: false,
|
||||
width: fitContent(),
|
||||
position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}
|
||||
});
|
||||
|
||||
// add listeners
|
||||
byId("routesOverviewRefresh").on("click", routesOverviewAddLines);
|
||||
byId("routesCreateNew").on("click", createRoute);
|
||||
byId("routesExport").on("click", downloadRoutesData);
|
||||
byId("routesLockAll").on("click", toggleLockAll);
|
||||
byId("routesRemoveAll").on("click", triggerAllRoutesRemove);
|
||||
|
||||
// add line for each route
|
||||
function routesOverviewAddLines() {
|
||||
body.innerHTML = "";
|
||||
let lines = "";
|
||||
|
||||
for (const route of pack.routes) {
|
||||
route.name = route.name || Routes.generateName(route);
|
||||
route.length = route.length || Routes.getLength(route.i);
|
||||
const length = rn(route.length * distanceScale) + " " + distanceUnitInput.value;
|
||||
|
||||
lines += /* html */ `<div
|
||||
class="states"
|
||||
data-id="${route.i}"
|
||||
data-name="${route.name}"
|
||||
data-group="${route.group}"
|
||||
data-length="${route.length}"
|
||||
>
|
||||
<span data-tip="Click to focus on route" class="icon-dot-circled pointer"></span>
|
||||
<div data-tip="Route name" style="width: 15em; margin-left: 0.4em;">${route.name}</div>
|
||||
<div data-tip="Route group" style="width: 8em;">${route.group}</div>
|
||||
<div data-tip="Route length" style="width: 6em;">${length}</div>
|
||||
<span data-tip="Edit route" class="icon-pencil"></span>
|
||||
<span class="locks pointer ${
|
||||
route.lock ? "icon-lock" : "icon-lock-open inactive"
|
||||
}" onmouseover="showElementLockTip(event)"></span>
|
||||
<span data-tip="Remove route" class="icon-trash-empty"></span>
|
||||
</div>`;
|
||||
}
|
||||
body.insertAdjacentHTML("beforeend", lines);
|
||||
|
||||
// update footer
|
||||
routesFooterNumber.innerHTML = pack.routes.length;
|
||||
const averageLength = rn(d3.mean(pack.routes.map(r => r.length)));
|
||||
routesFooterLength.innerHTML = averageLength * distanceScale + " " + distanceUnitInput.value;
|
||||
|
||||
// add listeners
|
||||
body.querySelectorAll("div.states").forEach(el => el.on("mouseenter", routeHighlightOn));
|
||||
body.querySelectorAll("div.states").forEach(el => el.on("mouseleave", routeHighlightOff));
|
||||
body.querySelectorAll("div > span.icon-dot-circled").forEach(el => el.on("click", zoomToRoute));
|
||||
body.querySelectorAll("div > span.icon-pencil").forEach(el => el.on("click", openRouteEditor));
|
||||
body.querySelectorAll("div > span.locks").forEach(el => el.addEventListener("click", toggleLockStatus));
|
||||
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.on("click", triggerRouteRemove));
|
||||
|
||||
applySorting(routesHeader);
|
||||
}
|
||||
|
||||
function routeHighlightOn(event) {
|
||||
if (!layerIsOn("toggleRoutes")) toggleRoutes();
|
||||
const routeId = +event.target.dataset.id;
|
||||
routes
|
||||
.select("#route" + routeId)
|
||||
.attr("stroke", "red")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke-dasharray", "none");
|
||||
}
|
||||
|
||||
function routeHighlightOff(e) {
|
||||
const routeId = +e.target.dataset.id;
|
||||
routes
|
||||
.select("#route" + routeId)
|
||||
.attr("stroke", null)
|
||||
.attr("stroke-width", null)
|
||||
.attr("stroke-dasharray", null);
|
||||
}
|
||||
|
||||
function zoomToRoute() {
|
||||
const r = +this.parentNode.dataset.id;
|
||||
const route = routes.select("#route" + r).node();
|
||||
highlightElement(route, 3);
|
||||
}
|
||||
|
||||
function downloadRoutesData() {
|
||||
let data = "Id,Route,Group,Length\n"; // headers
|
||||
|
||||
body.querySelectorAll(":scope > div").forEach(function (el) {
|
||||
const d = el.dataset;
|
||||
const length = rn(d.length * distanceScale) + " " + distanceUnitInput.value;
|
||||
data += [d.id, d.name, d.group, length].join(",") + "\n";
|
||||
});
|
||||
|
||||
const name = getFileName("Routes") + ".csv";
|
||||
downloadFile(data, name);
|
||||
}
|
||||
|
||||
function openRouteEditor() {
|
||||
const id = "route" + this.parentNode.dataset.id;
|
||||
editRoute(id);
|
||||
}
|
||||
|
||||
function toggleLockStatus() {
|
||||
const routeId = +this.parentNode.dataset.id;
|
||||
const route = pack.routes[routeId];
|
||||
route.lock = !route.lock;
|
||||
|
||||
if (this.classList.contains("icon-lock")) {
|
||||
this.classList.remove("icon-lock");
|
||||
this.classList.add("icon-lock-open");
|
||||
this.classList.add("inactive");
|
||||
} else {
|
||||
this.classList.remove("icon-lock-open");
|
||||
this.classList.add("icon-lock");
|
||||
this.classList.remove("inactive");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLockAll() {
|
||||
const allLocked = pack.routes.every(route => route.lock);
|
||||
|
||||
pack.routes.forEach(route => {
|
||||
route.lock = !allLocked;
|
||||
});
|
||||
|
||||
routesOverviewAddLines();
|
||||
byId("routesLockAll").className = allLocked ? "icon-lock" : "icon-lock-open";
|
||||
}
|
||||
|
||||
function triggerRouteRemove() {
|
||||
const routeId = +this.parentNode.dataset.id;
|
||||
|
||||
alertMessage.innerHTML = `Are you sure you want to remove the route?`;
|
||||
$("#alert").dialog({
|
||||
resizable: false,
|
||||
width: "22em",
|
||||
title: "Remove route",
|
||||
buttons: {
|
||||
Remove: function () {
|
||||
const route = pack.routes.find(r => r.i === routeId);
|
||||
Routes.remove(route);
|
||||
routesOverviewAddLines();
|
||||
$(this).dialog("close");
|
||||
},
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function triggerAllRoutesRemove() {
|
||||
alertMessage.innerHTML = /* html */ `Are you sure you want to remove all routes? This action can't be undone`;
|
||||
$("#alert").dialog({
|
||||
resizable: false,
|
||||
title: "Remove all routes",
|
||||
buttons: {
|
||||
Remove: function () {
|
||||
pack.cells.routes = {};
|
||||
pack.routes = [];
|
||||
routes.selectAll("path").remove();
|
||||
|
||||
$(this).dialog("close");
|
||||
$("#routesOverview").dialog("close");
|
||||
},
|
||||
Cancel: function () {
|
||||
$(this).dialog("close");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -503,7 +503,7 @@ styleGridScale.addEventListener("input", function () {
|
|||
|
||||
function calculateFriendlyGridSize() {
|
||||
const size = styleGridScale.value * 25;
|
||||
const friendly = `${rn(size * distanceScaleInput.value, 2)} ${distanceUnitInput.value}`;
|
||||
const friendly = `${rn(size * distanceScale, 2)} ${distanceUnitInput.value}`;
|
||||
styleGridSizeFriendly.value = friendly;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -258,11 +258,16 @@ window.UISubmap = (function () {
|
|||
byId("latitudeInput").value = latitudeOutput.value;
|
||||
|
||||
// fix scale
|
||||
distanceScaleInput.value = distanceScaleOutput.value = rn((distanceScale = distanceScaleOutput.value / scale), 2);
|
||||
distanceScale =
|
||||
distanceScaleInput.value =
|
||||
distanceScaleOutput.value =
|
||||
rn((distanceScale = distanceScaleOutput.value / scale), 2);
|
||||
|
||||
populationRateInput.value = populationRateOutput.value = rn(
|
||||
(populationRate = populationRateOutput.value / scale),
|
||||
2
|
||||
);
|
||||
|
||||
customization = 0;
|
||||
startResample(options);
|
||||
}, 1000);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ toolsContent.addEventListener("click", function (event) {
|
|||
else if (button === "editZonesButton") editZones();
|
||||
else if (button === "overviewChartsButton") overviewCharts();
|
||||
else if (button === "overviewBurgsButton") overviewBurgs();
|
||||
else if (button === "overviewRoutesButton") overviewRoutes();
|
||||
else if (button === "overviewRiversButton") overviewRivers();
|
||||
else if (button === "overviewMilitaryButton") overviewMilitary();
|
||||
else if (button === "overviewMarkersButton") overviewMarkers();
|
||||
|
|
@ -66,7 +67,7 @@ toolsContent.addEventListener("click", function (event) {
|
|||
if (button === "addLabel") toggleAddLabel();
|
||||
else if (button === "addBurgTool") toggleAddBurg();
|
||||
else if (button === "addRiver") toggleAddRiver();
|
||||
else if (button === "addRoute") toggleAddRoute();
|
||||
else if (button === "addRoute") createRoute();
|
||||
else if (button === "addMarker") toggleAddMarker();
|
||||
// click to create a new map buttons
|
||||
else if (button === "openSubmapMenu") UISubmap.openSubmapMenu();
|
||||
|
|
@ -79,7 +80,7 @@ function processFeatureRegeneration(event, button) {
|
|||
ReliefIcons();
|
||||
if (!layerIsOn("toggleRelief")) toggleRelief();
|
||||
} else if (button === "regenerateRoutes") {
|
||||
Routes.regenerate();
|
||||
regenerateRoutes();
|
||||
if (!layerIsOn("toggleRoutes")) toggleRoutes();
|
||||
} else if (button === "regenerateRivers") regenerateRivers();
|
||||
else if (button === "regeneratePopulation") recalculatePopulation();
|
||||
|
|
@ -115,6 +116,14 @@ async function openEmblemEditor() {
|
|||
editEmblem(type, id, el);
|
||||
}
|
||||
|
||||
function regenerateRoutes() {
|
||||
const locked = pack.routes.filter(route => route.lock).map((route, index) => ({...route, i: index}));
|
||||
Routes.generate(locked);
|
||||
|
||||
routes.selectAll("path").remove();
|
||||
if (layerIsOn("toggleRoutes")) drawRoutes();
|
||||
}
|
||||
|
||||
function regenerateRivers() {
|
||||
Rivers.generate();
|
||||
Lakes.defineGroup();
|
||||
|
|
@ -129,7 +138,7 @@ function recalculatePopulation() {
|
|||
if (!b.i || b.removed || b.lock) return;
|
||||
const i = b.cell;
|
||||
|
||||
b.population = rn(Math.max((pack.cells.s[i] + pack.cells.road[i] / 2) / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
|
||||
b.population = rn(Math.max(pack.cells.s[i] / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3);
|
||||
if (b.capital) b.population = b.population * 1.3; // increase capital population
|
||||
if (b.port) b.population = b.population * 1.3; // increase port population
|
||||
b.population = rn(b.population * gauss(2, 3, 0.6, 20, 3), 3);
|
||||
|
|
@ -429,7 +438,7 @@ function regenerateBurgs() {
|
|||
BurgsAndStates.specifyBurgs();
|
||||
BurgsAndStates.defineBurgFeatures();
|
||||
BurgsAndStates.drawBurgs();
|
||||
Routes.regenerate();
|
||||
regenerateRoutes();
|
||||
|
||||
// remove emblems
|
||||
document.querySelectorAll("[id^=burgCOA]").forEach(el => el.remove());
|
||||
|
|
@ -792,34 +801,6 @@ function addRiverOnClick() {
|
|||
}
|
||||
}
|
||||
|
||||
function toggleAddRoute() {
|
||||
const pressed = document.getElementById("addRoute").classList.contains("pressed");
|
||||
if (pressed) {
|
||||
unpressClickToAddButton();
|
||||
return;
|
||||
}
|
||||
|
||||
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
|
||||
addRoute.classList.add("pressed");
|
||||
closeDialogs(".stable");
|
||||
viewbox.style("cursor", "crosshair").on("click", addRouteOnClick);
|
||||
tip("Click on map to add a first control point", true);
|
||||
if (!layerIsOn("toggleRoutes")) toggleRoutes();
|
||||
}
|
||||
|
||||
function addRouteOnClick() {
|
||||
unpressClickToAddButton();
|
||||
const point = d3.mouse(this);
|
||||
const id = getNextId("route");
|
||||
elSelected = routes
|
||||
.select("g")
|
||||
.append("path")
|
||||
.attr("id", id)
|
||||
.attr("data-new", 1)
|
||||
.attr("d", `M${point[0]},${point[1]}`);
|
||||
editRoute(true);
|
||||
}
|
||||
|
||||
function toggleAddMarker() {
|
||||
const pressed = document.getElementById("addMarker")?.classList.contains("pressed");
|
||||
if (pressed) {
|
||||
|
|
@ -960,6 +941,6 @@ function viewCellDetails() {
|
|||
}
|
||||
|
||||
async function overviewCharts() {
|
||||
const Overview = await import("../dynamic/overview/charts-overview.js?v=1.89.24");
|
||||
const Overview = await import("../dynamic/overview/charts-overview.js?v=1.99.00");
|
||||
Overview.open();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ function editUnits() {
|
|||
}
|
||||
|
||||
function changeDistanceScale() {
|
||||
distanceScale = +this.value;
|
||||
renderScaleBar();
|
||||
calculateFriendlyGridSize();
|
||||
}
|
||||
|
|
@ -90,10 +91,9 @@ function editUnits() {
|
|||
}
|
||||
|
||||
function restoreDefaultUnits() {
|
||||
// distanceScale
|
||||
distanceScale = 3;
|
||||
byId("distanceScaleOutput").value = 3;
|
||||
byId("distanceScaleInput").value = 3;
|
||||
byId("distanceScaleOutput").value = distanceScale;
|
||||
byId("distanceScaleInput").value = distanceScale;
|
||||
unlock("distanceScale");
|
||||
|
||||
// units
|
||||
|
|
@ -179,13 +179,15 @@ function editUnits() {
|
|||
tip("Draw a curve along routes to measure length. Hold Shift to measure away from roads.", true);
|
||||
unitsBottom.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed"));
|
||||
this.classList.add("pressed");
|
||||
|
||||
viewbox.style("cursor", "crosshair").call(
|
||||
d3.drag().on("start", function () {
|
||||
const cells = pack.cells;
|
||||
const burgs = pack.burgs;
|
||||
const point = d3.mouse(this);
|
||||
const c = findCell(point[0], point[1]);
|
||||
if (cells.road[c] || d3.event.sourceEvent.shiftKey) {
|
||||
|
||||
if (Routes.isConnected(c) || d3.event.sourceEvent.shiftKey) {
|
||||
const b = cells.burg[c];
|
||||
const x = b ? burgs[b].x : cells.p[c][0];
|
||||
const y = b ? burgs[b].y : cells.p[c][1];
|
||||
|
|
@ -194,7 +196,7 @@ function editUnits() {
|
|||
d3.event.on("drag", function () {
|
||||
const point = d3.mouse(this);
|
||||
const c = findCell(point[0], point[1]);
|
||||
if (cells.road[c] || d3.event.sourceEvent.shiftKey) {
|
||||
if (Routes.isConnected(c) || d3.event.sourceEvent.shiftKey) {
|
||||
routeOpisometer.trackCell(c, true);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -105,13 +105,12 @@ function editWorld() {
|
|||
|
||||
calculateMapCoordinates();
|
||||
const mc = mapCoordinates;
|
||||
const scale = +distanceScaleInput.value;
|
||||
const unit = distanceUnitInput.value;
|
||||
const meridian = toKilometer(eqD * 2 * scale);
|
||||
const meridian = toKilometer(eqD * 2 * distanceScale);
|
||||
byId("mapSize").innerHTML = `${graphWidth}x${graphHeight}`;
|
||||
byId("mapSizeFriendly").innerHTML = `${rn(graphWidth * scale)}x${rn(graphHeight * scale)} ${unit}`;
|
||||
byId("mapSizeFriendly").innerHTML = `${rn(graphWidth * distanceScale)}x${rn(graphHeight * distanceScale)} ${unit}`;
|
||||
byId("meridianLength").innerHTML = rn(eqD * 2);
|
||||
byId("meridianLengthFriendly").innerHTML = `${rn(eqD * 2 * scale)} ${unit}`;
|
||||
byId("meridianLengthFriendly").innerHTML = `${rn(eqD * 2 * distanceScale)} ${unit}`;
|
||||
byId("meridianLengthEarth").innerHTML = meridian ? " = " + rn(meridian / 200) + "%🌏" : "";
|
||||
byId("mapCoordinates").innerHTML = `${lat(mc.latN)} ${Math.abs(rn(mc.lonW))}°W; ${lat(mc.latS)} ${rn(mc.lonE)}°E`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue