From 5703e621772624127c8349d8a5baafa423f4e960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20M=C3=A9sz=C3=A1ros=2C=20Ph=2ED?= Date: Fri, 15 Apr 2022 11:45:02 +0200 Subject: [PATCH] Dev submaps (#770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bioms shouldn't be masked or the style selection box is useless * fix: misleading comment * experimental submapping feature * burg remapping * Submap with options * Fix: calculating absolute flux from precipitation normal-value. * effective distanceScale * updated resampler * fix: missing cell * Fix: River automatic rerender on regeneration. * FIX: wrong culture migration * fixed 0 index burg bug, more accurate coast detection for burgs * FIX: wrong burg cell id * fix invalid feature number at burg.ports, option to disable regenerations * Relocate submap * update height model and scale parameters * new menu * Dropbox OAuth implementation and Cloud framework * add some space * removing uneccesary logs, defer script load * map position on planet, fix wrong riverbed generation * fix:riverbed generation * better cell sampler * Auto-Smoothing,dist fix * FIX: incorrect province copy and minor fix of rebels * Cleanup * FIX: water detection bug * Recompute centers (states, cultures, provinces) * activating forwardmap * FIX: port burg relocation algo * FIX: coast detection (for burgs) * Fix: invalid html id * add dot * update for FMG 1.73 * Update submap gui * refactored submap ui options * Copy all visible military units from the old map. * add info text * Add Markers.deleteMarker API. * Lock markers and lock burgs options * better comment * submapper gui updates, remove feature mapping on/off * Fix typo (thx evolvedexperiment) * fix ugly GUI (2 digit roundoff) * resample dialog * Town Promotion to largetown * don't promote to capitals. * Fix typo * round style settings * do not draw removed burgs * Fix port cell search algo * Fix: robust error handling, no error for 0. * submap: projection moved to options, fix double burg error * complete rewrite of burg relocation * findcell by coordinates * prepare to merge, add comments, remove fluff * replacing lodash with deepCopy implementation Co-authored-by: Mészáros Gergely --- index.html | 62 ++++- main.js | 12 +- modules/burgs-and-states.js | 4 +- modules/io/load.js | 2 +- modules/markers-generator.js | 8 +- modules/military-generator.js | 23 +- modules/river-generator.js | 2 + modules/submap.js | 411 ++++++++++++++++++++++++++++++++++ modules/ui/burg-editor.js | 9 +- modules/ui/editors.js | 21 +- modules/ui/markers-editor.js | 3 +- modules/ui/options.js | 36 +-- modules/ui/style.js | 10 +- modules/ui/submap.js | 156 +++++++++++++ modules/ui/units-editor.js | 1 + utils/arrayUtils.js | 34 +++ 16 files changed, 741 insertions(+), 53 deletions(-) create mode 100644 modules/submap.js create mode 100644 modules/ui/submap.js diff --git a/index.html b/index.html index 20930605..d7ce4e0a 100644 --- a/index.html +++ b/index.html @@ -1366,6 +1366,12 @@ + +

Click to create a new map:

+
+ + +
@@ -2234,7 +2240,7 @@
- Defenders + Defenders
@@ -3659,6 +3665,58 @@
+ + + @@ -4523,6 +4581,7 @@ + @@ -4574,6 +4633,7 @@ + diff --git a/main.js b/main.js index 6f4fd5fc..f9146b6f 100644 --- a/main.js +++ b/main.js @@ -155,6 +155,7 @@ let options = { }; let mapCoordinates = {}; // map coordinates on globe let populationRate = +document.getElementById("populationRateInput").value; +let distanceScale = +document.getElementById("distanceScaleInput").value; let urbanization = +document.getElementById("urbanizationInput").value; let urbanDensity = +document.getElementById("urbanDensityInput").value; @@ -826,6 +827,7 @@ function markupGridOcean() { TIME && console.timeEnd("markupGridOcean"); } +// Calculate cell-distance to coast for every cell function markup(cells, start, increment, limit) { for (let t = start, count = Infinity; count > 0 && t > limit; t += increment) { count = 0; @@ -1617,14 +1619,16 @@ function addZones(number = 1) { } function addRebels() { - const state = ra(states.filter(s => s.i && s.neighbors.some(n => n))); + const state = ra(states.filter(s => s.i && !s.removed && s.neighbors.some(n => n))); if (!state) return; - const neib = ra(state.neighbors.filter(n => n)); - const cell = cells.i.find(i => cells.state[i] === state.i && cells.c[i].some(c => cells.state[c] === neib)); + const neib = ra(state.neighbors.filter(n => n && !states[n].removed)); + if (!neib) return; + const cell = cells.i.find(i => cells.state[i] === state.i && !state.removed && cells.c[i].some(c => cells.state[c] === neib)); const cellsArray = [], - queue = [cell], + queue = [], power = rand(10, 30); + if (cell) queue.push.cell; while (queue.length) { const q = queue.shift(); diff --git a/modules/burgs-and-states.js b/modules/burgs-and-states.js index a9200868..e64e3e77 100644 --- a/modules/burgs-and-states.js +++ b/modules/burgs-and-states.js @@ -256,7 +256,7 @@ window.BurgsAndStates = (function () { icons.selectAll("use").remove(); // capitals - const capitals = pack.burgs.filter(b => b.capital); + const capitals = pack.burgs.filter(b => b.capital && !b.removed); const capitalIcons = burgIcons.select("#cities"); const capitalLabels = burgLabels.select("#cities"); const capitalSize = capitalIcons.attr("size") || 1; @@ -299,7 +299,7 @@ window.BurgsAndStates = (function () { .attr("height", caSize); // towns - const towns = pack.burgs.filter(b => b.i && !b.capital); + const towns = pack.burgs.filter(b => b.i && !b.capital && !b.removed); const townIcons = burgIcons.select("#towns"); const townLabels = burgLabels.select("#towns"); const townSize = townIcons.attr("size") || 0.5; diff --git a/modules/io/load.js b/modules/io/load.js index 14a59666..75687c05 100644 --- a/modules/io/load.js +++ b/modules/io/load.js @@ -205,7 +205,7 @@ async function parseLoadedData(data) { void (function parseSettings() { const settings = data[1].split("|"); if (settings[0]) applyOption(distanceUnitInput, settings[0]); - if (settings[1]) distanceScaleInput.value = distanceScaleOutput.value = settings[1]; + if (settings[1]) distanceScale = distanceScaleInput.value = distanceScaleOutput.value = settings[1]; if (settings[2]) areaUnit.value = settings[2]; if (settings[3]) applyOption(heightUnit, settings[3]); if (settings[4]) heightExponentInput.value = heightExponentOutput.value = settings[4]; diff --git a/modules/markers-generator.js b/modules/markers-generator.js index e2bb55d2..156e348c 100644 --- a/modules/markers-generator.js +++ b/modules/markers-generator.js @@ -129,6 +129,12 @@ window.Markers = (function () { return marker; } + function deleteMarker(markerId) { + const noteId = 'marker' + markerId; + notes = notes.filter(note => note.id !== noteId); + pack.markers = pack.markers.filter(m => m.i !== markerId); + } + function listVolcanoes({cells}) { return cells.i.filter(i => !occupied[i] && cells.h[i] >= 70); } @@ -796,5 +802,5 @@ window.Markers = (function () { notes.push({id, name, legend}); } - return {add, generate, regenerate, getConfig, setConfig}; + return {add, generate, regenerate, getConfig, setConfig, deleteMarker}; })(); diff --git a/modules/military-generator.js b/modules/military-generator.js index c69dc1e3..648f5637 100644 --- a/modules/military-generator.js +++ b/modules/military-generator.js @@ -157,14 +157,6 @@ window.Military = (function () { } } - void (function removeExistingRegiments() { - armies.selectAll("g > g").each(function () { - const index = notes.findIndex(n => n.id === this.id); - if (index != -1) notes.splice(index, 1); - }); - armies.selectAll("g").remove(); - })(); - const expected = 3 * populationRate; // expected regiment size const mergeable = (n0, n1) => (!n0.s && !n1.s) || n0.u === n1.u; // check if regiments can be merged @@ -172,9 +164,10 @@ window.Military = (function () { valid.forEach(s => { s.military = createRegiments(s.temp.platoons, s); delete s.temp; // do not store temp data - drawRegiments(s.military, s.i); }); + redraw(); + function createRegiments(nodes, s) { if (!nodes.length) return []; @@ -236,6 +229,16 @@ window.Military = (function () { TIME && console.timeEnd("generateMilitaryForces"); }; + function redraw() { + const validStates = pack.states.filter(s => s.i && !s.removed); + armies.selectAll("g > g").each(function () { + const index = notes.findIndex(n => n.id === this.id); + if (index != -1) notes.splice(index, 1); + }); + armies.selectAll("g").remove(); + validStates.forEach(s => drawRegiments(s.military, s.i)); + } + const getDefaultOptions = function () { return [ {icon: "⚔️", name: "infantry", rural: 0.25, urban: 0.2, crew: 1, power: 1, type: "melee", separate: 0}, @@ -406,5 +409,5 @@ window.Military = (function () { notes.push({id: `regiment${s.i}-${r.i}`, name: `${r.icon} ${r.name}`, legend}); }; - return {generate, getDefaultOptions, getName, generateNote, drawRegiments, drawRegiment, moveRegiment, getTotal, getEmblem}; + return {generate, redraw, getDefaultOptions, getName, generateNote, drawRegiments, drawRegiment, moveRegiment, getTotal, getEmblem}; })(); diff --git a/modules/river-generator.js b/modules/river-generator.js index 0943ea9c..957fe6fc 100644 --- a/modules/river-generator.js +++ b/modules/river-generator.js @@ -35,10 +35,12 @@ window.Rivers = (function () { TIME && console.timeEnd("generateRivers"); function drainWater() { + //const MIN_FLUX_TO_FORM_RIVER = 10 * distanceScale; const MIN_FLUX_TO_FORM_RIVER = 30; const cellsNumberModifier = (pointsInput.dataset.cells / 10000) ** 0.25; const prec = grid.cells.prec; + const area = pack.cells.area; const land = cells.i.filter(i => h[i] >= 20).sort((a, b) => h[b] - h[a]); const lakeOutCells = Lakes.setClimateData(h); diff --git a/modules/submap.js b/modules/submap.js new file mode 100644 index 00000000..d46be5e9 --- /dev/null +++ b/modules/submap.js @@ -0,0 +1,411 @@ +"use strict"; +/* +Cell resampler module used by submapper and resampler (transform) +main function: resample(options); +*/ + +window.Submap = (function () { + const isWater = (map, id) => map.grid.cells.h[map.pack.cells.g[id]] < 20? true: false; + const inMap = (x,y) => x>0 && x0 && y[Number, Number] + function to calculate new coordinates + inverse: g(Number,Number)->[Number, Number] + inverse of f + depressRivers: Bool carve out riverbeds? + smoothHeightMap: Bool run smooth filter on heights + addLakesInDepressions: call FMG original funtion on heightmap + + lockMarkers: Bool Auto lock all copied markers + lockBurgs: Bool Auto lock all copied burgs + } + */ + + const projection = options.projection; + const inverse = options.inverse; + const stage = s => INFO && console.log('SUBMAP:', s); + const timeStart = performance.now(); + const childMap = { grid, pack } + invokeActiveZooming(); + + // copy seed + seed = parentMap.seed; + Math.random = aleaPRNG(seed); + INFO && console.group("SubMap with seed: " + seed); + DEBUG && console.log("Using Options:", options); + + // create new grid + applyMapSize(); + placePoints(); + calculateVoronoi(grid, grid.points); + drawScaleBar(scale); + + const resampler = (points, qtree, f) => { + for(const [i,[x, y]] of points.entries()) { + const [tx, ty] = inverse(x, y); + const oldid = qtree.find(tx,ty,Infinity)[2]; + f(i, oldid); + } + } + + stage("Resampling heightmap, temperature and precipitation.") + // resample heightmap from old WorldState + const n = grid.points.length; + grid.cells.h = new Uint8Array(n); // heightmap + grid.cells.temp = new Int8Array(n); // temperature + grid.cells.prec = new Int8Array(n); // precipitation + const reverseGridMap = new Uint32Array(n); // cellmap from new -> oldcell + + const oldGrid = parentMap.grid; + // build cache old -> [newcelllist] + const forwardGridMap = parentMap.grid.points.map(_=>[]); + resampler(grid.points, parentMap.pack.cells.q, (id, oldid) => { + const cid = parentMap.pack.cells.g[oldid]; + grid.cells.h[id] = oldGrid.cells.h[cid]; + grid.cells.temp[id] = oldGrid.cells.temp[cid]; + grid.cells.prec[id] = oldGrid.cells.prec[cid]; + if (options.depressRivers) forwardGridMap[cid].push(id); + reverseGridMap[id] = cid; + }) + // TODO: add smooth/noise function for h, temp, prec n times + + // smooth heightmap + // smoothing should never change cell type (land->water or water->land) + + if (options.smoothHeightMap) { + const gcells = grid.cells; + gcells.h.forEach((h,i) => { + const hs = gcells.c[i].map(c=>gcells.h[c]) + hs.push(h) + gcells.h[i] = h>=20 + ? Math.max(d3.mean(hs),20) + : Math.min(d3.mean(hs),19); + }); + } + + if (options.depressRivers) { + stage("Generating riverbeds.") + const rbeds = new Uint16Array(grid.cells.i.length); + + // and erode riverbeds + parentMap.pack.rivers.forEach(r => + r.cells.forEach(oldpc => { + if (oldpc < 0) return; // ignore out-of-map marker (-1) + const oldc = parentMap.pack.cells.g[oldpc]; + const targetCells = forwardGridMap[oldc]; + if (!targetCells) + throw "TargetCell shouldn't be empty."; + targetCells.forEach(c => { + if (grid.cells.h[c]<20) return; + rbeds[c] = 1; + }); + }) + ); + // raise every land cell a bit except riverbeds + grid.cells.h.forEach((h, i) => { + if (rbeds[i] || h<20) return; + grid.cells.h[i] = Math.min(h+2, 100); + }); + } + + stage("Detect features, ocean and generating lakes.") + markFeatures(); + markupGridOcean(); + + // Warning: addLakesInDeepDepressions can be very slow! + if (options.addLakesInDepressions) { + addLakesInDeepDepressions(); + openNearSeaLakes(); + } + + OceanLayers(); + + calculateMapCoordinates(); + // calculateTemperatures(); + // generatePrecipitation(); + stage("Cell cleanup.") + reGraph(); + + // remove misclassified cells + stage("Define coastline.") + drawCoastline(); + + /****************************************************/ + /* Packed Graph */ + /****************************************************/ + const oldCells = parentMap.pack.cells; + // const reverseMap = new Map(); // cellmap from new -> oldcell + const forwardMap = parentMap.pack.cells.p.map(_=>[]); // old -> [newcelllist] + + const pn = pack.cells.i.length; + const cells = pack.cells; + cells.culture = new Uint16Array(pn); + 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.") + for(const [id, gridCellId] of cells.g.entries()) { + const oldGridId = reverseGridMap[gridCellId]; + if (oldGridId === undefined) { + console.error('Can not find old cell id', reverseGridMap, 'in', gridCellId); + continue; + } + // find old parent's children + const oldChildren = oldCells.i.filter(oid=>oldCells.g[oid]==oldGridId); + let oldid; // matching cell on the original map + + if (!oldChildren.length) { + // it *must* be a (deleted) deep ocean cell + if (!oldGrid.cells.h[oldGridId] < 20) { + console.error(`Warning, ${gridCellId} should be water cell, not ${oldGrid.cells.h[oldGridId]}`); + continue; + } + // find replacement: closest water cell + const [ox, oy] = cells.p[id]; + const [tx, ty] = inverse(x, y); + oldid = oldCells.q.find(tx,ty,Infinity)[2]; + if (!oldid) { + console.warn("Warning, no id found in quad", id, "parent", gridCellId); + continue; + } + } else { + // find closest children (packcell) on the parent map + const distance = x => (x[0]-cells.p[id][0])**2 + (x[1]-cells.p[id][1])**2; + let d = Infinity; + oldChildren.forEach(oid => { + // this should be always true, unless some algo modded the height! + if (isWater(parentMap, oid) !== isWater(childMap, id)) { + console.warn(`cell sank because of addLakesInDepressions: ${oid}`); + return; + } + const [oldpx, oldpy] = oldCells.p[oid]; + const nd = distance(projection(oldpx, oldpy)); + if (isNaN(nd)) { + console.error("Distance is not a number!", "Old point:", oldpx, oldpy); + } + if (nd < d) [d, oldid] = [nd, oid]; + }) + if (oldid === undefined) { + console.warn("Warning, no match for", id, "(parent:", gridCellId, ")"); + continue; + } + } + + if (isWater(childMap, id) !== isWater(parentMap, oldid)) { + WARN && console.warn('Type discrepancy detected:', id, oldid, `${pack.cells.t[id]} != ${oldCells.t[oldid]}`); + } + + cells.culture[id] = oldCells.culture[oldid]; + cells.state[id] = oldCells.state[oldid]; + cells.religion[id] = oldCells.religion[oldid]; + cells.province[id] = oldCells.province[oldid]; + // reverseMap.set(id, oldid) + forwardMap[oldid].push(id) + } + + stage("Regenerating river network.") + Rivers.generate(); + drawRivers(); + Lakes.defineGroup(); + + // biome calculation based on (resampled) grid.cells.temp and prec + // it's safe to recalculate. + stage("Regenerating Biome."); + defineBiomes(); + // recalculate suitability and population + // TODO: normalize according to the base-map + rankCells(); + + stage("Porting Cultures"); + pack.cultures = parentMap.pack.cultures; + // fix culture centers + const validCultures = new Set(pack.cells.culture); + pack.cultures.forEach((c, i) => { + if (!i) return // ignore wildlands + if (!validCultures.has(i)) { + c.removed = true; + c.center = null; + return + } + const newCenters = forwardMap[c.center] + c.center = newCenters.length + ? newCenters[0] + : pack.cells.culture.findIndex(x => x===i); + }); + + stage("Porting and locking burgs."); + copyBurgs(parentMap, projection, options); + + // transfer states, mark states without land as removed. + stage("Porting states."); + const validStates = new Set(pack.cells.state); + pack.states = parentMap.pack.states; + // keep valid states and neighbors only + pack.states.forEach((s, i) => { + if (!s.i || s.removed) return; // ignore removed and neutrals + if (!validStates.has(i)) s.removed = true; + s.neighbors = s.neighbors.filter(n => validStates.has(n)); + + // find center + s.center = pack.burgs[s.capital].cell + ? pack.burgs[s.capital].cell // capital is the best bet + : pack.cells.state.findIndex(x => x===i); // otherwise use the first valid cell + }); + + // transfer provinces, mark provinces without land as removed. + stage("Porting provinces."); + const validProvinces = new Set(pack.cells.province); + pack.provinces = parentMap.pack.provinces; + // mark uneccesary provinces + pack.provinces.forEach((p, i) => { + if (!p || p.removed) return; + if (!validProvinces.has(i)) { + p.removed = true; + return + } + const newCenters = forwardMap[p.center] + p.center = newCenters.length + ? newCenters[0] + : pack.cells.province.findIndex(x => x===i); + }); + + BurgsAndStates.drawBurgs(); + + stage("Regenerating road network."); + Routes.regenerate(); + + drawStates(); + drawBorders(); + BurgsAndStates.drawStateLabels(); + + Rivers.specify(); + Lakes.generateName(); + + stage("Porting military."); + for (const s of pack.states) { + if (!s.military) continue; + for (const m of s.military) { + [m.x, m.y] = projection(m.x, m.y); + [m.bx, m.by] = projection(m.bx, m.by); + const cc = forwardMap[m.cell]; + m.cell = (cc && cc.length)? cc[0]: null; + } + s.military = s.military.filter(m=>m.cell).map((m, i) => ({...m, i})); + } + Military.redraw(); + + stage("Copying markers."); + for (const m of pack.markers) { + const [x, y] = projection(m.x, m.y); + if (!inMap(x, y)) { + Markers.deleteMarker(m.i); + } else { + m.x = x; + m.y = y; + m.cell = findCell(x, y); + if (options.lockMarkers) m.lock = true; + } + } + drawMarkers(); + + stage("Regenerating Zones."); + addZones(); + Names.getMapName(); + stage("Submap done."); + + WARN && console.warn(`TOTAL: ${rn((performance.now() - timeStart) / 1000, 2)}s`); + showStatistics(); + INFO && console.groupEnd("Generated Map " + seed); + } + + /* find the nearest cell accepted by filter f *and* having at + * least one *neighbor* fulfilling filter g, up to cell-distance `max` + * returns [cellid, neighbor] tuple or undefined if no such cell. + * accepts coordinates (x, y) + */ + const findNearest = (f, g, max=3) => (px,py) => { + const d2 = c => (px-pack.cells.p[c][0])**2 + (py-pack.cells.p[c][0])**2 + const startCell = findCell(px, py); + const tested = new Set([startCell]); // ignore analyzed cells + const kernel = (cs, level) => { + const [bestf, bestg] = cs.filter(f).reduce(([cf, cg], c) => { + const neighbors = pack.cells.c[c]; + const betterg = neighbors.filter(g).reduce((u, x) => d2(x) pack.cells.c[c]).flat()) + const ring = Array.from(targets).filter(nc => !tested.has(nc)); + if (level >= max || !ring.length) + return [undefined, undefined]; + ring.forEach(c => tested.add(c)); + return kernel(ring, level+1); + } + const pair = kernel([startCell], 1); + return pair; + } + + function copyBurgs(parentMap, projection, options) { + const cells = pack.cells; + const childMap = { grid, pack } + pack.burgs = parentMap.pack.burgs; + + // remap burgs to the best new cell + pack.burgs.forEach( (b, id) => { + if (id == 0) return; // skip empty city of neturals + [b.x, b.y] = projection(b.x, b.y); + + // disable out-of-map (removed) burgs + if (!inMap(b.x,b.y)) { + b.removed = true; + b.cell = null; + return; + } + + const cityCell = findCell(b.x, b.y); + let searchFunc; + const isFreeLand = c => cells.t[c] === 1 && !cells.burg[c]; + const nearCoast = c => cells.t[c] === -1; + + // check if we need to relocate the burg + if (cells.burg[cityCell]) // already occupied + searchFunc = findNearest(isFreeLand, _ => true, 3); + + if (isWater(childMap, cityCell) || b.port) // burg is in water or port + searchFunc = findNearest(isFreeLand, nearCoast, 6); + + if (searchFunc) { + const [newCell, neighbor] = searchFunc(b.x, b.y); + if (!newCell) { + WARN && console.warn(`Can not relocate Burg: ${b.name} sunk and destroyed. :-(`); + b.cell = null; + b.removed = true; + return; + } + DEBUG && console.log(`Moving ${b.name} from ${cityCell} to ${newCell} near ${neighbor}.`); + [b.x, b.y] = b.port? getMiddlePoint(newCell, neighbor): cells.p[newCell]; + if (b.port) b.port = cells.f[neighbor]; // copy feature number + b.cell = newCell; + if (b.port && !isWater(childMap, neighbor)) console.error('betrayal! negihbor must be water!', b); + } else { + b.cell = cityCell; + } + if (!b.lock) b.lock = options.lockBurgs; + cells.burg[b.cell] = id; + }); + } + + // export + return { resample, findNearest } +})(); diff --git a/modules/ui/burg-editor.js b/modules/ui/burg-editor.js index 8ec8b61a..0e7aacf0 100644 --- a/modules/ui/burg-editor.js +++ b/modules/ui/burg-editor.js @@ -266,14 +266,7 @@ function editBurg(id) { toggleNewGroupInput(); document.getElementById("burgInputGroup").value = ""; - const newLabelG = document.querySelector("#burgLabels").appendChild(labelG.cloneNode(false)); - newLabelG.id = group; - const newIconG = document.querySelector("#burgIcons").appendChild(iconG.cloneNode(false)); - newIconG.id = group; - if (anchor) { - const newAnchorG = document.querySelector("#anchors").appendChild(anchorG.cloneNode(false)); - newAnchorG.id = group; - } + addBurgsGroup(group); moveBurgToGroup(id, group); } diff --git a/modules/ui/editors.js b/modules/ui/editors.js index e13f8eff..819a2637 100644 --- a/modules/ui/editors.js +++ b/modules/ui/editors.js @@ -169,7 +169,7 @@ function moveBurgToGroup(id, g) { const icon = document.querySelector("#burgIcons [data-id='" + id + "']"); const anchor = document.querySelector("#anchors [data-id='" + id + "']"); if (!label || !icon) { - ERROR && console.error("Cannot find label or icon elements"); + ERROR && console.error(`Cannot find label or icon elements for id ${id}`); return; } @@ -190,6 +190,25 @@ function moveBurgToGroup(id, g) { } } +function moveAllBurgsToGroup(fromGroup, toGroup) { + const groupToMove = document.querySelector(`#burgIcons #${fromGroup}`); + const burgsToMove = Array.from(groupToMove.children).map(x=>x.dataset.id); + addBurgsGroup(toGroup) + burgsToMove.forEach(x=>moveBurgToGroup(x, toGroup)); +} + +function addBurgsGroup(group) { + if (document.querySelector(`#burgLabels > #${group}`)) return; + const labelCopy = document.querySelector("#burgLabels > #towns").cloneNode(false); + const iconCopy = document.querySelector("#burgIcons > #towns").cloneNode(false); + const anchorCopy = document.querySelector("#anchors > #towns").cloneNode(false); + + // FIXME: using the same id is against the spec! + document.querySelector("#burgLabels").appendChild(labelCopy).id = group; + document.querySelector("#burgIcons").appendChild(iconCopy).id = group; + document.querySelector("#anchors").appendChild(anchorCopy).id = group; +} + function removeBurg(id) { const label = document.querySelector("#burgLabels [data-id='" + id + "']"); const icon = document.querySelector("#burgIcons [data-id='" + id + "']"); diff --git a/modules/ui/markers-editor.js b/modules/ui/markers-editor.js index d1f1cba4..26f035fa 100644 --- a/modules/ui/markers-editor.js +++ b/modules/ui/markers-editor.js @@ -241,8 +241,7 @@ function editMarker(markerI) { } function deleteMarker() { - notes = notes.filter(note => note.id !== element.id); - pack.markers = pack.markers.filter(m => m.i !== marker.i); + Markers.deleteMarker(marker.i) element.remove(); $("#markerEditor").dialog("close"); if (document.getElementById("markersOverviewRefresh").offsetParent) markersOverviewRefresh.click(); diff --git a/modules/ui/options.js b/modules/ui/options.js index f9c9473c..89642f5d 100644 --- a/modules/ui/options.js +++ b/modules/ui/options.js @@ -335,25 +335,25 @@ function copyMapURL() { .catch(err => tip("Could not copy URL: " + err, false, "error", 5000)); } -function changeCellsDensity(value) { - const convert = v => { - if (v == 1) return 1000; - if (v == 2) return 2000; - if (v == 3) return 5000; - if (v == 4) return 10000; - if (v == 5) return 20000; - if (v == 6) return 30000; - if (v == 7) return 40000; - if (v == 8) return 50000; - if (v == 9) return 60000; - if (v == 10) return 70000; - if (v == 11) return 80000; - if (v == 12) return 90000; - if (v == 13) return 100000; - }; - const cells = convert(value); +const cellsDensityConstants = { + 1: 1000, + 2: 2000, + 3: 5000, + 4: 10000, + 5: 20000, + 6: 30000, + 7: 40000, + 8: 50000, + 9: 60000, + 10: 70000, + 11: 80000, + 12: 90000, + 13: 100000, +}; - pointsInput.setAttribute("data-cells", cells); +function changeCellsDensity(value) { + const cells = value in cellsDensityConstants? cellsDensityConstants[value]: 1000; + pointsInput.dataset.cells = cells; pointsOutput_formatted.value = cells / 1000 + "K"; pointsOutput_formatted.style.color = cells > 50000 ? "#b12117" : cells !== 10000 ? "#dfdf12" : "#053305"; } diff --git a/modules/ui/style.js b/modules/ui/style.js index b4179fea..f1586f35 100644 --- a/modules/ui/style.js +++ b/modules/ui/style.js @@ -574,20 +574,20 @@ addFontMethod.addEventListener("change", function () { }); styleFontSize.addEventListener("change", function () { - changeFontSize(+this.value); + changeFontSize(getEl(), +this.value); }); styleFontPlus.addEventListener("click", function () { const size = +getEl().attr("data-size") + 1; - changeFontSize(Math.min(size, 999)); + changeFontSize(getEl(), Math.min(size, 999)); }); styleFontMinus.addEventListener("click", function () { const size = +getEl().attr("data-size") - 1; - changeFontSize(Math.max(size, 1)); + changeFontSize(getEl(), Math.max(size, 1)); }); -function changeFontSize(size) { +function changeFontSize(el, size) { styleFontSize.value = size; const getSizeOnScale = element => { @@ -600,7 +600,7 @@ function changeFontSize(size) { }; const scaleSize = getSizeOnScale(styleElementSelect.value); - getEl().attr("data-size", size).attr("font-size", scaleSize); + el.attr("data-size", size).attr("font-size", scaleSize); if (styleElementSelect.value === "legend") redrawLegend(); } diff --git a/modules/ui/submap.js b/modules/ui/submap.js new file mode 100644 index 00000000..d3e2d917 --- /dev/null +++ b/modules/ui/submap.js @@ -0,0 +1,156 @@ +"use strict"; + +/* +UI elements for submap generation +*/ + +function openSubmapOptions() { + $("#submapOptionsDialog").dialog({ + title: "Submap options", + resizable: false, + width: fitContent(), + position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}, + buttons: { + Submap: function () { + $(this).dialog("close"); + generateSubmap(); + }, + Cancel: function () { $(this).dialog("close"); }, + } + }); +} + +function openRemapOptions() { + resetZoom(0); + $("#remapOptionsDialog").dialog({ + title: "Resampler options", + resizable: false, + width: fitContent(), + position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}, + buttons: { + Resample: function () { + const cellNumId = Number(document.getElementById('submapPointsInput').value); + const cells = cellsDensityConstants[cellNumId]; + $(this).dialog("close"); + if (!cells) { + console.error('Unknown cell number!'); + return; + } + changeCellsDensity(cellNumId); + resampleCurrentMap(); + }, + Cancel: function () { $(this).dialog("close"); }, + }, + }); +} + +/* callbacks */ + +const resampleCurrentMap = debounce(function () { + // Resample the whole map to different cell resolution or shape + WARN && console.warn("Resampling current map"); + const options = { + lockMarkers: false, + lockBurgs: false, + depressRivers: false, + addLakesInDepressions: false, + promoteTowns: false, + smoothHeightMap: false, + projection: (x,y) => [x, y], + inverse: (x,y) => [x, y], + } + + startResample(options); +}, 1000); + + +const generateSubmap = debounce(function () { + // Create submap from the current map + // submap limits defined by the current window size (canvas viewport) + + WARN && console.warn("Resampling current map"); + closeDialogs("#worldConfigurator, #options3d"); + const checked = id => Boolean(document.getElementById(id).checked) + // Create projection func from current zoom extents + const [[x0, y0], [x1, y1]] = getViewBoxExtent(); + + const options = { + lockMarkers: checked("submapLockMarkers"), + lockBurgs: checked("submapLockBurgs"), + + depressRivers: checked("submapDepressRivers"), + addLakesInDepressions: checked("submapAddLakeInDepression"), + promoteTowns: checked("submapPromoteTowns"), + smoothHeightMap: scale > 2, + inverse: (x,y) => [x * (x1-x0) / graphWidth + x0, y * (y1-y0) / graphHeight + y0], + projection: (x, y) => [(x-x0) * graphWidth / (x1-x0), (y-y0) * graphHeight / (y1-y0)], + } + + // converting map position on the planet + const mapSizeOutput = document.getElementById("mapSizeOutput"); + const latitudeOutput = document.getElementById("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; + document.getElementById("mapSizeInput").value = mapSizeOutput.value; + document.getElementById("latitudeInput").value = latitudeOutput.value; + + // fix scale + 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); + + +async function startResample(options) { + undraw(); + resetZoom(0); + let oldstate = { + grid: deepCopy(grid), + pack: deepCopy(pack), + seed, + graphWidth, + graphHeight, + }; + + 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(); + } + } catch (error) { + showSubmapErrorHandler(error); + } + + oldstate = null; // destroy old state to free memory + + restoreLayers(); + turnButtonOn('toggleMarkers'); + if (ThreeD.options.isOn) ThreeD.redraw(); + if ($("#worldConfigurator").is(":visible")) editWorld(); +} + +function showSubmapErrorHandler(error) { + ERROR && console.error(error); + clearMainTip(); + + alertMessage.innerHTML = `Map resampling failed :_(. +
You may retry after clearing stored data or contact us at discord. +

${parseError(error)}

`; + $("#alert").dialog({ + resizable: false, + title: "Generation error", + width: "32em", + buttons: { + Ok: function () { $(this).dialog("close"); } + }, + position: {my: "center", at: "center", of: "svg"} + }); +} diff --git a/modules/ui/units-editor.js b/modules/ui/units-editor.js index 535b7e14..0f1345e0 100644 --- a/modules/ui/units-editor.js +++ b/modules/ui/units-editor.js @@ -103,6 +103,7 @@ function editUnits() { function restoreDefaultUnits() { // distanceScale + distanceScale = 3; document.getElementById("distanceScaleOutput").value = 3; document.getElementById("distanceScaleInput").value = 3; unlock("distanceScale"); diff --git a/utils/arrayUtils.js b/utils/arrayUtils.js index 854800ce..b59d48e3 100644 --- a/utils/arrayUtils.js +++ b/utils/arrayUtils.js @@ -15,3 +15,37 @@ function common(a, b) { function unique(array) { return [...new Set(array)]; } + +// deep copy for Arrays (and other objects) +function deepCopy(obj) { + const id = x=>x; + const dcTArray = a => a.map(id); + const dcObject = x => Object.fromEntries(Object.entries(x).map(([k,d])=>[k,dcAny(d)])); + const dcAny = x => x instanceof Object ? (cf.get(x.constructor)||id)(x) : x; + // don't map keys, probably this is what we would expect + const dcMapCore = m => [...m.entries()].map(([k,v])=>[k, dcAny(v)]); + + const cf = new Map([ + [Int8Array, dcTArray], + [Uint8Array, dcTArray], + [Uint8ClampedArray, dcTArray], + [Int16Array, dcTArray], + [Uint16Array, dcTArray], + [Int32Array, dcTArray], + [Uint32Array, dcTArray], + [Float32Array, dcTArray], + [Float64Array, dcTArray], + [BigInt64Array, dcTArray], + [BigUint64Array, dcTArray], + [Map, m => new Map(dcMapCore(m))], + [WeakMap, m => new WeakMap(dcMapCore(m))], + [Array, a => a.map(dcAny)], + [Set, s => [...s.values()].map(dcAny)], + [Date, d => new Date(d.getTime())], + [Object, dcObject], + // other types will be referenced + // ... extend here to implement their custom deep copy + ]); + + return dcAny(obj); +}