[Draft] Submap refactoring (#1153)

* refactor: submap - start

* refactor: submap - continue

* Merge branch 'master' of https://github.com/Azgaar/Fantasy-Map-Generator into submap-refactoring

* refactor: submap - relocate burgs

* refactor: submap - restore routes

* refactor: submap - restore lake names

* refactor: submap - UI update

* refactor: submap - restore river and biome data

* refactor: submap - simplify options

* refactor: submap - restore rivers

* refactor: submap - recalculateMapSize

* refactor: submap - add middle points

* refactor: submap - don't add middle points, unified findPath fn

* chore: update version

* feat: submap - relocate out of map regiments

* feat: submap - fix route gen

* feat: submap - allow custom number of cells

* feat: submap - add checkbox submapRescaleBurgStyles

* feat: submap - update version hash

* chore: supporters update

---------

Co-authored-by: Azgaar <azgaar.fmg@yandex.com>
This commit is contained in:
Azgaar 2024-12-12 13:11:54 +01:00 committed by GitHub
parent 23f36c3210
commit 66d22f26c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1043 additions and 745 deletions

View file

@ -880,6 +880,7 @@ window.BurgsAndStates = (() => {
generateDiplomacy,
defineStateForms,
getFullName,
updateCultures
updateCultures,
getCloseToEdgePoint
};
})();

365
modules/resample.js Normal file
View file

@ -0,0 +1,365 @@
"use strict";
window.Resample = (function () {
/*
generate new map based on an existing one (resampling parentMap)
parentMap: {grid, pack, notes} from original map
projection: f(Number, Number) -> [Number, Number]
inverse: f(Number, Number) -> [Number, Number]
scale: Number
*/
function process({projection, inverse, scale}) {
const parentMap = {grid: deepCopy(grid), pack: deepCopy(pack), notes: deepCopy(notes)};
const riversData = saveRiversData(pack.rivers);
grid = generateGrid();
pack = {};
notes = parentMap.notes;
resamplePrimaryGridData(parentMap, inverse, scale);
Features.markupGrid();
addLakesInDeepDepressions();
openNearSeaLakes();
OceanLayers();
calculateMapCoordinates();
calculateTemperatures();
reGraph();
Features.markupPack();
createDefaultRuler();
restoreCellData(parentMap, inverse, scale);
restoreRivers(riversData, projection, scale);
restoreCultures(parentMap, projection);
restoreBurgs(parentMap, projection, scale);
restoreStates(parentMap, projection);
restoreRoutes(parentMap, projection);
restoreReligions(parentMap, projection);
restoreProvinces(parentMap);
restoreFeatureDetails(parentMap, inverse);
restoreMarkers(parentMap, projection);
restoreZones(parentMap, projection, scale);
showStatistics();
}
function resamplePrimaryGridData(parentMap, inverse, scale) {
grid.cells.h = new Uint8Array(grid.points.length);
grid.cells.temp = new Int8Array(grid.points.length);
grid.cells.prec = new Uint8Array(grid.points.length);
grid.points.forEach(([x, y], newGridCell) => {
const [parentX, parentY] = inverse(x, y);
const parentPackCell = parentMap.pack.cells.q.find(parentX, parentY, Infinity)[2];
const parentGridCell = parentMap.pack.cells.g[parentPackCell];
grid.cells.h[newGridCell] = parentMap.grid.cells.h[parentGridCell];
grid.cells.temp[newGridCell] = parentMap.grid.cells.temp[parentGridCell];
grid.cells.prec[newGridCell] = parentMap.grid.cells.prec[parentGridCell];
});
if (scale >= 2) smoothHeightmap();
}
function smoothHeightmap() {
grid.cells.h.forEach((height, newGridCell) => {
const heights = [height, ...grid.cells.c[newGridCell].map(c => grid.cells.h[c])];
const meanHeight = d3.mean(heights);
grid.cells.h[newGridCell] = isWater(grid, newGridCell) ? Math.min(meanHeight, 19) : Math.max(meanHeight, 20);
});
}
function restoreCellData(parentMap, inverse, scale) {
pack.cells.biome = new Uint8Array(pack.cells.i.length);
pack.cells.fl = new Uint16Array(pack.cells.i.length);
pack.cells.s = new Int16Array(pack.cells.i.length);
pack.cells.pop = new Float32Array(pack.cells.i.length);
pack.cells.culture = new Uint16Array(pack.cells.i.length);
pack.cells.state = new Uint16Array(pack.cells.i.length);
pack.cells.burg = new Uint16Array(pack.cells.i.length);
pack.cells.religion = new Uint16Array(pack.cells.i.length);
pack.cells.province = new Uint16Array(pack.cells.i.length);
const parentPackCellGroups = groupCellsByType(parentMap.pack);
const parentPackLandCellsQuadtree = d3.quadtree(parentPackCellGroups.land);
for (const newPackCell of pack.cells.i) {
const [x, y] = inverse(...pack.cells.p[newPackCell]);
if (isWater(pack, newPackCell)) continue;
const parentPackCell = parentPackLandCellsQuadtree.find(x, y, Infinity)[2];
const parentCellArea = parentMap.pack.cells.area[parentPackCell];
const areaRatio = pack.cells.area[newPackCell] / parentCellArea;
const scaleRatio = areaRatio / scale;
pack.cells.biome[newPackCell] = parentMap.pack.cells.biome[parentPackCell];
pack.cells.fl[newPackCell] = parentMap.pack.cells.fl[parentPackCell];
pack.cells.s[newPackCell] = parentMap.pack.cells.s[parentPackCell] * scaleRatio;
pack.cells.pop[newPackCell] = parentMap.pack.cells.pop[parentPackCell] * scaleRatio;
pack.cells.culture[newPackCell] = parentMap.pack.cells.culture[parentPackCell];
pack.cells.state[newPackCell] = parentMap.pack.cells.state[parentPackCell];
pack.cells.religion[newPackCell] = parentMap.pack.cells.religion[parentPackCell];
pack.cells.province[newPackCell] = parentMap.pack.cells.province[parentPackCell];
}
}
function saveRiversData(parentRivers) {
return parentRivers.map(river => {
const meanderedPoints = Rivers.addMeandering(river.cells, river.points);
return {...river, meanderedPoints};
});
}
function restoreRivers(riversData, projection, scale) {
pack.cells.r = new Uint16Array(pack.cells.i.length);
pack.cells.conf = new Uint8Array(pack.cells.i.length);
pack.rivers = riversData
.map(river => {
let wasInMap = true;
const points = [];
river.meanderedPoints.forEach(([parentX, parentY]) => {
const [x, y] = projection(parentX, parentY);
const inMap = isInMap(x, y);
if (inMap || wasInMap) points.push([rn(x, 2), rn(y, 2)]);
wasInMap = inMap;
});
if (points.length < 2) return null;
const cells = points.map(point => findCell(...point));
cells.forEach(cellId => {
if (pack.cells.r[cellId]) pack.cells.conf[cellId] = 1;
pack.cells.r[cellId] = river.i;
});
const widthFactor = river.widthFactor * scale;
return {...river, cells, points, source: cells.at(0), mouth: cells.at(-2), widthFactor};
})
.filter(Boolean);
pack.rivers.forEach(river => {
river.basin = Rivers.getBasin(river.i);
river.length = Rivers.getApproximateLength(river.points);
});
}
function restoreCultures(parentMap, projection) {
const validCultures = new Set(pack.cells.culture);
const culturePoles = getPolesOfInaccessibility(pack, cellId => pack.cells.culture[cellId]);
pack.cultures = parentMap.pack.cultures.map(culture => {
if (!culture.i || culture.removed) return culture;
if (!validCultures.has(culture.i)) return {...culture, removed: true, lock: false};
const [xp, yp] = projection(...parentMap.pack.cells.p[culture.center]);
const [x, y] = [rn(xp, 2), rn(yp, 2)];
const centerCoords = isInMap(x, y) ? [x, y] : culturePoles[culture.i];
const center = findCell(...centerCoords);
return {...culture, center};
});
}
function restoreBurgs(parentMap, projection, scale) {
const packLandCellsQuadtree = d3.quadtree(groupCellsByType(pack).land);
const findLandCell = (x, y) => packLandCellsQuadtree.find(x, y, Infinity)?.[2];
pack.burgs = parentMap.pack.burgs.map(burg => {
if (!burg.i || burg.removed) return burg;
burg.population *= scale; // adjust for populationRate change
const [xp, yp] = projection(burg.x, burg.y);
if (!isInMap(xp, yp)) return {...burg, removed: true, lock: false};
const closestCell = findCell(xp, yp);
const cell = isWater(pack, closestCell) ? findLandCell(xp, yp) : closestCell;
if (pack.cells.burg[cell]) {
WARN && console.warn(`Cell ${cell} already has a burg. Removing burg ${burg.name} (${burg.i})`);
return {...burg, removed: true, lock: false};
}
pack.cells.burg[cell] = burg.i;
const [x, y] = getBurgCoordinates(burg, closestCell, cell, xp, yp);
return {...burg, cell, x, y};
});
function getBurgCoordinates(burg, closestCell, cell, xp, yp) {
const haven = pack.cells.haven[cell];
if (burg.port && haven) return BurgsAndStates.getCloseToEdgePoint(cell, haven);
if (closestCell !== cell) return pack.cells.p[cell];
return [rn(xp, 2), rn(yp, 2)];
}
}
function restoreStates(parentMap, projection) {
const validStates = new Set(pack.cells.state);
pack.states = parentMap.pack.states.map(state => {
if (!state.i || state.removed) return state;
if (validStates.has(state.i)) return state;
return {...state, removed: true, lock: false};
});
BurgsAndStates.getPoles();
const regimentCellsMap = {};
const VERTICAL_GAP = 8;
pack.states = pack.states.map(state => {
if (!state.i || state.removed) return state;
const capital = pack.burgs[state.capital];
state.center = !capital || capital.removed ? findCell(...state.pole) : capital.cell;
const military = state.military.map(regiment => {
const cellCoords = projection(...parentMap.pack.cells.p[regiment.cell]);
const cell = isInMap(...cellCoords) ? findCell(...cellCoords) : state.center;
const [xPos, yPos] = projection(regiment.x, regiment.y);
const [xBase, yBase] = projection(regiment.bx, regiment.by);
const [xCell, yCell] = pack.cells.p[cell];
const regsOnCell = regimentCellsMap[cell] || 0;
regimentCellsMap[cell] = regsOnCell + 1;
const name =
isInMap(xPos, yPos) || regiment.name.includes("[relocated]") ? regiment.name : `[relocated] ${regiment.name}`;
const pos = isInMap(xPos, yPos)
? {x: rn(xPos, 2), y: rn(yPos, 2)}
: {x: xCell, y: yCell + regsOnCell * VERTICAL_GAP};
const base = isInMap(xBase, yBase) ? {bx: rn(xBase, 2), by: rn(yBase, 2)} : {bx: xCell, by: yCell};
return {...regiment, cell, name, ...base, ...pos};
});
const neighbors = state.neighbors.filter(stateId => validStates.has(stateId));
return {...state, neighbors, military};
});
}
function restoreRoutes(parentMap, projection) {
pack.routes = parentMap.pack.routes
.map(route => {
let wasInMap = true;
const points = [];
route.points.forEach(([parentX, parentY]) => {
const [x, y] = projection(parentX, parentY);
const inMap = isInMap(x, y);
if (inMap || wasInMap) points.push([rn(x, 2), rn(y, 2)]);
wasInMap = inMap;
});
if (points.length < 2) return null;
const firstCell = points[0][2];
const feature = pack.cells.f[firstCell];
return {...route, feature, points};
})
.filter(Boolean);
pack.cells.routes = Routes.buildLinks(pack.routes);
}
function restoreReligions(parentMap, projection) {
const validReligions = new Set(pack.cells.religion);
const religionPoles = getPolesOfInaccessibility(pack, cellId => pack.cells.religion[cellId]);
pack.religions = parentMap.pack.religions.map(religion => {
if (!religion.i || religion.removed) return religion;
if (!validReligions.has(religion.i)) return {...religion, removed: true, lock: false};
const [xp, yp] = projection(...parentMap.pack.cells.p[religion.center]);
const [x, y] = [rn(xp, 2), rn(yp, 2)];
const centerCoords = isInMap(x, y) ? [x, y] : religionPoles[religion.i];
const center = findCell(...centerCoords);
return {...religion, center};
});
}
function restoreProvinces(parentMap) {
const validProvinces = new Set(pack.cells.province);
pack.provinces = parentMap.pack.provinces.map(province => {
if (!province.i || province.removed) return province;
if (!validProvinces.has(province.i)) return {...province, removed: true, lock: false};
return province;
});
Provinces.getPoles();
pack.provinces.forEach(province => {
if (!province.i || province.removed) return;
const capital = pack.burgs[province.burg];
province.center = !capital?.removed ? capital.cell : findCell(...province.pole);
});
}
function restoreMarkers(parentMap, projection) {
pack.markers = parentMap.pack.markers;
pack.markers.forEach(marker => {
const [x, y] = projection(marker.x, marker.y);
if (!isInMap(x, y)) Markers.deleteMarker(marker.i);
const cell = findCell(x, y);
marker.x = rn(x, 2);
marker.y = rn(y, 2);
marker.cell = cell;
});
}
function restoreZones(parentMap, projection, scale) {
const getSearchRadius = cellId => Math.sqrt(parentMap.pack.cells.area[cellId] / Math.PI) * scale;
pack.zones = parentMap.pack.zones.map(zone => {
const cells = zone.cells
.map(cellId => {
const [x, y] = projection(...parentMap.pack.cells.p[cellId]);
if (!isInMap(x, y)) return null;
return findAll(x, y, getSearchRadius(cellId));
})
.filter(Boolean)
.flat();
return {...zone, cells: unique(cells)};
});
}
function restoreFeatureDetails(parentMap, inverse) {
pack.features.forEach(feature => {
if (!feature) return;
const [x, y] = pack.cells.p[feature.firstCell];
const [parentX, parentY] = inverse(x, y);
const parentCell = parentMap.pack.cells.q.find(parentX, parentY, Infinity)[2];
if (parentCell === undefined) return;
const parentFeature = parentMap.pack.features[parentMap.pack.cells.f[parentCell]];
if (parentFeature.group) feature.group = parentFeature.group;
if (parentFeature.name) feature.name = parentFeature.name;
if (parentFeature.height) feature.height = parentFeature.height;
});
}
function groupCellsByType(graph) {
return graph.cells.p.reduce(
(acc, [x, y], cellId) => {
const group = isWater(graph, cellId) ? "water" : "land";
acc[group].push([x, y, cellId]);
return acc;
},
{land: [], water: []}
);
}
function isWater(graph, cellId) {
return graph.cells.h[cellId] < 20;
}
function isInMap(x, y) {
return x >= 0 && x <= graphWidth && y >= 0 && y <= graphHeight;
}
return {process};
})();

View file

@ -190,7 +190,15 @@ window.Rivers = (function () {
const meanderedPoints = addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, 0));
const sourceWidth = getSourceWidth(cells.fl[source]);
const width = getWidth(
getOffset({
flux: discharge,
pointIndex: meanderedPoints.length,
widthFactor,
sourceWidth
})
);
pack.rivers.push({
i: riverId,
@ -200,7 +208,7 @@ window.Rivers = (function () {
length,
width,
widthFactor,
sourceWidth: 0,
sourceWidth,
parent,
cells: riverCells
});
@ -306,59 +314,49 @@ window.Rivers = (function () {
// add points at 1/3 and 2/3 of a line between adjacents river cells
const addMeandering = function (riverCells, riverPoints = null, meandering = 0.5) {
const {fl, conf, h} = pack.cells;
const {fl, h} = pack.cells;
const meandered = [];
const lastStep = riverCells.length - 1;
const points = getRiverPoints(riverCells, riverPoints);
let step = h[riverCells[0]] < 20 ? 1 : 10;
let fluxPrev = 0;
const getFlux = (step, flux) => (step === lastStep ? fluxPrev : flux);
for (let i = 0; i <= lastStep; i++, step++) {
const cell = riverCells[i];
const isLastCell = i === lastStep;
const [x1, y1] = points[i];
const flux1 = getFlux(i, fl[cell]);
fluxPrev = flux1;
meandered.push([x1, y1, flux1]);
meandered.push([x1, y1, fl[cell]]);
if (isLastCell) break;
const nextCell = riverCells[i + 1];
const [x2, y2] = points[i + 1];
if (nextCell === -1) {
meandered.push([x2, y2, fluxPrev]);
meandered.push([x2, y2, fl[cell]]);
break;
}
const dist2 = (x2 - x1) ** 2 + (y2 - y1) ** 2; // square distance between cells
if (dist2 <= 25 && riverCells.length >= 6) continue;
const flux2 = getFlux(i + 1, fl[nextCell]);
const keepInitialFlux = conf[nextCell] || flux1 === flux2;
const meander = meandering + 1 / step + Math.max(meandering - step / 100, 0);
const angle = Math.atan2(y2 - y1, x2 - x1);
const sinMeander = Math.sin(angle) * meander;
const cosMeander = Math.cos(angle) * meander;
if (step < 10 && (dist2 > 64 || (dist2 > 36 && riverCells.length < 5))) {
if (step < 20 && (dist2 > 64 || (dist2 > 36 && riverCells.length < 5))) {
// if dist2 is big or river is small add extra points at 1/3 and 2/3 of segment
const p1x = (x1 * 2 + x2) / 3 + -sinMeander;
const p1y = (y1 * 2 + y2) / 3 + cosMeander;
const p2x = (x1 + x2 * 2) / 3 + sinMeander / 2;
const p2y = (y1 + y2 * 2) / 3 - cosMeander / 2;
const [p1fl, p2fl] = keepInitialFlux ? [flux1, flux1] : [(flux1 * 2 + flux2) / 3, (flux1 + flux2 * 2) / 3];
meandered.push([p1x, p1y, p1fl], [p2x, p2y, p2fl]);
meandered.push([p1x, p1y, 0], [p2x, p2y, 0]);
} else if (dist2 > 25 || riverCells.length < 6) {
// if dist is medium or river is small add 1 extra middlepoint
const p1x = (x1 + x2) / 2 + -sinMeander;
const p1y = (y1 + y2) / 2 + cosMeander;
const p1fl = keepInitialFlux ? flux1 : (flux1 + flux2) / 2;
meandered.push([p1x, p1y, p1fl]);
meandered.push([p1x, p1y, 0]);
}
}
@ -385,29 +383,36 @@ window.Rivers = (function () {
};
const FLUX_FACTOR = 500;
const MAX_FLUX_WIDTH = 2;
const MAX_FLUX_WIDTH = 1;
const LENGTH_FACTOR = 200;
const STEP_WIDTH = 1 / LENGTH_FACTOR;
const LENGTH_STEP_WIDTH = 1 / LENGTH_FACTOR;
const LENGTH_PROGRESSION = [1, 1, 2, 3, 5, 8, 13, 21, 34].map(n => n / LENGTH_FACTOR);
const MAX_PROGRESSION = last(LENGTH_PROGRESSION);
const getOffset = (flux, pointNumber, widthFactor, startingWidth = 0) => {
const fluxWidth = Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH);
const lengthWidth = pointNumber * STEP_WIDTH + (LENGTH_PROGRESSION[pointNumber] || MAX_PROGRESSION);
const getOffset = ({flux, pointIndex, widthFactor, startingWidth}) => {
if (pointIndex === 0) return startingWidth;
const fluxWidth = Math.min(flux ** 0.7 / FLUX_FACTOR, MAX_FLUX_WIDTH);
const lengthWidth = pointIndex * LENGTH_STEP_WIDTH + (LENGTH_PROGRESSION[pointIndex] || MAX_PROGRESSION);
return widthFactor * (lengthWidth + fluxWidth) + startingWidth;
};
const getSourceWidth = flux => rn(Math.min(flux ** 0.9 / FLUX_FACTOR, MAX_FLUX_WIDTH), 2);
// build polygon from a list of points and calculated offset (width)
const getRiverPath = function (points, widthFactor, startingWidth = 0) {
const getRiverPath = (points, widthFactor, startingWidth) => {
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const riverPointsLeft = [];
const riverPointsRight = [];
let flux = 0;
for (let p = 0; p < points.length; p++) {
const [x0, y0] = points[p - 1] || points[p];
const [x1, y1, flux] = points[p];
const [x2, y2] = points[p + 1] || points[p];
for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {
const [x0, y0] = points[pointIndex - 1] || points[pointIndex];
const [x1, y1, pointFlux] = points[pointIndex];
const [x2, y2] = points[pointIndex + 1] || points[pointIndex];
if (pointFlux > flux) flux = pointFlux;
const offset = getOffset(flux, p, widthFactor, startingWidth);
const offset = getOffset({flux, pointIndex, widthFactor, startingWidth});
const angle = Math.atan2(y0 - y2, x0 - x2);
const sinOffset = Math.sin(angle) * offset;
const cosOffset = Math.cos(angle) * offset;
@ -507,6 +512,7 @@ window.Rivers = (function () {
getBasin,
getWidth,
getOffset,
getSourceWidth,
getApproximateLength,
getRiverPoints,
remove,

View file

@ -1,6 +1,15 @@
const ROUTES_SHARP_ANGLE = 135;
const ROUTES_VERY_SHARP_ANGLE = 115;
const MIN_PASSABLE_SEA_TEMP = -4;
const ROUTE_TYPE_MODIFIERS = {
"-1": 1, // coastline
"-2": 1.8, // sea
"-3": 4, // open sea
"-4": 6, // ocean
default: 8 // far ocean
};
window.Routes = (function () {
function generate(lockedRoutes = []) {
const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs);
@ -118,10 +127,9 @@ window.Routes = (function () {
}
function findPathSegments({isWater, connections, start, exit}) {
const from = findPath(isWater, start, exit, connections);
if (!from) return [];
const pathCells = restorePath(start, exit, from);
const getCost = createCostEvaluator({isWater, connections});
const pathCells = findPath(start, current => current === exit, getCost);
if (!pathCells) return [];
const segments = getRouteSegments(pathCells, connections);
return segments;
}
@ -172,29 +180,61 @@ window.Routes = (function () {
return routesMerged > 1 ? mergeRoutes(routes) : routes;
}
}
function buildLinks(routes) {
const links = {};
function createCostEvaluator({isWater, connections}) {
return isWater ? getWaterPathCost : getLandPathCost;
for (const {points, i: routeId} of routes) {
const cells = points.map(p => p[2]);
function getLandPathCost(current, next) {
if (pack.cells.h[next] < 20) return Infinity; // ignore water cells
for (let i = 0; i < cells.length - 1; i++) {
const cellId = cells[i];
const nextCellId = cells[i + 1];
const habitability = biomesData.habitability[pack.cells.biome[next]];
if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier)
if (cellId !== nextCellId) {
if (!links[cellId]) links[cellId] = {};
links[cellId][nextCellId] = routeId;
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3];
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
const burgModifier = pack.cells.burg[next] ? 1 : 3;
if (!links[nextCellId]) links[nextCellId] = {};
links[nextCellId][cellId] = routeId;
}
const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier;
return pathCost;
}
function getWaterPathCost(current, next) {
if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells
if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) return Infinity; // ignore too cold cells
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default;
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
const pathCost = distanceCost * typeModifier * connectionModifier;
return pathCost;
}
}
function buildLinks(routes) {
const links = {};
for (const {points, i: routeId} of routes) {
const cells = points.map(p => p[2]);
for (let i = 0; i < cells.length - 1; i++) {
const cellId = cells[i];
const nextCellId = cells[i + 1];
if (cellId !== nextCellId) {
if (!links[cellId]) links[cellId] = {};
links[cellId][nextCellId] = routeId;
if (!links[nextCellId]) links[nextCellId] = {};
links[nextCellId][cellId] = routeId;
}
}
return links;
}
return links;
}
function preparePointsArray() {
@ -249,109 +289,6 @@ window.Routes = (function () {
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 = [];
@ -422,21 +359,16 @@ window.Routes = (function () {
// connect cell with routes system by land
function connect(cellId) {
if (isConnected(cellId)) return;
const getCost = createCostEvaluator({isWater: false, connections: new Map()});
const pathCells = findPath(cellId, isConnected, getCost);
if (!pathCells) 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 feature = pack.cells.f[cellId];
const routeId = getNextId();
const newRoute = {i: routeId, group: "trails", feature, points};
routes.push(newRoute);
pack.routes.push(newRoute);
for (let i = 0; i < pathCells.length; i++) {
const cellId = pathCells[i];
@ -446,43 +378,6 @@ window.Routes = (function () {
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;
@ -743,6 +638,7 @@ window.Routes = (function () {
return {
generate,
buildLinks,
connect,
isConnected,
areConnected,

View file

@ -796,14 +796,12 @@ function drawRivers() {
TIME && console.time("drawRivers");
rivers.selectAll("*").remove();
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const riverPaths = pack.rivers.map(({cells, points, i, widthFactor, sourceWidth}) => {
if (!cells || cells.length < 2) return;
if (points && points.length !== cells.length) {
console.error(
`River ${i} has ${cells.length} cells, but only ${points.length} points defined.`,
"Resetting points data"
`River ${i} has ${cells.length} cells, but only ${points.length} points defined. Resetting points data`
);
points = undefined;
}

View file

@ -332,16 +332,12 @@ const cellsDensityMap = {
function changeCellsDensity(value) {
pointsInput.value = value;
const cells = cellsDensityMap[value] || 1000;
const cells = cellsDensityMap[value] || pointsInput.dataset.cells;
pointsInput.dataset.cells = cells;
pointsOutputFormatted.value = getCellsDensityValue(cells);
pointsOutputFormatted.value = cells / 1000 + "K";
pointsOutputFormatted.style.color = getCellsDensityColor(cells);
}
function getCellsDensityValue(cells) {
return cells / 1000 + "K";
}
function getCellsDensityColor(cells) {
return cells > 50000 ? "#b12117" : cells !== 10000 ? "#dfdf12" : "#053305";
}

View file

@ -74,13 +74,10 @@ function createRiver() {
function addRiver() {
const {rivers, cells} = pack;
const {addMeandering, getApproximateLength, getWidth, getOffset, getName, getRiverPath, getBasin, getNextId} =
Rivers;
const riverCells = createRiver.cells;
if (riverCells.length < 2) return tip("Add at least 2 cells", false, "error");
const riverId = getNextId(rivers);
const riverId = Rivers.getNextId(rivers);
const parent = cells.r[last(riverCells)] || riverId;
riverCells.forEach(cell => {
@ -89,17 +86,24 @@ function createRiver() {
const source = riverCells[0];
const mouth = parent === riverId ? last(riverCells) : riverCells[riverCells.length - 2];
const sourceWidth = 0.05;
const sourceWidth = Rivers.getSourceWidth(cells.fl[source]);
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
const widthFactor = 1.2 * defaultWidthFactor;
const meanderedPoints = addMeandering(riverCells);
const meanderedPoints = Rivers.addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, sourceWidth));
const name = getName(mouth);
const basin = getBasin(parent);
const length = Rivers.getApproximateLength(meanderedPoints);
const width = Rivers.getWidth(
Rivers.getOffset({
flux: discharge,
pointIndex: meanderedPoints.length,
widthFactor,
startingWidth: sourceWidth
})
);
const name = Rivers.getName(mouth);
const basin = Rivers.getBasin(parent);
rivers.push({
i: riverId,
@ -118,13 +122,11 @@ function createRiver() {
});
const id = "river" + riverId;
// render river
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
viewbox
.select("#rivers")
.append("path")
.attr("id", id)
.attr("d", getRiverPath(meanderedPoints, widthFactor, sourceWidth));
.attr("d", Rivers.getRiverPath(meanderedPoints, widthFactor, sourceWidth));
editRiver(id);
}

View file

@ -86,10 +86,16 @@ function editRiver(id) {
}
function updateRiverWidth(river) {
const {addMeandering, getWidth, getOffset} = Rivers;
const {cells, discharge, widthFactor, sourceWidth} = river;
const meanderedPoints = addMeandering(cells);
river.width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor, sourceWidth));
const meanderedPoints = Rivers.addMeandering(cells);
river.width = Rivers.getWidth(
Rivers.getOffset({
flux: discharge,
pointIndex: meanderedPoints.length,
widthFactor,
startingWidth: sourceWidth
})
);
const width = `${rn(river.width * distanceScale, 3)} ${distanceUnitInput.value}`;
byId("riverWidth").value = width;
@ -158,11 +164,9 @@ function editRiver(id) {
river.points = debug.selectAll("#controlPoints > *").data();
river.cells = river.points.map(([x, y]) => findCell(x, y));
const {widthFactor, sourceWidth} = river;
const meanderedPoints = Rivers.addMeandering(river.cells, river.points);
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const path = Rivers.getRiverPath(meanderedPoints, widthFactor, sourceWidth);
const meanderedPoints = Rivers.addMeandering(river.cells, river.points);
const path = Rivers.getRiverPath(meanderedPoints, river.widthFactor, river.sourceWidth);
elSelected.attr("d", path);
updateRiverLength(river);

95
modules/ui/submap-tool.js Normal file
View file

@ -0,0 +1,95 @@
"use strict";
function openSubmapTool() {
resetInputs();
$("#submapTool").dialog({
title: "Create a submap",
resizable: false,
width: "32em",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Submap: function () {
closeDialogs();
generateSubmap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
if (modules.openSubmapTool) return;
modules.openSubmapTool = true;
function resetInputs() {
updateCellsNumber(byId("pointsInput").value);
byId("submapPointsInput").oninput = e => updateCellsNumber(e.target.value);
function updateCellsNumber(value) {
byId("submapPointsInput").value = value;
const cells = cellsDensityMap[value];
byId("submapPointsInput").dataset.cells = cells;
const output = byId("submapPointsFormatted");
output.value = cells / 1000 + "K";
output.style.color = getCellsDensityColor(cells);
}
}
function generateSubmap() {
INFO && console.group("generateSubmap");
const [x0, y0] = [Math.abs(viewX / scale), Math.abs(viewY / scale)]; // top-left corner
recalculateMapSize(x0, y0);
const submapPointsValue = byId("submapPointsInput").value;
const globalPointsValue = byId("pointsInput").value;
if (submapPointsValue !== globalPointsValue) changeCellsDensity(submapPointsValue);
const projection = (x, y) => [(x - x0) * scale, (y - y0) * scale];
const inverse = (x, y) => [x / scale + x0, y / scale + y0];
resetZoom(0);
undraw();
Resample.process({projection, inverse, scale});
if (byId("submapRescaleBurgStyles").checked) rescaleBurgStyles(scale);
drawLayers();
INFO && console.groupEnd("generateSubmap");
}
function recalculateMapSize(x0, y0) {
const mapSize = +byId("mapSizeOutput").value;
byId("mapSizeOutput").value = byId("mapSizeInput").value = rn(mapSize / scale, 2);
const latT = mapCoordinates.latT / scale;
const latN = getLatitude(y0);
const latShift = (90 - latN) / (180 - latT);
byId("latitudeOutput").value = byId("latitudeInput").value = rn(latShift * 100, 2);
const lotT = mapCoordinates.lonT / scale;
const lonE = getLongitude(x0 + graphWidth / scale);
const lonShift = (180 - lonE) / (360 - lotT);
byId("longitudeOutput").value = byId("longitudeInput").value = rn(lonShift * 100, 2);
distanceScale = distanceScaleInput.value = rn(distanceScale / scale, 2);
populationRate = populationRateInput.value = rn(populationRate / scale, 2);
}
function rescaleBurgStyles(scale) {
const burgIcons = [...byId("burgIcons").querySelectorAll("g")];
for (const group of burgIcons) {
const newRadius = rn(minmax(group.getAttribute("size") * scale, 0.2, 10), 2);
changeRadius(newRadius, group.id);
const strokeWidth = group.attributes["stroke-width"];
strokeWidth.value = strokeWidth.value * scale;
}
const burgLabels = [...byId("burgLabels").querySelectorAll("g")];
for (const group of burgLabels) {
const size = +group.dataset.size;
group.dataset.size = Math.max(rn((size + size / scale) / 2, 2), 1) * scale;
}
}
}

View file

@ -1,332 +0,0 @@
"use strict";
// UI elements for submap generation
window.UISubmap = (function () {
byId("submapPointsInput").addEventListener("input", function () {
const output = byId("submapPointsOutputFormatted");
const cells = cellsDensityMap[+this.value] || 1000;
this.dataset.cells = cells;
output.value = getCellsDensityValue(cells);
output.style.color = getCellsDensityColor(cells);
});
byId("submapScaleInput").addEventListener("input", function (event) {
const exp = Math.pow(1.1, +event.target.value);
byId("submapScaleOutput").value = rn(exp, 2);
});
byId("submapAngleInput").addEventListener("input", function (event) {
byId("submapAngleOutput").value = event.target.value;
});
const $previewBox = byId("submapPreview");
const $scaleInput = byId("submapScaleInput");
const $shiftX = byId("submapShiftX");
const $shiftY = byId("submapShiftY");
function openSubmapMenu() {
$("#submapOptionsDialog").dialog({
title: "Create a submap",
resizable: false,
width: "32em",
position: {my: "center", at: "center", of: "svg"},
buttons: {
Submap: function () {
$(this).dialog("close");
generateSubmap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
}
const getTransformInput = _ => ({
angle: (+byId("submapAngleInput").value / 180) * Math.PI,
shiftX: +byId("submapShiftX").value,
shiftY: +byId("submapShiftY").value,
ratio: +byId("submapScaleInput").value,
mirrorH: byId("submapMirrorH").checked,
mirrorV: byId("submapMirrorV").checked
});
async function openResampleMenu() {
resetZoom(0);
byId("submapAngleInput").value = 0;
byId("submapAngleOutput").value = "0";
byId("submapScaleOutput").value = 1;
byId("submapMirrorH").checked = false;
byId("submapMirrorV").checked = false;
$scaleInput.value = 0;
$shiftX.value = 0;
$shiftY.value = 0;
const w = Math.min(400, window.innerWidth * 0.5);
const previewScale = w / graphWidth;
const h = graphHeight * previewScale;
$previewBox.style.width = w + "px";
$previewBox.style.height = h + "px";
// handle mouse input
const dispatchInput = e => e.dispatchEvent(new Event("input", {bubbles: true}));
// mouse wheel
$previewBox.onwheel = e => {
$scaleInput.value = $scaleInput.valueAsNumber - Math.sign(e.deltaY);
dispatchInput($scaleInput);
};
// mouse drag
let mouseIsDown = false,
mouseX = 0,
mouseY = 0;
$previewBox.onmousedown = e => {
mouseIsDown = true;
mouseX = $shiftX.value - e.clientX / previewScale;
mouseY = $shiftY.value - e.clientY / previewScale;
};
$previewBox.onmouseup = _ => (mouseIsDown = false);
$previewBox.onmouseleave = _ => (mouseIsDown = false);
$previewBox.onmousemove = e => {
if (!mouseIsDown) return;
e.preventDefault();
$shiftX.value = Math.round(mouseX + e.clientX / previewScale);
$shiftY.value = Math.round(mouseY + e.clientY / previewScale);
dispatchInput($shiftX);
// dispatchInput($shiftY); // not needed X bubbles anyway
};
$("#resampleDialog").dialog({
title: "Transform map",
resizable: false,
position: {my: "center", at: "center", of: "svg"},
buttons: {
Transform: function () {
$(this).dialog("close");
resampleCurrentMap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
// use double resolution for PNG to get sharper image
const $preview = await loadPreview($previewBox, w * 2, h * 2);
// could be done with SVG. Faster to load, slower to use.
// const $preview = await loadPreviewSVG($previewBox, w, h);
$preview.style.position = "absolute";
$preview.style.width = w + "px";
$preview.style.height = h + "px";
byId("resampleDialog").oninput = event => {
const {angle, shiftX, shiftY, ratio, mirrorH, mirrorV} = getTransformInput();
const scale = Math.pow(1.1, ratio);
const transformStyle = `
translate(${shiftX * previewScale}px, ${shiftY * previewScale}px)
scale(${mirrorH ? -scale : scale}, ${mirrorV ? -scale : scale})
rotate(${angle}rad)
`;
$preview.style.transform = transformStyle;
$preview.style["transform-origin"] = "center";
event.stopPropagation();
};
}
async function loadPreview($container, w, h) {
const url = await getMapURL("png", {
globe: false,
noWater: true,
fullMap: true,
noLabels: true,
noScaleBar: true,
noVignette: true,
noIce: true
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = w;
canvas.height = h;
const img = new Image();
img.src = url;
img.onload = function () {
ctx.drawImage(img, 0, 0, w, h);
};
$container.textContent = "";
$container.appendChild(canvas);
return canvas;
}
// Resample the whole map to different cell resolution or shape
const resampleCurrentMap = debounce(function () {
WARN && console.warn("Resampling current map");
const cellNumId = +byId("submapPointsInput").value;
if (!cellsDensityMap[cellNumId]) return console.error("Unknown cell number!");
const {angle, shiftX, shiftY, ratio, mirrorH, mirrorV} = getTransformInput();
const [cx, cy] = [graphWidth / 2, graphHeight / 2];
const rot = alfa => (x, y) =>
[
(x - cx) * Math.cos(alfa) - (y - cy) * Math.sin(alfa) + cx,
(y - cy) * Math.cos(alfa) + (x - cx) * Math.sin(alfa) + cy
];
const shift = (dx, dy) => (x, y) => [x + dx, y + dy];
const scale = r => (x, y) => [(x - cx) * r + cx, (y - cy) * r + cy];
const flipH = (x, y) => [-x + 2 * cx, y];
const flipV = (x, y) => [x, -y + 2 * cy];
const app = (f, g) => (x, y) => f(...g(x, y));
const id = (x, y) => [x, y];
let projection = id;
let inverse = id;
if (angle) [projection, inverse] = [rot(angle), rot(-angle)];
if (ratio)
[projection, inverse] = [
app(scale(Math.pow(1.1, ratio)), projection),
app(inverse, scale(Math.pow(1.1, -ratio)))
];
if (mirrorH) [projection, inverse] = [app(flipH, projection), app(inverse, flipH)];
if (mirrorV) [projection, inverse] = [app(flipV, projection), app(inverse, flipV)];
if (shiftX || shiftY) {
projection = app(shift(shiftX, shiftY), projection);
inverse = app(inverse, shift(-shiftX, -shiftY));
}
changeCellsDensity(cellNumId);
startResample({
lockMarkers: false,
lockBurgs: false,
depressRivers: false,
addLakesInDepressions: false,
promoteTowns: false,
smoothHeightMap: false,
rescaleStyles: false,
scale: 1,
projection,
inverse
});
}, 1000);
// Create submap from the current map. Submap limits defined by the current window size (canvas viewport)
const generateSubmap = debounce(function () {
WARN && console.warn("Resampling current map");
closeDialogs("#worldConfigurator, #options3d");
const checked = id => Boolean(byId(id).checked);
// Create projection func from current zoom extents
const [[x0, y0], [x1, y1]] = getViewBoxExtent();
const origScale = scale;
const options = {
lockMarkers: checked("submapLockMarkers"),
lockBurgs: checked("submapLockBurgs"),
depressRivers: checked("submapDepressRivers"),
addLakesInDepressions: checked("submapAddLakeInDepression"),
promoteTowns: checked("submapPromoteTowns"),
rescaleStyles: checked("submapRescaleStyles"),
smoothHeightMap: scale > 2,
inverse: (x, y) => [x / origScale + x0, y / origScale + y0],
projection: (x, y) => [(x - x0) * origScale, (y - y0) * origScale],
scale: origScale
};
// converting map position on the planet
const mapSizeOutput = byId("mapSizeOutput");
const latitudeOutput = byId("latitudeOutput");
const latN = 90 - ((180 - (mapSizeInput.value / 100) * 180) * latitudeOutput.value) / 100;
const newLatN = latN - ((y0 / graphHeight) * mapSizeOutput.value * 180) / 100;
mapSizeOutput.value /= scale;
latitudeOutput.value = ((90 - newLatN) / (180 - (mapSizeOutput.value / 100) * 180)) * 100;
byId("mapSizeInput").value = mapSizeOutput.value;
byId("latitudeInput").value = latitudeOutput.value;
// fix scale
distanceScale = distanceScaleInput.value = rn(distanceScaleInput.value / scale, 2);
populationRate = populationRateInput.value = rn(populationRateInput.value / scale, 2);
customization = 0;
startResample(options);
}, 1000);
async function startResample(options) {
// Do model changes with Submap.resample then do view changes if needed
resetZoom(0);
let oldstate = {
grid: deepCopy(grid),
pack: deepCopy(pack),
notes: deepCopy(notes),
seed,
graphWidth,
graphHeight
};
undraw();
try {
const oldScale = scale;
await Submap.resample(oldstate, options);
if (options.promoteTowns) {
const groupName = "largetowns";
moveAllBurgsToGroup("towns", groupName);
changeRadius(rn(oldScale * 0.8, 2), groupName);
changeFontSize(svg.select(`#labels #${groupName}`), rn(oldScale * 2, 2));
invokeActiveZooming();
}
if (options.rescaleStyles) changeStyles(oldScale);
} catch (error) {
showSubmapErrorHandler(error);
}
oldstate = null; // destroy old state to free memory
drawLayers();
if (ThreeD.options.isOn) ThreeD.redraw();
if ($("#worldConfigurator").is(":visible")) editWorld();
}
function changeStyles(scale) {
// resize burgIcons
const burgIcons = [...byId("burgIcons").querySelectorAll("g")];
for (const bi of burgIcons) {
const newRadius = rn(minmax(bi.getAttribute("size") * scale, 0.2, 10), 2);
changeRadius(newRadius, bi.id);
const swAttr = bi.attributes["stroke-width"];
swAttr.value = +swAttr.value * scale;
}
// burglabels
const burgLabels = [...byId("burgLabels").querySelectorAll("g")];
for (const bl of burgLabels) {
const size = +bl.dataset["size"];
bl.dataset["size"] = Math.max(rn((size + size / scale) / 2, 2), 1) * scale;
}
drawEmblems();
}
function showSubmapErrorHandler(error) {
ERROR && console.error(error);
clearMainTip();
alertMessage.innerHTML = /* html */ `Map resampling failed: <br />You may retry after clearing stored data or contact us at discord.
<p id="errorBox">${parseError(error)}</p>`;
$("#alert").dialog({
resizable: false,
title: "Resampling error",
width: "32em",
buttons: {
Ok: function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
}
return {openSubmapMenu, openResampleMenu};
})();

View file

@ -3,7 +3,7 @@
// module to control the Tools options (click to edit, to re-geenerate, tp add)
toolsContent.addEventListener("click", function (event) {
if (customization) return tip("Please exit the customization mode first", false, "warning");
if (customization) return tip("Please exit the customization mode first", false, "error");
if (!["BUTTON", "I"].includes(event.target.tagName)) return;
const button = event.target.id;
@ -70,8 +70,8 @@ toolsContent.addEventListener("click", function (event) {
else if (button === "addRoute") createRoute();
else if (button === "addMarker") toggleAddMarker();
// click to create a new map buttons
else if (button === "openSubmapMenu") UISubmap.openSubmapMenu();
else if (button === "openResampleMenu") UISubmap.openResampleMenu();
else if (button === "openSubmapTool") openSubmapTool();
else if (button === "openTransformTool") openTransformTool();
});
function processFeatureRegeneration(event, button) {
@ -668,28 +668,15 @@ function addRiverOnClick() {
if (cells.h[i] < 20) return tip("Cannot create river in water cell", false, "error");
if (cells.b[i]) return;
const {
alterHeights,
resolveDepressions,
addMeandering,
getRiverPath,
getBasin,
getName,
getType,
getWidth,
getOffset,
getApproximateLength,
getNextId
} = Rivers;
const riverCells = [];
let riverId = getNextId(rivers);
let riverId = Rivers.getNextId(rivers);
let parent = riverId;
const initialFlux = grid.cells.prec[cells.g[i]];
cells.fl[i] = initialFlux;
const h = alterHeights();
resolveDepressions(h);
const h = Rivers.alterHeights();
Rivers.resolveDepressions(h);
while (i) {
cells.r[i] = riverId;
@ -763,11 +750,19 @@ function addRiverOnClick() {
const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2);
const widthFactor =
river?.widthFactor || (!parent || parent === riverId ? defaultWidthFactor * 1.2 : defaultWidthFactor);
const meanderedPoints = addMeandering(riverCells);
const sourceWidth = river?.sourceWidth || Rivers.getSourceWidth(cells.fl[source]);
const meanderedPoints = Rivers.addMeandering(riverCells);
const discharge = cells.fl[mouth]; // m3 in second
const length = getApproximateLength(meanderedPoints);
const width = getWidth(getOffset(discharge, meanderedPoints.length, widthFactor));
const length = Rivers.getApproximateLength(meanderedPoints);
const width = Rivers.getWidth(
Rivers.getOffset({
flux: discharge,
pointIndex: meanderedPoints.length,
widthFactor,
startingWidth: sourceWidth
})
);
if (river) {
river.source = source;
@ -776,9 +771,9 @@ function addRiverOnClick() {
river.width = width;
river.cells = riverCells;
} else {
const basin = getBasin(parent);
const name = getName(mouth);
const type = getType({i: riverId, length, parent});
const basin = Rivers.getBasin(parent);
const name = Rivers.getName(mouth);
const type = Rivers.getType({i: riverId, length, parent});
rivers.push({
i: riverId,
@ -788,7 +783,7 @@ function addRiverOnClick() {
length,
width,
widthFactor,
sourceWidth: 0,
sourceWidth,
parent,
cells: riverCells,
basin,
@ -798,8 +793,7 @@ function addRiverOnClick() {
}
// render river
lineGen.curve(d3.curveCatmullRom.alpha(0.1));
const path = getRiverPath(meanderedPoints, widthFactor);
const path = Rivers.getRiverPath(meanderedPoints, widthFactor, sourceWidth);
const id = "river" + riverId;
const riversG = viewbox.select("#rivers");
riversG.append("path").attr("id", id).attr("d", path);

View file

@ -0,0 +1,201 @@
"use strict";
async function openTransformTool() {
const width = Math.min(400, window.innerWidth * 0.5);
const previewScale = width / graphWidth;
const height = graphHeight * previewScale;
let mouseIsDown = false;
let mouseX = 0;
let mouseY = 0;
resetInputs();
loadPreview();
$("#transformTool").dialog({
title: "Transform map",
resizable: false,
position: {my: "center", at: "center", of: "svg"},
buttons: {
Transform: function () {
closeDialogs();
transformMap();
},
Cancel: function () {
$(this).dialog("close");
}
}
});
if (modules.openTransformTool) return;
modules.openTransformTool = true;
// add listeners
byId("transformToolBody").on("input", handleInput);
byId("transformPreview")
.on("mousedown", handleMousedown)
.on("mouseup", _ => (mouseIsDown = false))
.on("mousemove", handleMousemove)
.on("wheel", handleWheel);
async function loadPreview() {
byId("transformPreview").style.width = width + "px";
byId("transformPreview").style.height = height + "px";
const options = {noWater: true, fullMap: true, noLabels: true, noScaleBar: true, noVignette: true, noIce: true};
const url = await getMapURL("png", options);
const SCALE = 4;
const img = new Image();
img.src = url;
img.onload = function () {
const $canvas = byId("transformPreviewCanvas");
$canvas.style.width = width + "px";
$canvas.style.height = height + "px";
$canvas.width = width * SCALE;
$canvas.height = height * SCALE;
$canvas.getContext("2d").drawImage(img, 0, 0, width * SCALE, height * SCALE);
};
}
function resetInputs() {
byId("transformAngleInput").value = 0;
byId("transformAngleOutput").value = "0";
byId("transformMirrorH").checked = false;
byId("transformMirrorV").checked = false;
byId("transformScaleInput").value = 0;
byId("transformScaleResult").value = 1;
byId("transformShiftX").value = 0;
byId("transformShiftY").value = 0;
handleInput();
updateCellsNumber(byId("pointsInput").value);
byId("transformPointsInput").oninput = e => updateCellsNumber(e.target.value);
function updateCellsNumber(value) {
byId("transformPointsInput").value = value;
const cells = cellsDensityMap[value];
byId("transformPointsInput").dataset.cells = cells;
const output = byId("transformPointsFormatted");
output.value = cells / 1000 + "K";
output.style.color = getCellsDensityColor(cells);
}
}
function handleInput() {
const angle = (+byId("transformAngleInput").value / 180) * Math.PI;
const shiftX = +byId("transformShiftX").value;
const shiftY = +byId("transformShiftY").value;
const mirrorH = byId("transformMirrorH").checked;
const mirrorV = byId("transformMirrorV").checked;
const EXP = 1.0965;
const scale = rn(EXP ** +byId("transformScaleInput").value, 2); // [0.1, 10]x
byId("transformScaleResult").value = scale;
byId("transformPreviewCanvas").style.transform = `
translate(${shiftX * previewScale}px, ${shiftY * previewScale}px)
scale(${mirrorH ? -scale : scale}, ${mirrorV ? -scale : scale})
rotate(${angle}rad)
`;
}
function handleMousedown(e) {
mouseIsDown = true;
const shiftX = +byId("transformShiftX").value;
const shiftY = +byId("transformShiftY").value;
mouseX = shiftX - e.clientX / previewScale;
mouseY = shiftY - e.clientY / previewScale;
}
function handleMousemove(e) {
if (!mouseIsDown) return;
e.preventDefault();
byId("transformShiftX").value = Math.round(mouseX + e.clientX / previewScale);
byId("transformShiftY").value = Math.round(mouseY + e.clientY / previewScale);
handleInput();
}
function handleWheel(e) {
const $scaleInput = byId("transformScaleInput");
$scaleInput.value = $scaleInput.valueAsNumber - Math.sign(e.deltaY);
handleInput();
}
function transformMap() {
INFO && console.group("transformMap");
const transformPointsValue = byId("transformPointsInput").value;
const globalPointsValue = byId("pointsInput").value;
if (transformPointsValue !== globalPointsValue) changeCellsDensity(transformPointsValue);
const [projection, inverse] = getProjection();
resetZoom(0);
undraw();
Resample.process({projection, inverse, scale: 1});
drawLayers();
INFO && console.groupEnd("transformMap");
}
function getProjection() {
const centerX = graphWidth / 2;
const centerY = graphHeight / 2;
const shiftX = +byId("transformShiftX").value;
const shiftY = +byId("transformShiftY").value;
const angle = (+byId("transformAngleInput").value / 180) * Math.PI;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const scale = +byId("transformScaleResult").value;
const mirrorH = byId("transformMirrorH").checked;
const mirrorV = byId("transformMirrorV").checked;
function project(x, y) {
// center the point
x -= centerX;
y -= centerY;
// apply scale
if (scale !== 1) {
x *= scale;
y *= scale;
}
// apply rotation
if (angle) [x, y] = [x * cos - y * sin, x * sin + y * cos];
// apply mirroring
if (mirrorH) x = -x;
if (mirrorV) y = -y;
// uncenter the point and apply shift
return [x + centerX + shiftX, y + centerY + shiftY];
}
function inverse(x, y) {
// undo shift and center the point
x -= centerX + shiftX;
y -= centerY + shiftY;
// undo mirroring
if (mirrorV) y = -y;
if (mirrorH) x = -x;
// undo rotation
if (angle !== 0) [x, y] = [x * cos + y * sin, -x * sin + y * cos];
// undo scale
if (scale !== 1) {
x /= scale;
y /= scale;
}
// uncenter the point
return [x + centerX, y + centerY];
}
return [project, inverse];
}
}