From b223dc62da49e82f4d176e4aa8d3e3917cda8e7b Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Thu, 22 Jan 2026 17:24:02 +0100 Subject: [PATCH 1/8] fix: implement quadtree search for points within a radius (#1274) --- src/utils/graphUtils.ts | 180 ++++++++++++++++++++++------------------ 1 file changed, 97 insertions(+), 83 deletions(-) diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index 875445fb..274d69f9 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -206,13 +206,109 @@ export const findClosestCell = (x: number, y: number, radius = Infinity, packedG return found ? found[2] : undefined; } +/** + * Searches a quadtree for all points within a given radius + * Based on https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e + * @param {number} x - The x coordinate of the search center + * @param {number} y - The y coordinate of the search center + * @param {number} radius - The search radius + * @param {Object} quadtree - The D3 quadtree to search + * @returns {Array} - An array of found data points within the radius + */ +export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => { + const radiusSearchInit = (t: any, radius: number) => { + t.result = []; + (t.x0 = t.x - radius), (t.y0 = t.y - radius); + (t.x3 = t.x + radius), (t.y3 = t.y + radius); + t.radius = radius * radius; + }; + + const radiusSearchVisit = (t: any, d2: number) => { + t.node.data.scanned = true; + if (d2 < t.radius) { + do { + t.result.push(t.node.data); + t.node.data.selected = true; + } while ((t.node = t.node.next)); + } + }; + + class Quad { + node: any; + x0: number; + y0: number; + x1: number; + y1: number; + constructor(node: any, x0: number, y0: number, x1: number, y1: number) { + this.node = node; + this.x0 = x0; + this.y0 = y0; + this.x1 = x1; + this.y1 = y1; + } + } + + const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root}; + if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3)); + radiusSearchInit(t, radius); + + var i = 0; + while ((t.q = t.quads.pop())) { + i++; + + // Stop searching if this quadrant can't contain a closer node. + if ( + !(t.node = t.q.node) || + (t.x1 = t.q.x0) > t.x3 || + (t.y1 = t.q.y0) > t.y3 || + (t.x2 = t.q.x1) < t.x0 || + (t.y2 = t.q.y1) < t.y0 + ) + continue; + + // Bisect the current quadrant. + if (t.node.length) { + t.node.explored = true; + var xm: number = (t.x1 + t.x2) / 2, + ym: number = (t.y1 + t.y2) / 2; + + t.quads.push( + new Quad(t.node[3], xm, ym, t.x2, t.y2), + new Quad(t.node[2], t.x1, ym, xm, t.y2), + new Quad(t.node[1], xm, t.y1, t.x2, ym), + new Quad(t.node[0], t.x1, t.y1, xm, ym) + ); + + // Visit the closest quadrant first. + if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) { + t.q = t.quads[t.quads.length - 1]; + t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; + t.quads[t.quads.length - 1 - t.i] = t.q; + } + } + + // Visit this point. (Visiting coincident points isn't necessary!) + else { + var dx = x - +quadtree._x.call(null, t.node.data), + dy = y - +quadtree._y.call(null, t.node.data), + d2 = dx * dx + dy * dy; + radiusSearchVisit(t, d2); + } + } + return t.result; +} + /** * Returns an array of packed cell indexes within a specified radius from given x and y coordinates * @param {number} x - The x coordinate * @param {number} y - The y coordinate + * @param {number} radius - The search radius + * @param {Object} packedGraph - The packed graph containing cells with quadtree + * @returns {number[]} - An array of cell indexes within the radius */ export const findAllCellsInRadius = (x: number, y: number, radius: number, packedGraph: any): number[] => { - const found = packedGraph.cells.q.findAll(x, y, radius); + // Use findAllInQuadtree directly instead of relying on prototype extension + const found = findAllInQuadtree(x, y, radius, packedGraph.cells.q); return found.map((r: any) => r[2]); } @@ -325,88 +421,6 @@ export const isWater = (i: number, packedGraph: any) => { return packedGraph.cells.h[i] < 20; } -export const findAllInQuadtree = (x: number, y: number, radius: number, quadtree: any) => { - const radiusSearchInit = (t: any, radius: number) => { - t.result = []; - (t.x0 = t.x - radius), (t.y0 = t.y - radius); - (t.x3 = t.x + radius), (t.y3 = t.y + radius); - t.radius = radius * radius; - }; - - const radiusSearchVisit = (t: any, d2: number) => { - t.node.data.scanned = true; - if (d2 < t.radius) { - do { - t.result.push(t.node.data); - t.node.data.selected = true; - } while ((t.node = t.node.next)); - } - }; - - class Quad { - node: any; - x0: number; - y0: number; - x1: number; - y1: number; - constructor(node: any, x0: number, y0: number, x1: number, y1: number) { - this.node = node; - this.x0 = x0; - this.y0 = y0; - this.x1 = x1; - this.y1 = y1; - } - } - - const t: any = {x, y, x0: quadtree._x0, y0: quadtree._y0, x3: quadtree._x1, y3: quadtree._y1, quads: [], node: quadtree._root}; - if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3)); - radiusSearchInit(t, radius); - - var i = 0; - while ((t.q = t.quads.pop())) { - i++; - - // Stop searching if this quadrant can’t contain a closer node. - if ( - !(t.node = t.q.node) || - (t.x1 = t.q.x0) > t.x3 || - (t.y1 = t.q.y0) > t.y3 || - (t.x2 = t.q.x1) < t.x0 || - (t.y2 = t.q.y1) < t.y0 - ) - continue; - - // Bisect the current quadrant. - if (t.node.length) { - t.node.explored = true; - var xm: number = (t.x1 + t.x2) / 2, - ym: number = (t.y1 + t.y2) / 2; - - t.quads.push( - new Quad(t.node[3], xm, ym, t.x2, t.y2), - new Quad(t.node[2], t.x1, ym, xm, t.y2), - new Quad(t.node[1], xm, t.y1, t.x2, ym), - new Quad(t.node[0], t.x1, t.y1, xm, ym) - ); - - // Visit the closest quadrant first. - if ((t.i = (+(y >= ym) << 1) | +(x >= xm))) { - t.q = t.quads[t.quads.length - 1]; - t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i]; - t.quads[t.quads.length - 1 - t.i] = t.q; - } - } - - // Visit this point. (Visiting coincident points isn’t necessary!) - else { - var dx = x - +quadtree._x.call(null, t.node.data), - dy = y - +quadtree._y.call(null, t.node.data), - d2 = dx * dx + dy * dy; - radiusSearchVisit(t, d2); - } - } - return t.result; -} // draw raster heightmap preview (not used in main generation) /** From e597d905eb6a96e11156d9016c14dd98d8c421e5 Mon Sep 17 00:00:00 2001 From: kruschen Date: Thu, 22 Jan 2026 17:33:30 +0100 Subject: [PATCH 2/8] Ice Layer Data Model (#1262) * prototype for ice seperation * feat: migrate ice data to new data model and update version to 1.110.0 * refactor: update ice data handling and rendering for improved performance * feat: integrate ice generation and recalculation in heightmap editing * fix ice selection(hopefully) * fix ice selection better(pls) * refactor: remove redundant element selection in ice editing functions * fix: clear ice data before generating glaciers and icebergs * sparse array implementation with reduced updates * fix logic chech in modules/dynamic/auto-update.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: migrate ice data to new data model structure * refactor: streamline ice generation process and clean up rendering functions * refactor: simplify ice rendering logic by removing redundant clearing of old SVG * fix: update editIce function to accept element parameter and improve logic for glacier handling * ice drawing with only type on less occuring glaciers * feat: add compactPackData function to filter out undefined glaciers and icebergs * fix: clear existing ice elements before redrawing in editHeightmap function * fix compact problems on autosave * refactor: unify ice data structure and streamline ice element handling * refactor: improve getNextId function to fill gaps in ice element IDs(optional commit) * just to be sure * bump version in html * fix index.html script import --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/main.js | 2 + public/modules/dynamic/auto-update.js | 102 +++++++++++++--- public/modules/ice.js | 170 ++++++++++++++++++++++++++ public/modules/io/load.js | 3 +- public/modules/io/save.js | 15 ++- public/modules/renderers/draw-ice.js | 70 +++++++++++ public/modules/ui/editors.js | 2 +- public/modules/ui/heightmap-editor.js | 6 + public/modules/ui/ice-editor.js | 80 ++++++------ public/modules/ui/layers.js | 43 ------- public/modules/ui/tools.js | 2 +- public/versioning.js | 2 +- src/index.html | 23 ++-- 13 files changed, 402 insertions(+), 118 deletions(-) create mode 100644 public/modules/ice.js create mode 100644 public/modules/renderers/draw-ice.js diff --git a/public/main.js b/public/main.js index 6da462d5..7dbb9585 100644 --- a/public/main.js +++ b/public/main.js @@ -632,6 +632,8 @@ async function generate(options) { Biomes.define(); Features.defineGroups(); + Ice.generate(); + rankCells(); Cultures.generate(); Cultures.expand(); diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index a3190e3b..6e252330 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -253,8 +253,8 @@ export function resolveVersionConflicts(mapVersion) { const source = findCell(s.x, s.y); const mouth = findCell(e.x, e.y); const name = Rivers.getName(mouth); - const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River"; - pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type}); + const type = length < 25 ? rw({ Creek: 9, River: 3, Brook: 3, Stream: 1 }) : "River"; + pack.rivers.push({ i, parent: 0, length, source, mouth, basin: i, name, type }); }); } @@ -270,7 +270,7 @@ export function resolveVersionConflicts(mapVersion) { const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era"; const eraShort = era[0] + "E"; const military = Military.getDefaultOptions(); - options = {winds, year, era, eraShort, military}; + options = { winds, year, era, eraShort, military }; // v1.3 added campaings data for all states States.generateCampaigns(); @@ -481,7 +481,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.65.0")) { // v1.65 changed rivers data d3.select("#rivers").attr("style", null); // remove style to unhide layer - const {cells, rivers} = pack; + const { cells, rivers } = pack; const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); for (const river of rivers) { @@ -497,8 +497,8 @@ export function resolveVersionConflicts(mapVersion) { for (let i = 0; i <= segments; i++) { const shift = increment * i; - const {x: x1, y: y1} = node.getPointAtLength(length + shift); - const {x: x2, y: y2} = node.getPointAtLength(length - shift); + const { x: x1, y: y1 } = node.getPointAtLength(length + shift); + const { x: x2, y: y2 } = node.getPointAtLength(length - shift); const x = rn((x1 + x2) / 2, 1); const y = rn((y1 + y2) / 2, 1); @@ -565,7 +565,7 @@ export function resolveVersionConflicts(mapVersion) { const fill = circle && circle.getAttribute("fill"); const stroke = circle && circle.getAttribute("stroke"); - const marker = {i, icon, type, x, y, size, cell}; + const marker = { i, icon, type, x, y, size, cell }; if (size && size !== 30) marker.size = size; if (!isNaN(px) && px !== 12) marker.px = px; if (!isNaN(dx) && dx !== 50) marker.dx = dx; @@ -631,7 +631,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.88.0")) { // v1.87 may have incorrect shield for some reason - pack.states.forEach(({coa}) => { + pack.states.forEach(({ coa }) => { if (coa?.shield === "state") delete coa.shield; }); } @@ -639,13 +639,13 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.91.0")) { // from 1.91.00 custom coa is moved to coa object pack.states.forEach(state => { - if (state.coa === "custom") state.coa = {custom: true}; + if (state.coa === "custom") state.coa = { custom: true }; }); pack.provinces.forEach(province => { - if (province.coa === "custom") province.coa = {custom: true}; + if (province.coa === "custom") province.coa = { custom: true }; }); pack.burgs.forEach(burg => { - if (burg.coa === "custom") burg.coa = {custom: true}; + if (burg.coa === "custom") burg.coa = { custom: true }; }); // from 1.91.00 emblems don't have transform attribute @@ -747,7 +747,7 @@ export function resolveVersionConflicts(mapVersion) { const skip = terrs.attr("skip"); const relax = terrs.attr("relax"); - const curveTypes = {0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep"}; + const curveTypes = { 0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep" }; const curve = curveTypes[terrs.attr("curve")] || "curveBasisClosed"; terrs @@ -882,7 +882,7 @@ export function resolveVersionConflicts(mapVersion) { const secondCellId = points[1][2]; const feature = pack.cells.f[secondCellId]; - pack.routes.push({i: pack.routes.length, group, feature, points}); + pack.routes.push({ i: pack.routes.length, group, feature, points }); } } routes.selectAll("path").remove(); @@ -914,7 +914,7 @@ export function resolveVersionConflicts(mapVersion) { const type = this.dataset.type; const color = this.getAttribute("fill"); const cells = this.dataset.cells.split(",").map(Number); - pack.zones.push({i, name, type, cells, color}); + pack.zones.push({ i, name, type, cells, color }); }); zones.style("display", null).selectAll("*").remove(); if (layerIsOn("toggleZones")) drawZones(); @@ -975,7 +975,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.109.0")) { // v1.109.0 added customizable burg groups and icons - options.burgs = {groups: []}; + options.burgs = { groups: [] }; burgIcons.selectAll("circle, use").each(function () { const group = this.parentNode.id; @@ -987,7 +987,7 @@ export function resolveVersionConflicts(mapVersion) { burgIcons.selectAll("g").each(function (_el, index) { const name = this.id; const isDefault = name === "towns"; - options.burgs.groups.push({name, active: true, order: index + 1, isDefault, preview: "watabou-city"}); + options.burgs.groups.push({ name, active: true, order: index + 1, isDefault, preview: "watabou-city" }); if (!this.dataset.icon) this.dataset.icon = "#icon-circle"; const size = Number(this.getAttribute("size") || 2) * 2; @@ -1036,4 +1036,74 @@ export function resolveVersionConflicts(mapVersion) { delete options.showMFCGMap; delete options.villageMaxPopulation; } + + if (isOlderThan("1.111.0")) { + // v1.111.0 moved ice data from SVG to data model + // Migrate old ice SVG elements to new pack.ice structure + if (!pack.ice) { + pack.ice = []; + let iceId = 0; + + const iceLayer = document.getElementById("ice"); + if (iceLayer) { + // Migrate glaciers (type="iceShield") + iceLayer.querySelectorAll("polygon[type='iceShield']").forEach(polygon => { + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "glacier" + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Migrate icebergs + iceLayer.querySelectorAll("polygon:not([type])").forEach(polygon => { + const cellId = +polygon.getAttribute("cell"); + const size = +polygon.getAttribute("size"); + + // points string must exist, cell attribute must be present, and size must be non-zero + if (polygon.getAttribute("cell") === null || !size) return; + + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "iceberg", + cellId, + size + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Clear old SVG elements + iceLayer.querySelectorAll("*").forEach(el => el.remove()); + } else { + // If ice layer element doesn't exist, create it + ice = viewbox.insert("g", "#coastline").attr("id", "ice"); + ice + .attr("opacity", null) + .attr("fill", "#e8f0f6") + .attr("stroke", "#e8f0f6") + .attr("stroke-width", 1) + .attr("filter", "url(#dropShadow05)"); + } + + // Re-render ice from migrated data + if (layerIsOn("toggleIce")) drawIce(); + } + + } } diff --git a/public/modules/ice.js b/public/modules/ice.js new file mode 100644 index 00000000..90c7c3e6 --- /dev/null +++ b/public/modules/ice.js @@ -0,0 +1,170 @@ +"use strict"; + +// Ice layer data model - separates ice data from SVG rendering +window.Ice = (function () { + + // Find next available id for new ice element idealy filling gaps + function getNextId() { + if (pack.ice.length === 0) return 0; + // find gaps in existing ids + const existingIds = pack.ice.map(e => e.i).sort((a, b) => a - b); + for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { + if (!existingIds.includes(id)) return id; + } + return existingIds[existingIds.length - 1] + 1; + } + + // Generate glaciers and icebergs based on temperature and height + function generate() { + clear(); + const { cells, features } = grid; + const { temp, h } = cells; + Math.random = aleaPRNG(seed); + + const ICEBERG_MAX_TEMP = 0; + const GLACIER_MAX_TEMP = -8; + const minMaxTemp = d3.min(temp); + + // Generate glaciers on cold land + { + const type = "iceShield"; + const getType = cellId => + h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null; + const isolines = getIsolines(grid, getType, { polygons: true }); + + if (isolines[type]?.polygons) { + isolines[type].polygons.forEach(points => { + const clipped = clipPoly(points); + pack.ice.push({ + i: getNextId(), + points: clipped, + type: "glacier" + }); + }); + } + } + + // Generate icebergs on cold water + for (const cellId of grid.cells.i) { + const t = temp[cellId]; + if (h[cellId] >= 20) continue; // no icebergs on land + if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs + if (features[cells.f[cellId]].type === "lake") continue; // no icebergs on lakes + if (P(0.8)) continue; // skip most of eligible cells + + const randomFactor = 0.8 + rand() * 0.4; // random size factor + let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero, 1 = full + if (cells.t[cellId] === -1) baseSize /= 1.3; // coastline: smaller icebergs + const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); + + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + + pack.ice.push({ + i: getNextId(), + points, + type: "iceberg", + cellId, + size + }); + } + } + + function addIceberg(cellId, size) { + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + const id = getNextId(); + pack.ice.push({ + i: id, + points, + type: "iceberg", + cellId, + size + }); + redrawIceberg(id); + } + + function removeIce(id) { + const index = pack.ice.findIndex(element => element.i === id); + if (index !== -1) { + const type = pack.ice.find(element => element.i === id).type; + pack.ice.splice(index, 1); + if (type === "glacier") { + redrawGlacier(id); + } else { + redrawIceberg(id); + } + + } + } + + function updateIceberg(id, points, size) { + const iceberg = pack.ice.find(element => element.i === id); + if (iceberg) { + iceberg.points = points; + iceberg.size = size; + } + } + + function randomizeIcebergShape(id) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const size = iceberg.size; + const [cx, cy] = grid.points[cellId]; + + // Get a different random cell for the polygon template + const i = ra(grid.cells.i); + const cn = grid.points[i]; + const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); + const points = poly.map(p => [ + rn(cx + p[0] * size, 2), + rn(cy + p[1] * size, 2) + ]); + + iceberg.points = points; + } + + function changeIcebergSize(id, newSize) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const [cx, cy] = grid.points[cellId]; + const oldSize = iceberg.size; + + const flat = iceberg.points.flat(); + const pairs = []; + while (flat.length) pairs.push(flat.splice(0, 2)); + const poly = pairs.map(p => [(p[0] - cx) / oldSize, (p[1] - cy) / oldSize]); + const points = poly.map(p => [ + rn(cx + p[0] * newSize, 2), + rn(cy + p[1] * newSize, 2) + ]); + + iceberg.points = points; + iceberg.size = newSize; + } + + // Clear all ice + function clear() { + pack.ice = []; + } + + return { + generate, + addIceberg, + removeIce, + updateIceberg, + randomizeIcebergShape, + changeIcebergSize, + clear + }; +})(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 689757b2..9b401733 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -406,6 +406,7 @@ async function parseLoadedData(data, mapVersion) { pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length); // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; + pack.ice = data[39] ? JSON.parse(data[39]) : []; if (data[31]) { const namesDL = data[31].split("/"); @@ -449,7 +450,7 @@ async function parseLoadedData(data, mapVersion) { if (isVisible(routes) && hasChild(routes, "path")) turnOn("toggleRoutes"); if (hasChildren(temperature)) turnOn("toggleTemperature"); if (hasChild(population, "line")) turnOn("togglePopulation"); - if (hasChildren(ice)) turnOn("toggleIce"); + if (isVisible(ice)) turnOn("toggleIce"); if (hasChild(prec, "circle")) turnOn("togglePrecipitation"); if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems"); if (isVisible(labels)) turnOn("toggleLabels"); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 304fef59..25cd7493 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -32,12 +32,13 @@ async function saveMap(method) { $(this).dialog("close"); } }, - position: {my: "center", at: "center", of: "svg"} + position: { my: "center", at: "center", of: "svg" } }); } } function prepareMapData() { + const date = new Date(); const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator"; @@ -89,8 +90,8 @@ function prepareMapData() { const serializedSVG = new XMLSerializer().serializeToString(cloneEl); - const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid; - const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired}); + const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid; + const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired }); const packFeatures = JSON.stringify(pack.features); const cultures = JSON.stringify(pack.cultures); const states = JSON.stringify(pack.states); @@ -102,6 +103,7 @@ function prepareMapData() { const cellRoutes = JSON.stringify(pack.cells.routes); const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); + const ice = JSON.stringify(pack.ice); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -155,21 +157,22 @@ function prepareMapData() { markers, cellRoutes, routes, - zones + zones, + ice ].join("\r\n"); return mapData; } // save map file to indexedDB async function saveToStorage(mapData, showTip = false) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); await ldb.set("lastMap", blob); showTip && tip("Map is saved to the browser storage", false, "success"); } // download map file function saveToMachine(mapData, filename) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); const URL = window.URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/public/modules/renderers/draw-ice.js b/public/modules/renderers/draw-ice.js new file mode 100644 index 00000000..4b35f75c --- /dev/null +++ b/public/modules/renderers/draw-ice.js @@ -0,0 +1,70 @@ +"use strict"; + +// Ice layer renderer - renders ice from data model to SVG +function drawIce() { + TIME && console.time("drawIce"); + + // Clear existing ice SVG + ice.selectAll("*").remove(); + + let html = ""; + + // Draw all ice elements + pack.ice.forEach(iceElement => { + if (iceElement.type === "glacier") { + html += getGlacierHtml(iceElement); + } else if (iceElement.type === "iceberg") { + html += getIcebergHtml(iceElement); + } + }); + + ice.html(html); + + TIME && console.timeEnd("drawIce"); +} + +function redrawIceberg(id) { + TIME && console.time("redrawIceberg"); + const iceberg = pack.ice.find(element => element.i === id); + let el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`); + if (!iceberg && !el.empty()) { + el.remove(); + } else { + if (el.empty()) { + // Create new element if it doesn't exist + const polygon = getIcebergHtml(iceberg); + ice.node().insertAdjacentHTML("beforeend", polygon); + el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`); + } + el.attr("points", iceberg.points); + el.attr("transform", iceberg.offset ? `translate(${iceberg.offset[0]},${iceberg.offset[1]})` : null); + } + TIME && console.timeEnd("redrawIceberg"); +} + +function redrawGlacier(id) { + TIME && console.time("redrawGlacier"); + const glacier = pack.ice.find(element => element.i === id); + let el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`); + if (!glacier && !el.empty()) { + el.remove(); + } else { + if (el.empty()) { + // Create new element if it doesn't exist + const polygon = getGlacierHtml(glacier); + ice.node().insertAdjacentHTML("beforeend", polygon); + el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`); + } + el.attr("points", glacier.points); + el.attr("transform", glacier.offset ? `translate(${glacier.offset[0]},${glacier.offset[1]})` : null); + } + TIME && console.timeEnd("redrawGlacier"); +} + +function getGlacierHtml(glacier) { + return ``; +} + +function getIcebergHtml(iceberg) { + return ``; +} \ No newline at end of file diff --git a/public/modules/ui/editors.js b/public/modules/ui/editors.js index 77c391ee..50eaf1c7 100644 --- a/public/modules/ui/editors.js +++ b/public/modules/ui/editors.js @@ -26,7 +26,7 @@ function clicked() { else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel(); else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); - else if (parent.id === "ice") editIce(); + else if (parent.id === "ice") editIce(el); else if (parent.id === "terrain") editReliefIcon(); else if (grand.id === "markers" || great.id === "markers") editMarker(); else if (grand.id === "coastline") editCoastline(); diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index d655e39d..5c3f1fc3 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -259,6 +259,8 @@ function editHeightmap(options) { Rivers.specify(); Lakes.defineNames(); + Ice.generate(); + Military.generate(); Markers.generate(); Zones.generate(); @@ -465,6 +467,10 @@ function editHeightmap(options) { .attr("id", d => base + d); }); + // recalculate ice + Ice.generate(); + ice.selectAll("*").remove(); + TIME && console.timeEnd("restoreRiskedData"); INFO && console.groupEnd("Edit Heightmap"); } diff --git a/public/modules/ui/ice-editor.js b/public/modules/ui/ice-editor.js index a9e6ff28..16818b4c 100644 --- a/public/modules/ui/ice-editor.js +++ b/public/modules/ui/ice-editor.js @@ -1,26 +1,32 @@ "use strict"; -function editIce() { +function editIce(element) { if (customization) return; + if (elSelected && element === elSelected.node()) return; + closeDialogs(".stable"); if (!layerIsOn("toggleIce")) toggleIce(); elSelected = d3.select(d3.event.target); - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; - document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block"; - document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block"; - if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size"); + const id = +elSelected.attr("data-id"); + const iceElement = pack.ice.find(el => el.i === id); + const isGlacier = elSelected.attr("type") === "glacier"; + const type = isGlacier ? "Glacier" : "Iceberg"; + + document.getElementById("iceRandomize").style.display = isGlacier ? "none" : "inline-block"; + document.getElementById("iceSize").style.display = isGlacier ? "none" : "inline-block"; + if (!isGlacier) document.getElementById("iceSize").value = iceElement?.size || ""; + ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement)); $("#iceEditor").dialog({ title: "Edit " + type, resizable: false, - position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"}, + position: { my: "center top+60", at: "top", of: d3.event, collision: "fit" }, close: closeEditor }); if (modules.editIce) return; modules.editIce = true; - // add listeners document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice")); document.getElementById("iceRandomize").addEventListener("click", randomizeShape); @@ -28,29 +34,18 @@ function editIce() { document.getElementById("iceNew").addEventListener("click", toggleAdd); document.getElementById("iceRemove").addEventListener("click", removeIce); + function randomizeShape() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const i = ra(grid.cells.i), - cn = grid.points[i]; - const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); - const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]); - elSelected.attr("points", points); + const selectedId = +elSelected.attr("data-id"); + Ice.randomizeIcebergShape(selectedId); + redrawIceberg(selectedId); } function changeSize() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const flat = elSelected - .attr("points") - .split(",") - .map(el => +el); - const pairs = []; - while (flat.length) pairs.push(flat.splice(0, 2)); - const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]); - const size = +this.value; - const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]); - elSelected.attr("points", points).attr("size", size); + const newSize = +this.value; + const selectedId = +elSelected.attr("data-id"); + Ice.changeIcebergSize(selectedId, newSize); + redrawIceberg(selectedId); } function toggleAdd() { @@ -67,17 +62,15 @@ function editIce() { function addIcebergOnClick() { const [x, y] = d3.mouse(this); const i = findGridCell(x, y, grid); - const [cx, cy] = grid.points[i]; const size = +document.getElementById("iceSize")?.value || 1; - const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size); - iceberg.call(d3.drag().on("drag", dragElement)); + Ice.addIceberg(i, size); + if (d3.event.shiftKey === false) toggleAdd(); } function removeIce() { - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; + const type = elSelected.attr("type") === "glacier" ? "Glacier" : "Iceberg"; alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`; $("#alert").dialog({ resizable: false, @@ -85,7 +78,7 @@ function editIce() { buttons: { Remove: function () { $(this).dialog("close"); - elSelected.remove(); + Ice.removeIce(+elSelected.attr("data-id")); $("#iceEditor").dialog("close"); }, Cancel: function () { @@ -96,14 +89,24 @@ function editIce() { } function dragElement() { - const tr = parseTransform(this.getAttribute("transform")); - const dx = +tr[0] - d3.event.x, - dy = +tr[1] - d3.event.y; + const selectedId = +elSelected.attr("data-id"); + const initialTransform = parseTransform(this.getAttribute("transform")); + const dx = initialTransform[0] - d3.event.x; + const dy = initialTransform[1] - d3.event.y; d3.event.on("drag", function () { - const x = d3.event.x, - y = d3.event.y; - this.setAttribute("transform", `translate(${dx + x},${dy + y})`); + const x = d3.event.x; + const y = d3.event.y; + const transform = `translate(${dx + x},${dy + y})`; + this.setAttribute("transform", transform); + + // Update data model with new position + const offset = [dx + x, dy + y]; + const iceData = pack.ice.find(element => element.i === selectedId); + if (iceData) { + // Store offset for visual positioning, actual geometry stays in points + iceData.offset = offset; + } }); } @@ -114,3 +117,4 @@ function editIce() { unselect(); } } + diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 5037a5ee..f2f04a4b 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -417,49 +417,6 @@ function toggleIce(event) { } } -function drawIce() { - TIME && console.time("drawIce"); - - const {cells, features} = grid; - const {temp, h} = cells; - Math.random = aleaPRNG(seed); - - const ICEBERG_MAX_TEMP = 0; - const GLACIER_MAX_TEMP = -8; - const minMaxTemp = d3.min(temp); - - // cold land: draw glaciers - { - const type = "iceShield"; - const getType = cellId => (h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null); - const isolines = getIsolines(grid, getType, {polygons: true}); - isolines[type]?.polygons?.forEach(points => { - const clipped = clipPoly(points); - ice.append("polygon").attr("points", clipped).attr("type", type); - }); - } - - // cold water: draw icebergs - for (const cellId of grid.cells.i) { - const t = temp[cellId]; - if (h[cellId] >= 20) continue; // no icebergs on land - if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs - if (features[cells.f[cellId]].type === "lake") continue; // no icebers on lakes - if (P(0.8)) continue; // skip most of eligible cells - - const randomFactor = 0.8 + rand() * 0.4; // random size factor - let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero size, 1 = full size - if (cells.t[cellId] === -1) baseSize /= 1.3; // coasline: smaller icebergs - const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); - - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - ice.append("polygon").attr("points", points).attr("cell", cellId).attr("size", size); - } - - TIME && console.timeEnd("drawIce"); -} - function toggleCultures(event) { const cultures = pack.cultures.filter(c => c.i && !c.removed); const empty = !cults.selectAll("path").size(); diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index a3df5c00..eade993f 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -555,7 +555,7 @@ function regenerateMilitary() { function regenerateIce() { if (!layerIsOn("toggleIce")) toggleIce(); - ice.selectAll("*").remove(); + Ice.generate(); drawIce(); } diff --git a/public/versioning.js b/public/versioning.js index 11fcde66..8069c818 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.110.0"; +const VERSION = "1.111.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index 21d84187..f75cbbfc 100644 --- a/src/index.html +++ b/src/index.html @@ -8515,16 +8515,16 @@ - + - + - - + + @@ -8535,7 +8535,7 @@ - + @@ -8549,12 +8549,12 @@ - - - + + + - + @@ -8566,8 +8566,8 @@ - - + + @@ -8583,5 +8583,6 @@ + From b228a8f6100810aec33ed7de47e47156f3c40033 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Thu, 22 Jan 2026 17:51:20 +0100 Subject: [PATCH 3/8] Revert "Ice Layer Data Model (#1262)" (#1275) This reverts commit e597d905eb6a96e11156d9016c14dd98d8c421e5. --- public/main.js | 2 - public/modules/dynamic/auto-update.js | 102 +++------------- public/modules/ice.js | 170 -------------------------- public/modules/io/load.js | 3 +- public/modules/io/save.js | 15 +-- public/modules/renderers/draw-ice.js | 70 ----------- public/modules/ui/editors.js | 2 +- public/modules/ui/heightmap-editor.js | 6 - public/modules/ui/ice-editor.js | 80 ++++++------ public/modules/ui/layers.js | 43 +++++++ public/modules/ui/tools.js | 2 +- public/versioning.js | 2 +- src/index.html | 23 ++-- 13 files changed, 118 insertions(+), 402 deletions(-) delete mode 100644 public/modules/ice.js delete mode 100644 public/modules/renderers/draw-ice.js diff --git a/public/main.js b/public/main.js index 7dbb9585..6da462d5 100644 --- a/public/main.js +++ b/public/main.js @@ -632,8 +632,6 @@ async function generate(options) { Biomes.define(); Features.defineGroups(); - Ice.generate(); - rankCells(); Cultures.generate(); Cultures.expand(); diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index 6e252330..a3190e3b 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -253,8 +253,8 @@ export function resolveVersionConflicts(mapVersion) { const source = findCell(s.x, s.y); const mouth = findCell(e.x, e.y); const name = Rivers.getName(mouth); - const type = length < 25 ? rw({ Creek: 9, River: 3, Brook: 3, Stream: 1 }) : "River"; - pack.rivers.push({ i, parent: 0, length, source, mouth, basin: i, name, type }); + const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River"; + pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type}); }); } @@ -270,7 +270,7 @@ export function resolveVersionConflicts(mapVersion) { const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era"; const eraShort = era[0] + "E"; const military = Military.getDefaultOptions(); - options = { winds, year, era, eraShort, military }; + options = {winds, year, era, eraShort, military}; // v1.3 added campaings data for all states States.generateCampaigns(); @@ -481,7 +481,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.65.0")) { // v1.65 changed rivers data d3.select("#rivers").attr("style", null); // remove style to unhide layer - const { cells, rivers } = pack; + const {cells, rivers} = pack; const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); for (const river of rivers) { @@ -497,8 +497,8 @@ export function resolveVersionConflicts(mapVersion) { for (let i = 0; i <= segments; i++) { const shift = increment * i; - const { x: x1, y: y1 } = node.getPointAtLength(length + shift); - const { x: x2, y: y2 } = node.getPointAtLength(length - shift); + const {x: x1, y: y1} = node.getPointAtLength(length + shift); + const {x: x2, y: y2} = node.getPointAtLength(length - shift); const x = rn((x1 + x2) / 2, 1); const y = rn((y1 + y2) / 2, 1); @@ -565,7 +565,7 @@ export function resolveVersionConflicts(mapVersion) { const fill = circle && circle.getAttribute("fill"); const stroke = circle && circle.getAttribute("stroke"); - const marker = { i, icon, type, x, y, size, cell }; + const marker = {i, icon, type, x, y, size, cell}; if (size && size !== 30) marker.size = size; if (!isNaN(px) && px !== 12) marker.px = px; if (!isNaN(dx) && dx !== 50) marker.dx = dx; @@ -631,7 +631,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.88.0")) { // v1.87 may have incorrect shield for some reason - pack.states.forEach(({ coa }) => { + pack.states.forEach(({coa}) => { if (coa?.shield === "state") delete coa.shield; }); } @@ -639,13 +639,13 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.91.0")) { // from 1.91.00 custom coa is moved to coa object pack.states.forEach(state => { - if (state.coa === "custom") state.coa = { custom: true }; + if (state.coa === "custom") state.coa = {custom: true}; }); pack.provinces.forEach(province => { - if (province.coa === "custom") province.coa = { custom: true }; + if (province.coa === "custom") province.coa = {custom: true}; }); pack.burgs.forEach(burg => { - if (burg.coa === "custom") burg.coa = { custom: true }; + if (burg.coa === "custom") burg.coa = {custom: true}; }); // from 1.91.00 emblems don't have transform attribute @@ -747,7 +747,7 @@ export function resolveVersionConflicts(mapVersion) { const skip = terrs.attr("skip"); const relax = terrs.attr("relax"); - const curveTypes = { 0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep" }; + const curveTypes = {0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep"}; const curve = curveTypes[terrs.attr("curve")] || "curveBasisClosed"; terrs @@ -882,7 +882,7 @@ export function resolveVersionConflicts(mapVersion) { const secondCellId = points[1][2]; const feature = pack.cells.f[secondCellId]; - pack.routes.push({ i: pack.routes.length, group, feature, points }); + pack.routes.push({i: pack.routes.length, group, feature, points}); } } routes.selectAll("path").remove(); @@ -914,7 +914,7 @@ export function resolveVersionConflicts(mapVersion) { const type = this.dataset.type; const color = this.getAttribute("fill"); const cells = this.dataset.cells.split(",").map(Number); - pack.zones.push({ i, name, type, cells, color }); + pack.zones.push({i, name, type, cells, color}); }); zones.style("display", null).selectAll("*").remove(); if (layerIsOn("toggleZones")) drawZones(); @@ -975,7 +975,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.109.0")) { // v1.109.0 added customizable burg groups and icons - options.burgs = { groups: [] }; + options.burgs = {groups: []}; burgIcons.selectAll("circle, use").each(function () { const group = this.parentNode.id; @@ -987,7 +987,7 @@ export function resolveVersionConflicts(mapVersion) { burgIcons.selectAll("g").each(function (_el, index) { const name = this.id; const isDefault = name === "towns"; - options.burgs.groups.push({ name, active: true, order: index + 1, isDefault, preview: "watabou-city" }); + options.burgs.groups.push({name, active: true, order: index + 1, isDefault, preview: "watabou-city"}); if (!this.dataset.icon) this.dataset.icon = "#icon-circle"; const size = Number(this.getAttribute("size") || 2) * 2; @@ -1036,74 +1036,4 @@ export function resolveVersionConflicts(mapVersion) { delete options.showMFCGMap; delete options.villageMaxPopulation; } - - if (isOlderThan("1.111.0")) { - // v1.111.0 moved ice data from SVG to data model - // Migrate old ice SVG elements to new pack.ice structure - if (!pack.ice) { - pack.ice = []; - let iceId = 0; - - const iceLayer = document.getElementById("ice"); - if (iceLayer) { - // Migrate glaciers (type="iceShield") - iceLayer.querySelectorAll("polygon[type='iceShield']").forEach(polygon => { - // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] - const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); - - const transform = polygon.getAttribute("transform"); - const iceElement = { - i: iceId++, - points, - type: "glacier" - }; - if (transform) { - iceElement.offset = parseTransform(transform); - } - pack.ice.push(iceElement); - }); - - // Migrate icebergs - iceLayer.querySelectorAll("polygon:not([type])").forEach(polygon => { - const cellId = +polygon.getAttribute("cell"); - const size = +polygon.getAttribute("size"); - - // points string must exist, cell attribute must be present, and size must be non-zero - if (polygon.getAttribute("cell") === null || !size) return; - - // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] - const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); - - const transform = polygon.getAttribute("transform"); - const iceElement = { - i: iceId++, - points, - type: "iceberg", - cellId, - size - }; - if (transform) { - iceElement.offset = parseTransform(transform); - } - pack.ice.push(iceElement); - }); - - // Clear old SVG elements - iceLayer.querySelectorAll("*").forEach(el => el.remove()); - } else { - // If ice layer element doesn't exist, create it - ice = viewbox.insert("g", "#coastline").attr("id", "ice"); - ice - .attr("opacity", null) - .attr("fill", "#e8f0f6") - .attr("stroke", "#e8f0f6") - .attr("stroke-width", 1) - .attr("filter", "url(#dropShadow05)"); - } - - // Re-render ice from migrated data - if (layerIsOn("toggleIce")) drawIce(); - } - - } } diff --git a/public/modules/ice.js b/public/modules/ice.js deleted file mode 100644 index 90c7c3e6..00000000 --- a/public/modules/ice.js +++ /dev/null @@ -1,170 +0,0 @@ -"use strict"; - -// Ice layer data model - separates ice data from SVG rendering -window.Ice = (function () { - - // Find next available id for new ice element idealy filling gaps - function getNextId() { - if (pack.ice.length === 0) return 0; - // find gaps in existing ids - const existingIds = pack.ice.map(e => e.i).sort((a, b) => a - b); - for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { - if (!existingIds.includes(id)) return id; - } - return existingIds[existingIds.length - 1] + 1; - } - - // Generate glaciers and icebergs based on temperature and height - function generate() { - clear(); - const { cells, features } = grid; - const { temp, h } = cells; - Math.random = aleaPRNG(seed); - - const ICEBERG_MAX_TEMP = 0; - const GLACIER_MAX_TEMP = -8; - const minMaxTemp = d3.min(temp); - - // Generate glaciers on cold land - { - const type = "iceShield"; - const getType = cellId => - h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null; - const isolines = getIsolines(grid, getType, { polygons: true }); - - if (isolines[type]?.polygons) { - isolines[type].polygons.forEach(points => { - const clipped = clipPoly(points); - pack.ice.push({ - i: getNextId(), - points: clipped, - type: "glacier" - }); - }); - } - } - - // Generate icebergs on cold water - for (const cellId of grid.cells.i) { - const t = temp[cellId]; - if (h[cellId] >= 20) continue; // no icebergs on land - if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs - if (features[cells.f[cellId]].type === "lake") continue; // no icebergs on lakes - if (P(0.8)) continue; // skip most of eligible cells - - const randomFactor = 0.8 + rand() * 0.4; // random size factor - let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero, 1 = full - if (cells.t[cellId] === -1) baseSize /= 1.3; // coastline: smaller icebergs - const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); - - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [ - rn(lerp(cx, x, size), 2), - rn(lerp(cy, y, size), 2) - ]); - - pack.ice.push({ - i: getNextId(), - points, - type: "iceberg", - cellId, - size - }); - } - } - - function addIceberg(cellId, size) { - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [ - rn(lerp(cx, x, size), 2), - rn(lerp(cy, y, size), 2) - ]); - const id = getNextId(); - pack.ice.push({ - i: id, - points, - type: "iceberg", - cellId, - size - }); - redrawIceberg(id); - } - - function removeIce(id) { - const index = pack.ice.findIndex(element => element.i === id); - if (index !== -1) { - const type = pack.ice.find(element => element.i === id).type; - pack.ice.splice(index, 1); - if (type === "glacier") { - redrawGlacier(id); - } else { - redrawIceberg(id); - } - - } - } - - function updateIceberg(id, points, size) { - const iceberg = pack.ice.find(element => element.i === id); - if (iceberg) { - iceberg.points = points; - iceberg.size = size; - } - } - - function randomizeIcebergShape(id) { - const iceberg = pack.ice.find(element => element.i === id); - if (!iceberg) return; - - const cellId = iceberg.cellId; - const size = iceberg.size; - const [cx, cy] = grid.points[cellId]; - - // Get a different random cell for the polygon template - const i = ra(grid.cells.i); - const cn = grid.points[i]; - const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); - const points = poly.map(p => [ - rn(cx + p[0] * size, 2), - rn(cy + p[1] * size, 2) - ]); - - iceberg.points = points; - } - - function changeIcebergSize(id, newSize) { - const iceberg = pack.ice.find(element => element.i === id); - if (!iceberg) return; - - const cellId = iceberg.cellId; - const [cx, cy] = grid.points[cellId]; - const oldSize = iceberg.size; - - const flat = iceberg.points.flat(); - const pairs = []; - while (flat.length) pairs.push(flat.splice(0, 2)); - const poly = pairs.map(p => [(p[0] - cx) / oldSize, (p[1] - cy) / oldSize]); - const points = poly.map(p => [ - rn(cx + p[0] * newSize, 2), - rn(cy + p[1] * newSize, 2) - ]); - - iceberg.points = points; - iceberg.size = newSize; - } - - // Clear all ice - function clear() { - pack.ice = []; - } - - return { - generate, - addIceberg, - removeIce, - updateIceberg, - randomizeIcebergShape, - changeIcebergSize, - clear - }; -})(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 9b401733..689757b2 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -406,7 +406,6 @@ async function parseLoadedData(data, mapVersion) { pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length); // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; - pack.ice = data[39] ? JSON.parse(data[39]) : []; if (data[31]) { const namesDL = data[31].split("/"); @@ -450,7 +449,7 @@ async function parseLoadedData(data, mapVersion) { if (isVisible(routes) && hasChild(routes, "path")) turnOn("toggleRoutes"); if (hasChildren(temperature)) turnOn("toggleTemperature"); if (hasChild(population, "line")) turnOn("togglePopulation"); - if (isVisible(ice)) turnOn("toggleIce"); + if (hasChildren(ice)) turnOn("toggleIce"); if (hasChild(prec, "circle")) turnOn("togglePrecipitation"); if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems"); if (isVisible(labels)) turnOn("toggleLabels"); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 25cd7493..304fef59 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -32,13 +32,12 @@ async function saveMap(method) { $(this).dialog("close"); } }, - position: { my: "center", at: "center", of: "svg" } + position: {my: "center", at: "center", of: "svg"} }); } } function prepareMapData() { - const date = new Date(); const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator"; @@ -90,8 +89,8 @@ function prepareMapData() { const serializedSVG = new XMLSerializer().serializeToString(cloneEl); - const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid; - const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired }); + const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid; + const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired}); const packFeatures = JSON.stringify(pack.features); const cultures = JSON.stringify(pack.cultures); const states = JSON.stringify(pack.states); @@ -103,7 +102,6 @@ function prepareMapData() { const cellRoutes = JSON.stringify(pack.cells.routes); const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); - const ice = JSON.stringify(pack.ice); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -157,22 +155,21 @@ function prepareMapData() { markers, cellRoutes, routes, - zones, - ice + zones ].join("\r\n"); return mapData; } // save map file to indexedDB async function saveToStorage(mapData, showTip = false) { - const blob = new Blob([mapData], { type: "text/plain" }); + const blob = new Blob([mapData], {type: "text/plain"}); await ldb.set("lastMap", blob); showTip && tip("Map is saved to the browser storage", false, "success"); } // download map file function saveToMachine(mapData, filename) { - const blob = new Blob([mapData], { type: "text/plain" }); + const blob = new Blob([mapData], {type: "text/plain"}); const URL = window.URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/public/modules/renderers/draw-ice.js b/public/modules/renderers/draw-ice.js deleted file mode 100644 index 4b35f75c..00000000 --- a/public/modules/renderers/draw-ice.js +++ /dev/null @@ -1,70 +0,0 @@ -"use strict"; - -// Ice layer renderer - renders ice from data model to SVG -function drawIce() { - TIME && console.time("drawIce"); - - // Clear existing ice SVG - ice.selectAll("*").remove(); - - let html = ""; - - // Draw all ice elements - pack.ice.forEach(iceElement => { - if (iceElement.type === "glacier") { - html += getGlacierHtml(iceElement); - } else if (iceElement.type === "iceberg") { - html += getIcebergHtml(iceElement); - } - }); - - ice.html(html); - - TIME && console.timeEnd("drawIce"); -} - -function redrawIceberg(id) { - TIME && console.time("redrawIceberg"); - const iceberg = pack.ice.find(element => element.i === id); - let el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`); - if (!iceberg && !el.empty()) { - el.remove(); - } else { - if (el.empty()) { - // Create new element if it doesn't exist - const polygon = getIcebergHtml(iceberg); - ice.node().insertAdjacentHTML("beforeend", polygon); - el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`); - } - el.attr("points", iceberg.points); - el.attr("transform", iceberg.offset ? `translate(${iceberg.offset[0]},${iceberg.offset[1]})` : null); - } - TIME && console.timeEnd("redrawIceberg"); -} - -function redrawGlacier(id) { - TIME && console.time("redrawGlacier"); - const glacier = pack.ice.find(element => element.i === id); - let el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`); - if (!glacier && !el.empty()) { - el.remove(); - } else { - if (el.empty()) { - // Create new element if it doesn't exist - const polygon = getGlacierHtml(glacier); - ice.node().insertAdjacentHTML("beforeend", polygon); - el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`); - } - el.attr("points", glacier.points); - el.attr("transform", glacier.offset ? `translate(${glacier.offset[0]},${glacier.offset[1]})` : null); - } - TIME && console.timeEnd("redrawGlacier"); -} - -function getGlacierHtml(glacier) { - return ``; -} - -function getIcebergHtml(iceberg) { - return ``; -} \ No newline at end of file diff --git a/public/modules/ui/editors.js b/public/modules/ui/editors.js index 50eaf1c7..77c391ee 100644 --- a/public/modules/ui/editors.js +++ b/public/modules/ui/editors.js @@ -26,7 +26,7 @@ function clicked() { else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel(); else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); - else if (parent.id === "ice") editIce(el); + else if (parent.id === "ice") editIce(); else if (parent.id === "terrain") editReliefIcon(); else if (grand.id === "markers" || great.id === "markers") editMarker(); else if (grand.id === "coastline") editCoastline(); diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index 5c3f1fc3..d655e39d 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -259,8 +259,6 @@ function editHeightmap(options) { Rivers.specify(); Lakes.defineNames(); - Ice.generate(); - Military.generate(); Markers.generate(); Zones.generate(); @@ -467,10 +465,6 @@ function editHeightmap(options) { .attr("id", d => base + d); }); - // recalculate ice - Ice.generate(); - ice.selectAll("*").remove(); - TIME && console.timeEnd("restoreRiskedData"); INFO && console.groupEnd("Edit Heightmap"); } diff --git a/public/modules/ui/ice-editor.js b/public/modules/ui/ice-editor.js index 16818b4c..a9e6ff28 100644 --- a/public/modules/ui/ice-editor.js +++ b/public/modules/ui/ice-editor.js @@ -1,32 +1,26 @@ "use strict"; -function editIce(element) { +function editIce() { if (customization) return; - if (elSelected && element === elSelected.node()) return; - closeDialogs(".stable"); if (!layerIsOn("toggleIce")) toggleIce(); elSelected = d3.select(d3.event.target); - const id = +elSelected.attr("data-id"); - const iceElement = pack.ice.find(el => el.i === id); - const isGlacier = elSelected.attr("type") === "glacier"; - const type = isGlacier ? "Glacier" : "Iceberg"; - - document.getElementById("iceRandomize").style.display = isGlacier ? "none" : "inline-block"; - document.getElementById("iceSize").style.display = isGlacier ? "none" : "inline-block"; - if (!isGlacier) document.getElementById("iceSize").value = iceElement?.size || ""; - + const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; + document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block"; + document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block"; + if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size"); ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement)); $("#iceEditor").dialog({ title: "Edit " + type, resizable: false, - position: { my: "center top+60", at: "top", of: d3.event, collision: "fit" }, + position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"}, close: closeEditor }); if (modules.editIce) return; modules.editIce = true; + // add listeners document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice")); document.getElementById("iceRandomize").addEventListener("click", randomizeShape); @@ -34,18 +28,29 @@ function editIce(element) { document.getElementById("iceNew").addEventListener("click", toggleAdd); document.getElementById("iceRemove").addEventListener("click", removeIce); - function randomizeShape() { - const selectedId = +elSelected.attr("data-id"); - Ice.randomizeIcebergShape(selectedId); - redrawIceberg(selectedId); + const c = grid.points[+elSelected.attr("cell")]; + const s = +elSelected.attr("size"); + const i = ra(grid.cells.i), + cn = grid.points[i]; + const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); + const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]); + elSelected.attr("points", points); } function changeSize() { - const newSize = +this.value; - const selectedId = +elSelected.attr("data-id"); - Ice.changeIcebergSize(selectedId, newSize); - redrawIceberg(selectedId); + const c = grid.points[+elSelected.attr("cell")]; + const s = +elSelected.attr("size"); + const flat = elSelected + .attr("points") + .split(",") + .map(el => +el); + const pairs = []; + while (flat.length) pairs.push(flat.splice(0, 2)); + const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]); + const size = +this.value; + const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]); + elSelected.attr("points", points).attr("size", size); } function toggleAdd() { @@ -62,15 +67,17 @@ function editIce(element) { function addIcebergOnClick() { const [x, y] = d3.mouse(this); const i = findGridCell(x, y, grid); + const [cx, cy] = grid.points[i]; const size = +document.getElementById("iceSize")?.value || 1; - Ice.addIceberg(i, size); - + const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); + const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size); + iceberg.call(d3.drag().on("drag", dragElement)); if (d3.event.shiftKey === false) toggleAdd(); } function removeIce() { - const type = elSelected.attr("type") === "glacier" ? "Glacier" : "Iceberg"; + const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`; $("#alert").dialog({ resizable: false, @@ -78,7 +85,7 @@ function editIce(element) { buttons: { Remove: function () { $(this).dialog("close"); - Ice.removeIce(+elSelected.attr("data-id")); + elSelected.remove(); $("#iceEditor").dialog("close"); }, Cancel: function () { @@ -89,24 +96,14 @@ function editIce(element) { } function dragElement() { - const selectedId = +elSelected.attr("data-id"); - const initialTransform = parseTransform(this.getAttribute("transform")); - const dx = initialTransform[0] - d3.event.x; - const dy = initialTransform[1] - d3.event.y; + const tr = parseTransform(this.getAttribute("transform")); + const dx = +tr[0] - d3.event.x, + dy = +tr[1] - d3.event.y; d3.event.on("drag", function () { - const x = d3.event.x; - const y = d3.event.y; - const transform = `translate(${dx + x},${dy + y})`; - this.setAttribute("transform", transform); - - // Update data model with new position - const offset = [dx + x, dy + y]; - const iceData = pack.ice.find(element => element.i === selectedId); - if (iceData) { - // Store offset for visual positioning, actual geometry stays in points - iceData.offset = offset; - } + const x = d3.event.x, + y = d3.event.y; + this.setAttribute("transform", `translate(${dx + x},${dy + y})`); }); } @@ -117,4 +114,3 @@ function editIce(element) { unselect(); } } - diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index f2f04a4b..5037a5ee 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -417,6 +417,49 @@ function toggleIce(event) { } } +function drawIce() { + TIME && console.time("drawIce"); + + const {cells, features} = grid; + const {temp, h} = cells; + Math.random = aleaPRNG(seed); + + const ICEBERG_MAX_TEMP = 0; + const GLACIER_MAX_TEMP = -8; + const minMaxTemp = d3.min(temp); + + // cold land: draw glaciers + { + const type = "iceShield"; + const getType = cellId => (h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null); + const isolines = getIsolines(grid, getType, {polygons: true}); + isolines[type]?.polygons?.forEach(points => { + const clipped = clipPoly(points); + ice.append("polygon").attr("points", clipped).attr("type", type); + }); + } + + // cold water: draw icebergs + for (const cellId of grid.cells.i) { + const t = temp[cellId]; + if (h[cellId] >= 20) continue; // no icebergs on land + if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs + if (features[cells.f[cellId]].type === "lake") continue; // no icebers on lakes + if (P(0.8)) continue; // skip most of eligible cells + + const randomFactor = 0.8 + rand() * 0.4; // random size factor + let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero size, 1 = full size + if (cells.t[cellId] === -1) baseSize /= 1.3; // coasline: smaller icebergs + const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); + + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); + ice.append("polygon").attr("points", points).attr("cell", cellId).attr("size", size); + } + + TIME && console.timeEnd("drawIce"); +} + function toggleCultures(event) { const cultures = pack.cultures.filter(c => c.i && !c.removed); const empty = !cults.selectAll("path").size(); diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index eade993f..a3df5c00 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -555,7 +555,7 @@ function regenerateMilitary() { function regenerateIce() { if (!layerIsOn("toggleIce")) toggleIce(); - Ice.generate(); + ice.selectAll("*").remove(); drawIce(); } diff --git a/public/versioning.js b/public/versioning.js index 8069c818..11fcde66 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.111.0"; +const VERSION = "1.110.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index f75cbbfc..21d84187 100644 --- a/src/index.html +++ b/src/index.html @@ -8515,16 +8515,16 @@ - + - + - - + + @@ -8535,7 +8535,7 @@ - + @@ -8549,12 +8549,12 @@ - - - + + + - + @@ -8566,8 +8566,8 @@ - - + + @@ -8583,6 +8583,5 @@ - From 81c1ba2963bda4a030c62ba57d0c86a9a916511a Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Thu, 22 Jan 2026 18:28:09 +0100 Subject: [PATCH 4/8] fix: initialize heights array if not already set in HeightmapGenerator (#1277) --- src/modules/heightmap-generator.ts | 4 +--- src/utils/stringUtils.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index eb48f9f4..27fb063b 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -560,9 +560,7 @@ class HeightmapGenerator { if(!ctx) { throw new Error("Could not get canvas context"); } - if(!this.heights) { - throw new Error("Heights array is not initialized"); - } + this.heights = this.heights || new Uint8Array(cellsX * cellsY); ctx.drawImage(img, 0, 0, cellsX, cellsY); const imageData = ctx.getImageData(0, 0, cellsX, cellsY); this.setGraph(graph); diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts index 02c4d42a..dc00a23a 100644 --- a/src/utils/stringUtils.ts +++ b/src/utils/stringUtils.ts @@ -6,7 +6,7 @@ import { rn } from "./numberUtils"; * @param {number} decimals - Number of decimal places (default is 1) * @returns {string} - The string with rounded numbers */ -export const round = (inputString: string, decimals: number = 1) => { +export const round = (inputString: string = "", decimals: number = 1) => { return inputString.replace(/[\d\.-][\d\.e-]*/g, (n: string) => { return rn(parseFloat(n), decimals).toString(); }); From 4b341a65905773bfe2330a9985f42c7d793fcccc Mon Sep 17 00:00:00 2001 From: kruschen Date: Thu, 22 Jan 2026 22:24:34 +0100 Subject: [PATCH 5/8] Data model ice (#1279) * prototype for ice seperation * feat: migrate ice data to new data model and update version to 1.110.0 * refactor: update ice data handling and rendering for improved performance * feat: integrate ice generation and recalculation in heightmap editing * fix ice selection(hopefully) * fix ice selection better(pls) * refactor: remove redundant element selection in ice editing functions * fix: clear ice data before generating glaciers and icebergs * sparse array implementation with reduced updates * fix logic chech in modules/dynamic/auto-update.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: migrate ice data to new data model structure * refactor: streamline ice generation process and clean up rendering functions * refactor: simplify ice rendering logic by removing redundant clearing of old SVG * fix: update editIce function to accept element parameter and improve logic for glacier handling * ice drawing with only type on less occuring glaciers * feat: add compactPackData function to filter out undefined glaciers and icebergs * fix: clear existing ice elements before redrawing in editHeightmap function * fix compact problems on autosave * refactor: unify ice data structure and streamline ice element handling * refactor: improve getNextId function to fill gaps in ice element IDs(optional commit) * just to be sure * bump version in html * fix index.html script import * feat: add ice module script to index.html * fix migration check --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/main.js | 2 + public/modules/dynamic/auto-update.js | 102 +++++++++++++--- public/modules/ice.js | 170 ++++++++++++++++++++++++++ public/modules/io/load.js | 3 +- public/modules/io/save.js | 15 ++- public/modules/renderers/draw-ice.js | 70 +++++++++++ public/modules/ui/editors.js | 2 +- public/modules/ui/heightmap-editor.js | 6 + public/modules/ui/ice-editor.js | 80 ++++++------ public/modules/ui/layers.js | 43 ------- public/modules/ui/tools.js | 2 +- public/versioning.js | 2 +- src/index.html | 24 ++-- 13 files changed, 403 insertions(+), 118 deletions(-) create mode 100644 public/modules/ice.js create mode 100644 public/modules/renderers/draw-ice.js diff --git a/public/main.js b/public/main.js index 6da462d5..7dbb9585 100644 --- a/public/main.js +++ b/public/main.js @@ -632,6 +632,8 @@ async function generate(options) { Biomes.define(); Features.defineGroups(); + Ice.generate(); + rankCells(); Cultures.generate(); Cultures.expand(); diff --git a/public/modules/dynamic/auto-update.js b/public/modules/dynamic/auto-update.js index a3190e3b..0b1cd227 100644 --- a/public/modules/dynamic/auto-update.js +++ b/public/modules/dynamic/auto-update.js @@ -253,8 +253,8 @@ export function resolveVersionConflicts(mapVersion) { const source = findCell(s.x, s.y); const mouth = findCell(e.x, e.y); const name = Rivers.getName(mouth); - const type = length < 25 ? rw({Creek: 9, River: 3, Brook: 3, Stream: 1}) : "River"; - pack.rivers.push({i, parent: 0, length, source, mouth, basin: i, name, type}); + const type = length < 25 ? rw({ Creek: 9, River: 3, Brook: 3, Stream: 1 }) : "River"; + pack.rivers.push({ i, parent: 0, length, source, mouth, basin: i, name, type }); }); } @@ -270,7 +270,7 @@ export function resolveVersionConflicts(mapVersion) { const era = Names.getBaseShort(P(0.7) ? 1 : rand(nameBases.length)) + " Era"; const eraShort = era[0] + "E"; const military = Military.getDefaultOptions(); - options = {winds, year, era, eraShort, military}; + options = { winds, year, era, eraShort, military }; // v1.3 added campaings data for all states States.generateCampaigns(); @@ -481,7 +481,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.65.0")) { // v1.65 changed rivers data d3.select("#rivers").attr("style", null); // remove style to unhide layer - const {cells, rivers} = pack; + const { cells, rivers } = pack; const defaultWidthFactor = rn(1 / (pointsInput.dataset.cells / 10000) ** 0.25, 2); for (const river of rivers) { @@ -497,8 +497,8 @@ export function resolveVersionConflicts(mapVersion) { for (let i = 0; i <= segments; i++) { const shift = increment * i; - const {x: x1, y: y1} = node.getPointAtLength(length + shift); - const {x: x2, y: y2} = node.getPointAtLength(length - shift); + const { x: x1, y: y1 } = node.getPointAtLength(length + shift); + const { x: x2, y: y2 } = node.getPointAtLength(length - shift); const x = rn((x1 + x2) / 2, 1); const y = rn((y1 + y2) / 2, 1); @@ -565,7 +565,7 @@ export function resolveVersionConflicts(mapVersion) { const fill = circle && circle.getAttribute("fill"); const stroke = circle && circle.getAttribute("stroke"); - const marker = {i, icon, type, x, y, size, cell}; + const marker = { i, icon, type, x, y, size, cell }; if (size && size !== 30) marker.size = size; if (!isNaN(px) && px !== 12) marker.px = px; if (!isNaN(dx) && dx !== 50) marker.dx = dx; @@ -631,7 +631,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.88.0")) { // v1.87 may have incorrect shield for some reason - pack.states.forEach(({coa}) => { + pack.states.forEach(({ coa }) => { if (coa?.shield === "state") delete coa.shield; }); } @@ -639,13 +639,13 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.91.0")) { // from 1.91.00 custom coa is moved to coa object pack.states.forEach(state => { - if (state.coa === "custom") state.coa = {custom: true}; + if (state.coa === "custom") state.coa = { custom: true }; }); pack.provinces.forEach(province => { - if (province.coa === "custom") province.coa = {custom: true}; + if (province.coa === "custom") province.coa = { custom: true }; }); pack.burgs.forEach(burg => { - if (burg.coa === "custom") burg.coa = {custom: true}; + if (burg.coa === "custom") burg.coa = { custom: true }; }); // from 1.91.00 emblems don't have transform attribute @@ -747,7 +747,7 @@ export function resolveVersionConflicts(mapVersion) { const skip = terrs.attr("skip"); const relax = terrs.attr("relax"); - const curveTypes = {0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep"}; + const curveTypes = { 0: "curveBasisClosed", 1: "curveLinear", 2: "curveStep" }; const curve = curveTypes[terrs.attr("curve")] || "curveBasisClosed"; terrs @@ -882,7 +882,7 @@ export function resolveVersionConflicts(mapVersion) { const secondCellId = points[1][2]; const feature = pack.cells.f[secondCellId]; - pack.routes.push({i: pack.routes.length, group, feature, points}); + pack.routes.push({ i: pack.routes.length, group, feature, points }); } } routes.selectAll("path").remove(); @@ -914,7 +914,7 @@ export function resolveVersionConflicts(mapVersion) { const type = this.dataset.type; const color = this.getAttribute("fill"); const cells = this.dataset.cells.split(",").map(Number); - pack.zones.push({i, name, type, cells, color}); + pack.zones.push({ i, name, type, cells, color }); }); zones.style("display", null).selectAll("*").remove(); if (layerIsOn("toggleZones")) drawZones(); @@ -975,7 +975,7 @@ export function resolveVersionConflicts(mapVersion) { if (isOlderThan("1.109.0")) { // v1.109.0 added customizable burg groups and icons - options.burgs = {groups: []}; + options.burgs = { groups: [] }; burgIcons.selectAll("circle, use").each(function () { const group = this.parentNode.id; @@ -987,7 +987,7 @@ export function resolveVersionConflicts(mapVersion) { burgIcons.selectAll("g").each(function (_el, index) { const name = this.id; const isDefault = name === "towns"; - options.burgs.groups.push({name, active: true, order: index + 1, isDefault, preview: "watabou-city"}); + options.burgs.groups.push({ name, active: true, order: index + 1, isDefault, preview: "watabou-city" }); if (!this.dataset.icon) this.dataset.icon = "#icon-circle"; const size = Number(this.getAttribute("size") || 2) * 2; @@ -1036,4 +1036,74 @@ export function resolveVersionConflicts(mapVersion) { delete options.showMFCGMap; delete options.villageMaxPopulation; } + + if (isOlderThan("1.111.0")) { + // v1.111.0 moved ice data from SVG to data model + // Migrate old ice SVG elements to new pack.ice structure + if (!pack.ice.length) { + pack.ice = []; + let iceId = 0; + + const iceLayer = document.getElementById("ice"); + if (iceLayer) { + // Migrate glaciers (type="iceShield") + iceLayer.querySelectorAll("polygon[type='iceShield']").forEach(polygon => { + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "glacier" + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Migrate icebergs + iceLayer.querySelectorAll("polygon:not([type])").forEach(polygon => { + const cellId = +polygon.getAttribute("cell"); + const size = +polygon.getAttribute("size"); + + // points string must exist, cell attribute must be present, and size must be non-zero + if (polygon.getAttribute("cell") === null || !size) return; + + // Parse points string "x1,y1 x2,y2 x3,y3 ..." into array [[x1,y1], [x2,y2], ...] + const points = [...polygon.points].map(svgPoint => [svgPoint.x, svgPoint.y]); + + const transform = polygon.getAttribute("transform"); + const iceElement = { + i: iceId++, + points, + type: "iceberg", + cellId, + size + }; + if (transform) { + iceElement.offset = parseTransform(transform); + } + pack.ice.push(iceElement); + }); + + // Clear old SVG elements + iceLayer.querySelectorAll("*").forEach(el => el.remove()); + } else { + // If ice layer element doesn't exist, create it + ice = viewbox.insert("g", "#coastline").attr("id", "ice"); + ice + .attr("opacity", null) + .attr("fill", "#e8f0f6") + .attr("stroke", "#e8f0f6") + .attr("stroke-width", 1) + .attr("filter", "url(#dropShadow05)"); + } + + // Re-render ice from migrated data + if (layerIsOn("toggleIce")) drawIce(); + } + + } } diff --git a/public/modules/ice.js b/public/modules/ice.js new file mode 100644 index 00000000..90c7c3e6 --- /dev/null +++ b/public/modules/ice.js @@ -0,0 +1,170 @@ +"use strict"; + +// Ice layer data model - separates ice data from SVG rendering +window.Ice = (function () { + + // Find next available id for new ice element idealy filling gaps + function getNextId() { + if (pack.ice.length === 0) return 0; + // find gaps in existing ids + const existingIds = pack.ice.map(e => e.i).sort((a, b) => a - b); + for (let id = 0; id < existingIds[existingIds.length - 1]; id++) { + if (!existingIds.includes(id)) return id; + } + return existingIds[existingIds.length - 1] + 1; + } + + // Generate glaciers and icebergs based on temperature and height + function generate() { + clear(); + const { cells, features } = grid; + const { temp, h } = cells; + Math.random = aleaPRNG(seed); + + const ICEBERG_MAX_TEMP = 0; + const GLACIER_MAX_TEMP = -8; + const minMaxTemp = d3.min(temp); + + // Generate glaciers on cold land + { + const type = "iceShield"; + const getType = cellId => + h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null; + const isolines = getIsolines(grid, getType, { polygons: true }); + + if (isolines[type]?.polygons) { + isolines[type].polygons.forEach(points => { + const clipped = clipPoly(points); + pack.ice.push({ + i: getNextId(), + points: clipped, + type: "glacier" + }); + }); + } + } + + // Generate icebergs on cold water + for (const cellId of grid.cells.i) { + const t = temp[cellId]; + if (h[cellId] >= 20) continue; // no icebergs on land + if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs + if (features[cells.f[cellId]].type === "lake") continue; // no icebergs on lakes + if (P(0.8)) continue; // skip most of eligible cells + + const randomFactor = 0.8 + rand() * 0.4; // random size factor + let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero, 1 = full + if (cells.t[cellId] === -1) baseSize /= 1.3; // coastline: smaller icebergs + const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); + + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + + pack.ice.push({ + i: getNextId(), + points, + type: "iceberg", + cellId, + size + }); + } + } + + function addIceberg(cellId, size) { + const [cx, cy] = grid.points[cellId]; + const points = getGridPolygon(cellId).map(([x, y]) => [ + rn(lerp(cx, x, size), 2), + rn(lerp(cy, y, size), 2) + ]); + const id = getNextId(); + pack.ice.push({ + i: id, + points, + type: "iceberg", + cellId, + size + }); + redrawIceberg(id); + } + + function removeIce(id) { + const index = pack.ice.findIndex(element => element.i === id); + if (index !== -1) { + const type = pack.ice.find(element => element.i === id).type; + pack.ice.splice(index, 1); + if (type === "glacier") { + redrawGlacier(id); + } else { + redrawIceberg(id); + } + + } + } + + function updateIceberg(id, points, size) { + const iceberg = pack.ice.find(element => element.i === id); + if (iceberg) { + iceberg.points = points; + iceberg.size = size; + } + } + + function randomizeIcebergShape(id) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const size = iceberg.size; + const [cx, cy] = grid.points[cellId]; + + // Get a different random cell for the polygon template + const i = ra(grid.cells.i); + const cn = grid.points[i]; + const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); + const points = poly.map(p => [ + rn(cx + p[0] * size, 2), + rn(cy + p[1] * size, 2) + ]); + + iceberg.points = points; + } + + function changeIcebergSize(id, newSize) { + const iceberg = pack.ice.find(element => element.i === id); + if (!iceberg) return; + + const cellId = iceberg.cellId; + const [cx, cy] = grid.points[cellId]; + const oldSize = iceberg.size; + + const flat = iceberg.points.flat(); + const pairs = []; + while (flat.length) pairs.push(flat.splice(0, 2)); + const poly = pairs.map(p => [(p[0] - cx) / oldSize, (p[1] - cy) / oldSize]); + const points = poly.map(p => [ + rn(cx + p[0] * newSize, 2), + rn(cy + p[1] * newSize, 2) + ]); + + iceberg.points = points; + iceberg.size = newSize; + } + + // Clear all ice + function clear() { + pack.ice = []; + } + + return { + generate, + addIceberg, + removeIce, + updateIceberg, + randomizeIcebergShape, + changeIcebergSize, + clear + }; +})(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 689757b2..9b401733 100644 --- a/public/modules/io/load.js +++ b/public/modules/io/load.js @@ -406,6 +406,7 @@ async function parseLoadedData(data, mapVersion) { pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length); // data[28] had deprecated cells.crossroad pack.cells.routes = data[36] ? JSON.parse(data[36]) : {}; + pack.ice = data[39] ? JSON.parse(data[39]) : []; if (data[31]) { const namesDL = data[31].split("/"); @@ -449,7 +450,7 @@ async function parseLoadedData(data, mapVersion) { if (isVisible(routes) && hasChild(routes, "path")) turnOn("toggleRoutes"); if (hasChildren(temperature)) turnOn("toggleTemperature"); if (hasChild(population, "line")) turnOn("togglePopulation"); - if (hasChildren(ice)) turnOn("toggleIce"); + if (isVisible(ice)) turnOn("toggleIce"); if (hasChild(prec, "circle")) turnOn("togglePrecipitation"); if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems"); if (isVisible(labels)) turnOn("toggleLabels"); diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 304fef59..25cd7493 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -32,12 +32,13 @@ async function saveMap(method) { $(this).dialog("close"); } }, - position: {my: "center", at: "center", of: "svg"} + position: { my: "center", at: "center", of: "svg" } }); } } function prepareMapData() { + const date = new Date(); const dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); const license = "File can be loaded in azgaar.github.io/Fantasy-Map-Generator"; @@ -89,8 +90,8 @@ function prepareMapData() { const serializedSVG = new XMLSerializer().serializeToString(cloneEl); - const {spacing, cellsX, cellsY, boundary, points, features, cellsDesired} = grid; - const gridGeneral = JSON.stringify({spacing, cellsX, cellsY, boundary, points, features, cellsDesired}); + const { spacing, cellsX, cellsY, boundary, points, features, cellsDesired } = grid; + const gridGeneral = JSON.stringify({ spacing, cellsX, cellsY, boundary, points, features, cellsDesired }); const packFeatures = JSON.stringify(pack.features); const cultures = JSON.stringify(pack.cultures); const states = JSON.stringify(pack.states); @@ -102,6 +103,7 @@ function prepareMapData() { const cellRoutes = JSON.stringify(pack.cells.routes); const routes = JSON.stringify(pack.routes); const zones = JSON.stringify(pack.zones); + const ice = JSON.stringify(pack.ice); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -155,21 +157,22 @@ function prepareMapData() { markers, cellRoutes, routes, - zones + zones, + ice ].join("\r\n"); return mapData; } // save map file to indexedDB async function saveToStorage(mapData, showTip = false) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); await ldb.set("lastMap", blob); showTip && tip("Map is saved to the browser storage", false, "success"); } // download map file function saveToMachine(mapData, filename) { - const blob = new Blob([mapData], {type: "text/plain"}); + const blob = new Blob([mapData], { type: "text/plain" }); const URL = window.URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/public/modules/renderers/draw-ice.js b/public/modules/renderers/draw-ice.js new file mode 100644 index 00000000..4b35f75c --- /dev/null +++ b/public/modules/renderers/draw-ice.js @@ -0,0 +1,70 @@ +"use strict"; + +// Ice layer renderer - renders ice from data model to SVG +function drawIce() { + TIME && console.time("drawIce"); + + // Clear existing ice SVG + ice.selectAll("*").remove(); + + let html = ""; + + // Draw all ice elements + pack.ice.forEach(iceElement => { + if (iceElement.type === "glacier") { + html += getGlacierHtml(iceElement); + } else if (iceElement.type === "iceberg") { + html += getIcebergHtml(iceElement); + } + }); + + ice.html(html); + + TIME && console.timeEnd("drawIce"); +} + +function redrawIceberg(id) { + TIME && console.time("redrawIceberg"); + const iceberg = pack.ice.find(element => element.i === id); + let el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`); + if (!iceberg && !el.empty()) { + el.remove(); + } else { + if (el.empty()) { + // Create new element if it doesn't exist + const polygon = getIcebergHtml(iceberg); + ice.node().insertAdjacentHTML("beforeend", polygon); + el = ice.selectAll(`polygon[data-id="${id}"]:not([type="glacier"])`); + } + el.attr("points", iceberg.points); + el.attr("transform", iceberg.offset ? `translate(${iceberg.offset[0]},${iceberg.offset[1]})` : null); + } + TIME && console.timeEnd("redrawIceberg"); +} + +function redrawGlacier(id) { + TIME && console.time("redrawGlacier"); + const glacier = pack.ice.find(element => element.i === id); + let el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`); + if (!glacier && !el.empty()) { + el.remove(); + } else { + if (el.empty()) { + // Create new element if it doesn't exist + const polygon = getGlacierHtml(glacier); + ice.node().insertAdjacentHTML("beforeend", polygon); + el = ice.selectAll(`polygon[data-id="${id}"][type="glacier"]`); + } + el.attr("points", glacier.points); + el.attr("transform", glacier.offset ? `translate(${glacier.offset[0]},${glacier.offset[1]})` : null); + } + TIME && console.timeEnd("redrawGlacier"); +} + +function getGlacierHtml(glacier) { + return ``; +} + +function getIcebergHtml(iceberg) { + return ``; +} \ No newline at end of file diff --git a/public/modules/ui/editors.js b/public/modules/ui/editors.js index 77c391ee..50eaf1c7 100644 --- a/public/modules/ui/editors.js +++ b/public/modules/ui/editors.js @@ -26,7 +26,7 @@ function clicked() { else if (ancestor.id === "labels" && el.tagName === "tspan") editLabel(); else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); - else if (parent.id === "ice") editIce(); + else if (parent.id === "ice") editIce(el); else if (parent.id === "terrain") editReliefIcon(); else if (grand.id === "markers" || great.id === "markers") editMarker(); else if (grand.id === "coastline") editCoastline(); diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index d655e39d..5c3f1fc3 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -259,6 +259,8 @@ function editHeightmap(options) { Rivers.specify(); Lakes.defineNames(); + Ice.generate(); + Military.generate(); Markers.generate(); Zones.generate(); @@ -465,6 +467,10 @@ function editHeightmap(options) { .attr("id", d => base + d); }); + // recalculate ice + Ice.generate(); + ice.selectAll("*").remove(); + TIME && console.timeEnd("restoreRiskedData"); INFO && console.groupEnd("Edit Heightmap"); } diff --git a/public/modules/ui/ice-editor.js b/public/modules/ui/ice-editor.js index a9e6ff28..16818b4c 100644 --- a/public/modules/ui/ice-editor.js +++ b/public/modules/ui/ice-editor.js @@ -1,26 +1,32 @@ "use strict"; -function editIce() { +function editIce(element) { if (customization) return; + if (elSelected && element === elSelected.node()) return; + closeDialogs(".stable"); if (!layerIsOn("toggleIce")) toggleIce(); elSelected = d3.select(d3.event.target); - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; - document.getElementById("iceRandomize").style.display = type === "Glacier" ? "none" : "inline-block"; - document.getElementById("iceSize").style.display = type === "Glacier" ? "none" : "inline-block"; - if (type === "Iceberg") document.getElementById("iceSize").value = +elSelected.attr("size"); + const id = +elSelected.attr("data-id"); + const iceElement = pack.ice.find(el => el.i === id); + const isGlacier = elSelected.attr("type") === "glacier"; + const type = isGlacier ? "Glacier" : "Iceberg"; + + document.getElementById("iceRandomize").style.display = isGlacier ? "none" : "inline-block"; + document.getElementById("iceSize").style.display = isGlacier ? "none" : "inline-block"; + if (!isGlacier) document.getElementById("iceSize").value = iceElement?.size || ""; + ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement)); $("#iceEditor").dialog({ title: "Edit " + type, resizable: false, - position: {my: "center top+60", at: "top", of: d3.event, collision: "fit"}, + position: { my: "center top+60", at: "top", of: d3.event, collision: "fit" }, close: closeEditor }); if (modules.editIce) return; modules.editIce = true; - // add listeners document.getElementById("iceEditStyle").addEventListener("click", () => editStyle("ice")); document.getElementById("iceRandomize").addEventListener("click", randomizeShape); @@ -28,29 +34,18 @@ function editIce() { document.getElementById("iceNew").addEventListener("click", toggleAdd); document.getElementById("iceRemove").addEventListener("click", removeIce); + function randomizeShape() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const i = ra(grid.cells.i), - cn = grid.points[i]; - const poly = getGridPolygon(i).map(p => [p[0] - cn[0], p[1] - cn[1]]); - const points = poly.map(p => [rn(c[0] + p[0] * s, 2), rn(c[1] + p[1] * s, 2)]); - elSelected.attr("points", points); + const selectedId = +elSelected.attr("data-id"); + Ice.randomizeIcebergShape(selectedId); + redrawIceberg(selectedId); } function changeSize() { - const c = grid.points[+elSelected.attr("cell")]; - const s = +elSelected.attr("size"); - const flat = elSelected - .attr("points") - .split(",") - .map(el => +el); - const pairs = []; - while (flat.length) pairs.push(flat.splice(0, 2)); - const poly = pairs.map(p => [(p[0] - c[0]) / s, (p[1] - c[1]) / s]); - const size = +this.value; - const points = poly.map(p => [rn(c[0] + p[0] * size, 2), rn(c[1] + p[1] * size, 2)]); - elSelected.attr("points", points).attr("size", size); + const newSize = +this.value; + const selectedId = +elSelected.attr("data-id"); + Ice.changeIcebergSize(selectedId, newSize); + redrawIceberg(selectedId); } function toggleAdd() { @@ -67,17 +62,15 @@ function editIce() { function addIcebergOnClick() { const [x, y] = d3.mouse(this); const i = findGridCell(x, y, grid); - const [cx, cy] = grid.points[i]; const size = +document.getElementById("iceSize")?.value || 1; - const points = getGridPolygon(i).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - const iceberg = ice.append("polygon").attr("points", points).attr("cell", i).attr("size", size); - iceberg.call(d3.drag().on("drag", dragElement)); + Ice.addIceberg(i, size); + if (d3.event.shiftKey === false) toggleAdd(); } function removeIce() { - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; + const type = elSelected.attr("type") === "glacier" ? "Glacier" : "Iceberg"; alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`; $("#alert").dialog({ resizable: false, @@ -85,7 +78,7 @@ function editIce() { buttons: { Remove: function () { $(this).dialog("close"); - elSelected.remove(); + Ice.removeIce(+elSelected.attr("data-id")); $("#iceEditor").dialog("close"); }, Cancel: function () { @@ -96,14 +89,24 @@ function editIce() { } function dragElement() { - const tr = parseTransform(this.getAttribute("transform")); - const dx = +tr[0] - d3.event.x, - dy = +tr[1] - d3.event.y; + const selectedId = +elSelected.attr("data-id"); + const initialTransform = parseTransform(this.getAttribute("transform")); + const dx = initialTransform[0] - d3.event.x; + const dy = initialTransform[1] - d3.event.y; d3.event.on("drag", function () { - const x = d3.event.x, - y = d3.event.y; - this.setAttribute("transform", `translate(${dx + x},${dy + y})`); + const x = d3.event.x; + const y = d3.event.y; + const transform = `translate(${dx + x},${dy + y})`; + this.setAttribute("transform", transform); + + // Update data model with new position + const offset = [dx + x, dy + y]; + const iceData = pack.ice.find(element => element.i === selectedId); + if (iceData) { + // Store offset for visual positioning, actual geometry stays in points + iceData.offset = offset; + } }); } @@ -114,3 +117,4 @@ function editIce() { unselect(); } } + diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 5037a5ee..f2f04a4b 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -417,49 +417,6 @@ function toggleIce(event) { } } -function drawIce() { - TIME && console.time("drawIce"); - - const {cells, features} = grid; - const {temp, h} = cells; - Math.random = aleaPRNG(seed); - - const ICEBERG_MAX_TEMP = 0; - const GLACIER_MAX_TEMP = -8; - const minMaxTemp = d3.min(temp); - - // cold land: draw glaciers - { - const type = "iceShield"; - const getType = cellId => (h[cellId] >= 20 && temp[cellId] <= GLACIER_MAX_TEMP ? type : null); - const isolines = getIsolines(grid, getType, {polygons: true}); - isolines[type]?.polygons?.forEach(points => { - const clipped = clipPoly(points); - ice.append("polygon").attr("points", clipped).attr("type", type); - }); - } - - // cold water: draw icebergs - for (const cellId of grid.cells.i) { - const t = temp[cellId]; - if (h[cellId] >= 20) continue; // no icebergs on land - if (t > ICEBERG_MAX_TEMP) continue; // too warm: no icebergs - if (features[cells.f[cellId]].type === "lake") continue; // no icebers on lakes - if (P(0.8)) continue; // skip most of eligible cells - - const randomFactor = 0.8 + rand() * 0.4; // random size factor - let baseSize = (1 - normalize(t, minMaxTemp, 1)) * 0.8; // size: 0 = zero size, 1 = full size - if (cells.t[cellId] === -1) baseSize /= 1.3; // coasline: smaller icebergs - const size = minmax(rn(baseSize * randomFactor, 2), 0.1, 1); - - const [cx, cy] = grid.points[cellId]; - const points = getGridPolygon(cellId).map(([x, y]) => [rn(lerp(cx, x, size), 2), rn(lerp(cy, y, size), 2)]); - ice.append("polygon").attr("points", points).attr("cell", cellId).attr("size", size); - } - - TIME && console.timeEnd("drawIce"); -} - function toggleCultures(event) { const cultures = pack.cultures.filter(c => c.i && !c.removed); const empty = !cults.selectAll("path").size(); diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index a3df5c00..eade993f 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -555,7 +555,7 @@ function regenerateMilitary() { function regenerateIce() { if (!layerIsOn("toggleIce")) toggleIce(); - ice.selectAll("*").remove(); + Ice.generate(); drawIce(); } diff --git a/public/versioning.js b/public/versioning.js index 11fcde66..8069c818 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.110.0"; +const VERSION = "1.111.0"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { diff --git a/src/index.html b/src/index.html index 21d84187..3fd7ba17 100644 --- a/src/index.html +++ b/src/index.html @@ -8498,6 +8498,7 @@ + @@ -8515,16 +8516,16 @@ - + - + - - + + @@ -8535,7 +8536,7 @@ - + @@ -8549,12 +8550,12 @@ - - - + + + - + @@ -8566,8 +8567,8 @@ - - + + @@ -8583,5 +8584,6 @@ + From 70ed9aec56445a5bf7053bf40901871feab6302e Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 23 Jan 2026 13:47:13 +0100 Subject: [PATCH 6/8] fix: bind HeightmapGenerator methods for correct context in editHeightmap (#1281) --- public/modules/ui/heightmap-editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index 5c3f1fc3..6e76c4bb 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -675,7 +675,7 @@ function editHeightmap(options) { if (power === 0) return tip("Power should not be zero", false, "error"); const heights = grid.cells.h; - const operation = power > 0 ? HeightmapGenerator.addRange : HeightmapGenerator.addTrough; + const operation = power > 0 ? HeightmapGenerator.addRange.bind(HeightmapGenerator) : HeightmapGenerator.addTrough.bind(HeightmapGenerator); HeightmapGenerator.setGraph(grid); operation("1", String(Math.abs(power)), null, null, fromCell, toCell); const changedHeights = HeightmapGenerator.getHeights(); From c590c168f4b41d8e37e09d064288d5d0b77ac28f Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 23 Jan 2026 13:47:35 +0100 Subject: [PATCH 7/8] fix: update colorUtils and probabilityUtils to use seeded randomness (#1280) --- src/utils/colorUtils.ts | 5 ++++- src/utils/probabilityUtils.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 047d6eae..e64636fc 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -1,4 +1,4 @@ -import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffle } from "d3"; +import { color, interpolate, interpolateRainbow, range, RGBColor, scaleSequential, shuffler } from "d3"; /** * Convert RGB or RGBA color to HEX @@ -35,11 +35,14 @@ export const C_12 = [ /** * Get an array of distinct colors + * Uses shuffler with current Math.random to ensure seeded randomness works * @param {number} count - The count of colors to generate * @returns {string[]} - The array of HEX color strings */ export const getColors = (count: number): string[] => { const scaleRainbow = scaleSequential(interpolateRainbow); + // Use shuffler() to create a shuffle function that uses the current Math.random + const shuffle = shuffler(() => Math.random()); const colors = shuffle( range(count).map(i => (i < 12 ? C_12[i] : color(scaleRainbow((i - 12) / (count - 12)))?.formatHex())) ); diff --git a/src/utils/probabilityUtils.ts b/src/utils/probabilityUtils.ts index 4f6fd66a..a526c981 100644 --- a/src/utils/probabilityUtils.ts +++ b/src/utils/probabilityUtils.ts @@ -38,6 +38,7 @@ export const each = (n: number) => { /** * Random Gaussian number generator + * Uses randomNormal.source(Math.random) to ensure it uses the current PRNG * @param {number} expected - expected value * @param {number} deviation - standard deviation * @param {number} min - minimum value @@ -46,7 +47,8 @@ export const each = (n: number) => { * @return {number} random number */ export const gauss = (expected = 100, deviation = 30, min = 0, max = 300, round = 0) => { - return rn(minmax(randomNormal(expected, deviation)(), min, max), round); + // Use .source() to get a version that uses the current Math.random (which may be seeded) + return rn(minmax(randomNormal.source(() => Math.random())(expected, deviation)(), min, max), round); } /** From 9903f0b9aa50fdd2a3664ad99838b10a539d7843 Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 23 Jan 2026 16:50:21 +0100 Subject: [PATCH 8/8] Test/add e2e and unit testing (#1282) * feat: add string utility tests and vitest browser configuration * feat: add Playwright for end-to-end testing and update snapshots - Introduced Playwright for E2E testing with a new configuration file. - Added test scripts to package.json for running E2E tests. - Updated package-lock.json and package.json with new dependencies for Playwright and types. - Created new SVG snapshot files for various layers (ruler, scaleBar, temperature, terrain, vignette, zones) to support visual testing. - Excluded e2e directory from TypeScript compilation. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add SVG layer snapshots for various components - Added ruler layer snapshot with hidden display. - Added scale bar layer snapshot with detailed structure and styling. - Added temperature layer snapshot with opacity and stroke settings. - Added terrain layer snapshot with ocean and land heights groups. - Added vignette layer snapshot with mask and opacity settings. - Added zones layer snapshot with specified opacity and stroke settings. * fix: update Playwright browser installation command to use npx * Update snapshots * refactor: remove unused layer tests and their corresponding snapshots as fonts are unpredictable --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/playwright.yml | 25 + .gitignore | 3 + package-lock.json | 592 +++++++++++++++++- package.json | 13 +- playwright.config.ts | 34 + public/main.js | 4 + src/utils/stringUtils.test.ts | 8 + tests/e2e/layers.spec.ts | 241 +++++++ .../e2e/layers.spec.ts-snapshots/anchors.html | 1 + .../e2e/layers.spec.ts-snapshots/armies.html | 1 + .../e2e/layers.spec.ts-snapshots/biomes.html | 1 + .../e2e/layers.spec.ts-snapshots/borders.html | 1 + tests/e2e/layers.spec.ts-snapshots/cells.html | 1 + .../layers.spec.ts-snapshots/coastline.html | 1 + .../e2e/layers.spec.ts-snapshots/compass.html | 1 + .../layers.spec.ts-snapshots/coordinates.html | 1 + .../layers.spec.ts-snapshots/cultures.html | 1 + .../e2e/layers.spec.ts-snapshots/emblems.html | 1 + tests/e2e/layers.spec.ts-snapshots/ice.html | 1 + tests/e2e/layers.spec.ts-snapshots/icons.html | 1 + .../e2e/layers.spec.ts-snapshots/labels.html | 1 + tests/e2e/layers.spec.ts-snapshots/lakes.html | 1 + .../layers.spec.ts-snapshots/landmass.html | 1 + .../e2e/layers.spec.ts-snapshots/markers.html | 1 + tests/e2e/layers.spec.ts-snapshots/ocean.html | 1 + .../layers.spec.ts-snapshots/population.html | 1 + .../precipitation.html | 1 + .../layers.spec.ts-snapshots/provinces.html | 1 + .../e2e/layers.spec.ts-snapshots/regions.html | 1 + .../e2e/layers.spec.ts-snapshots/relief.html | 1 + .../layers.spec.ts-snapshots/religions.html | 1 + .../e2e/layers.spec.ts-snapshots/rivers.html | 1 + .../e2e/layers.spec.ts-snapshots/routes.html | 1 + .../layers.spec.ts-snapshots/temperature.html | 1 + .../e2e/layers.spec.ts-snapshots/terrain.html | 1 + tests/e2e/layers.spec.ts-snapshots/zones.html | 1 + tsconfig.json | 3 +- vitest.browser.config.ts | 18 + 38 files changed, 963 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.ts create mode 100644 src/utils/stringUtils.test.ts create mode 100644 tests/e2e/layers.spec.ts create mode 100644 tests/e2e/layers.spec.ts-snapshots/anchors.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/armies.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/biomes.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/borders.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/cells.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/coastline.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/compass.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/coordinates.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/cultures.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/emblems.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/ice.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/icons.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/labels.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/lakes.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/landmass.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/markers.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/ocean.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/population.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/precipitation.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/provinces.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/regions.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/relief.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/religions.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/rivers.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/routes.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/temperature.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/terrain.html create mode 100644 tests/e2e/layers.spec.ts-snapshots/zones.html create mode 100644 vitest.browser.config.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..b41b4ac2 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,25 @@ +name: Playwright Tests +on: + pull_request: + branches: [ master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 \ No newline at end of file diff --git a/.gitignore b/.gitignore index b0a273f0..c730ec13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .vscode .idea /node_modules +*/node_modules /dist /coverage +/playwright-report +/test-results \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cafbec00..67512031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fantasy-map-generator", - "version": "1.109.5", + "version": "1.110.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fantasy-map-generator", - "version": "1.109.5", + "version": "1.110.0", "license": "MIT", "dependencies": { "alea": "^1.0.1", @@ -15,11 +15,17 @@ "polylabel": "^2.0.1" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", + "@types/node": "^25.0.10", "@types/polylabel": "^1.1.3", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "playwright": "^1.57.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "engines": { "node": ">=24.0.0" @@ -467,6 +473,36 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -817,6 +853,24 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -1101,6 +1155,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/delaunator": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/delaunator/-/delaunator-5.0.3.tgz", @@ -1122,6 +1183,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/polylabel": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz", @@ -1129,12 +1201,191 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/browser": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", + "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.18", + "@vitest/utils": "4.0.18", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz", + "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/browser": "4.0.18", + "@vitest/mocker": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/alea": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/alea/-/alea-1.0.1.tgz", "integrity": "sha512-QU+wv+ziDXaMxRdsQg/aH7sVfWdhKps5YP97IIwFkHCsbDZA3k87JXoZ5/iuemf4ntytzIWeScrRpae8+lDrXA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -1555,6 +1806,13 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1597,6 +1855,26 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1651,6 +1929,26 @@ "node": ">=12" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1670,6 +1968,24 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1691,6 +2007,77 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/polylabel": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz", @@ -1792,6 +2179,28 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1802,6 +2211,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1825,6 +2265,26 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1839,12 +2299,20 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -1913,6 +2381,124 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 7a17e01b..9d3fbe11 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,23 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:browser": "vitest --config=vitest.browser.config.ts", + "test:e2e": "playwright test" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/d3": "^7.4.3", "@types/delaunator": "^5.0.3", + "@types/node": "^25.0.10", "@types/polylabel": "^1.1.3", + "@vitest/browser": "^4.0.18", + "@vitest/browser-playwright": "^4.0.18", + "playwright": "^1.57.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "dependencies": { "alea": "^1.0.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..86348c64 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test' + +const isCI = !!process.env.CI + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + reporter: 'html', + // Use OS-independent snapshot names (HTML content is the same across platforms) + snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', + use: { + baseURL: isCI ? 'http://localhost:4173' : 'http://localhost:5173', + trace: 'on-first-retry', + // Fixed viewport to ensure consistent map rendering + viewport: { width: 1280, height: 720 }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + // In CI: build and preview for production-like testing + // In dev: use vite dev server (faster, no rebuild needed) + command: isCI ? 'npm run build && npm run preview' : 'npm run dev', + url: isCI ? 'http://localhost:4173' : 'http://localhost:5173', + reuseExistingServer: !isCI, + timeout: 120000, + }, +}) diff --git a/public/main.js b/public/main.js index 7dbb9585..e922c44e 100644 --- a/public/main.js +++ b/public/main.js @@ -1229,8 +1229,12 @@ function showStatistics() { Cultures: ${pack.cultures.length - 1}`; mapId = Date.now(); // unique map id is it's creation date number + window.mapId = mapId; // expose for test automation mapHistory.push({seed, width: graphWidth, height: graphHeight, template: heightmap, created: mapId}); INFO && console.info(stats); + + // Dispatch event for test automation and external integrations + window.dispatchEvent(new CustomEvent('map:generated', { detail: { seed, mapId } })); } const regenerateMap = debounce(async function (options) { diff --git a/src/utils/stringUtils.test.ts b/src/utils/stringUtils.test.ts new file mode 100644 index 00000000..10da484f --- /dev/null +++ b/src/utils/stringUtils.test.ts @@ -0,0 +1,8 @@ +import { expect, describe, it } from 'vitest' +import { round } from './stringUtils' + +describe('round', () => { + it('should be able to handle undefined input', () => { + expect(round(undefined)).toBe(""); + }); +}) \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts b/tests/e2e/layers.spec.ts new file mode 100644 index 00000000..458ded73 --- /dev/null +++ b/tests/e2e/layers.spec.ts @@ -0,0 +1,241 @@ +import { test, expect } from '@playwright/test' + +test.describe('map layers', () => { + test.beforeEach(async ({ context, page }) => { + // Clear all storage to ensure clean state + await context.clearCookies() + + await page.goto('/') + await page.evaluate(() => { + localStorage.clear() + sessionStorage.clear() + }) + + // Navigate with seed parameter and wait for full load + // NOTE: + // - We use a fixed seed ("test-seed") to make map generation deterministic for snapshot tests. + // - Snapshots are OS-independent (configured in playwright.config.ts). + await page.goto('/?seed=test-seed&&width=1280&height=720') + + // Wait for map generation to complete by checking window.mapId + // mapId is exposed on window at the very end of showStatistics() + await page.waitForFunction(() => (window as any).mapId !== undefined, { timeout: 60000 }) + + // Additional wait for any rendering/animations to settle + await page.waitForTimeout(500) + }) + + // Ocean and water layers + test('ocean layer', async ({ page }) => { + const ocean = page.locator('#ocean') + await expect(ocean).toBeAttached() + const html = await ocean.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('ocean.html') + }) + + test('lakes layer', async ({ page }) => { + const lakes = page.locator('#lakes') + await expect(lakes).toBeAttached() + const html = await lakes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('lakes.html') + }) + + test('coastline layer', async ({ page }) => { + const coastline = page.locator('#coastline') + await expect(coastline).toBeAttached() + const html = await coastline.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('coastline.html') + }) + + // Terrain and heightmap layers + test('terrain layer', async ({ page }) => { + const terrs = page.locator('#terrs') + await expect(terrs).toBeAttached() + const html = await terrs.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('terrain.html') + }) + + test('landmass layer', async ({ page }) => { + const landmass = page.locator('#landmass') + await expect(landmass).toBeAttached() + const html = await landmass.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('landmass.html') + }) + + // Climate and environment layers + test('biomes layer', async ({ page }) => { + const biomes = page.locator('#biomes') + await expect(biomes).toBeAttached() + const html = await biomes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('biomes.html') + }) + + test('ice layer', async ({ page }) => { + const ice = page.locator('#ice') + await expect(ice).toBeAttached() + const html = await ice.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('ice.html') + }) + + test('temperature layer', async ({ page }) => { + const temperature = page.locator('#temperature') + await expect(temperature).toBeAttached() + const html = await temperature.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('temperature.html') + }) + + test('precipitation layer', async ({ page }) => { + const prec = page.locator('#prec') + await expect(prec).toBeAttached() + const html = await prec.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('precipitation.html') + }) + + // Geographic features + test('rivers layer', async ({ page }) => { + const rivers = page.locator('#rivers') + await expect(rivers).toBeAttached() + const html = await rivers.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('rivers.html') + }) + + test('relief layer', async ({ page }) => { + const terrain = page.locator('#terrain') + await expect(terrain).toBeAttached() + const html = await terrain.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('relief.html') + }) + + // Political layers + test('states/regions layer', async ({ page }) => { + const regions = page.locator('#regions') + await expect(regions).toBeAttached() + const html = await regions.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('regions.html') + }) + + test('provinces layer', async ({ page }) => { + const provs = page.locator('#provs') + await expect(provs).toBeAttached() + const html = await provs.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('provinces.html') + }) + + test('borders layer', async ({ page }) => { + const borders = page.locator('#borders') + await expect(borders).toBeAttached() + const html = await borders.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('borders.html') + }) + + // Cultural layers + test('cultures layer', async ({ page }) => { + const cults = page.locator('#cults') + await expect(cults).toBeAttached() + const html = await cults.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('cultures.html') + }) + + test('religions layer', async ({ page }) => { + const relig = page.locator('#relig') + await expect(relig).toBeAttached() + const html = await relig.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('religions.html') + }) + + // Infrastructure layers + test('routes layer', async ({ page }) => { + const routes = page.locator('#routes') + await expect(routes).toBeAttached() + const html = await routes.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('routes.html') + }) + + // Settlement layers + test('burgs/icons layer', async ({ page }) => { + const icons = page.locator('#icons') + await expect(icons).toBeAttached() + const html = await icons.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('icons.html') + }) + + test('anchors layer', async ({ page }) => { + const anchors = page.locator('#anchors') + await expect(anchors).toBeAttached() + const html = await anchors.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('anchors.html') + }) + + // Labels layer (without text content due to font rendering) + test('labels layer', async ({ page }) => { + const labels = page.locator('#labels') + await expect(labels).toBeAttached() + // Remove text content but keep structure (text rendering varies) + const html = await labels.evaluate((el) => { + const clone = el.cloneNode(true) as Element + clone.querySelectorAll('text, tspan').forEach((t) => t.remove()) + return clone.outerHTML + }) + expect(html).toMatchSnapshot('labels.html') + }) + + // Military and markers + test('markers layer', async ({ page }) => { + const markers = page.locator('#markers') + await expect(markers).toBeAttached() + const html = await markers.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('markers.html') + }) + + test('armies layer', async ({ page }) => { + const armies = page.locator('#armies') + await expect(armies).toBeAttached() + const html = await armies.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('armies.html') + }) + + // Special features + test('zones layer', async ({ page }) => { + const zones = page.locator('#zones') + await expect(zones).toBeAttached() + const html = await zones.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('zones.html') + }) + + test('emblems layer', async ({ page }) => { + const emblems = page.locator('#emblems') + await expect(emblems).toBeAttached() + const html = await emblems.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('emblems.html') + }) + + // Grid and coordinates + test('cells layer', async ({ page }) => { + const cells = page.locator('g#cells') + await expect(cells).toBeAttached() + const html = await cells.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('cells.html') + }) + + test('coordinates layer', async ({ page }) => { + const coordinates = page.locator('#coordinates') + await expect(coordinates).toBeAttached() + const html = await coordinates.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('coordinates.html') + }) + + test('compass layer', async ({ page }) => { + const compass = page.locator('#compass') + await expect(compass).toBeAttached() + const html = await compass.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('compass.html') + }) + + // Population layer + test('population layer', async ({ page }) => { + const population = page.locator('#population') + await expect(population).toBeAttached() + const html = await population.evaluate((el) => el.outerHTML) + expect(html).toMatchSnapshot('population.html') + }) +}) diff --git a/tests/e2e/layers.spec.ts-snapshots/anchors.html b/tests/e2e/layers.spec.ts-snapshots/anchors.html new file mode 100644 index 00000000..3037abb5 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/anchors.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/armies.html b/tests/e2e/layers.spec.ts-snapshots/armies.html new file mode 100644 index 00000000..face6396 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/armies.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/biomes.html b/tests/e2e/layers.spec.ts-snapshots/biomes.html new file mode 100644 index 00000000..582a9c1d --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/biomes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/borders.html b/tests/e2e/layers.spec.ts-snapshots/borders.html new file mode 100644 index 00000000..6e5c5003 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/borders.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/cells.html b/tests/e2e/layers.spec.ts-snapshots/cells.html new file mode 100644 index 00000000..d73d9b2f --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/cells.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/coastline.html b/tests/e2e/layers.spec.ts-snapshots/coastline.html new file mode 100644 index 00000000..7a2c4c51 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/coastline.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/compass.html b/tests/e2e/layers.spec.ts-snapshots/compass.html new file mode 100644 index 00000000..3c0892a6 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/compass.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/coordinates.html b/tests/e2e/layers.spec.ts-snapshots/coordinates.html new file mode 100644 index 00000000..48e6c40b --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/coordinates.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/cultures.html b/tests/e2e/layers.spec.ts-snapshots/cultures.html new file mode 100644 index 00000000..193726a3 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/cultures.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/emblems.html b/tests/e2e/layers.spec.ts-snapshots/emblems.html new file mode 100644 index 00000000..1de7ef9d --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/emblems.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/ice.html b/tests/e2e/layers.spec.ts-snapshots/ice.html new file mode 100644 index 00000000..1729b6ff --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/ice.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/icons.html b/tests/e2e/layers.spec.ts-snapshots/icons.html new file mode 100644 index 00000000..c759dc38 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/icons.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/labels.html b/tests/e2e/layers.spec.ts-snapshots/labels.html new file mode 100644 index 00000000..6ffcf3b9 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/labels.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/lakes.html b/tests/e2e/layers.spec.ts-snapshots/lakes.html new file mode 100644 index 00000000..cce3f70e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/lakes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/landmass.html b/tests/e2e/layers.spec.ts-snapshots/landmass.html new file mode 100644 index 00000000..ec70a34e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/landmass.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/markers.html b/tests/e2e/layers.spec.ts-snapshots/markers.html new file mode 100644 index 00000000..100a1e3f --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/markers.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/ocean.html b/tests/e2e/layers.spec.ts-snapshots/ocean.html new file mode 100644 index 00000000..b950e1a7 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/ocean.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/population.html b/tests/e2e/layers.spec.ts-snapshots/population.html new file mode 100644 index 00000000..10175492 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/population.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/precipitation.html b/tests/e2e/layers.spec.ts-snapshots/precipitation.html new file mode 100644 index 00000000..8ab517cb --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/precipitation.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/provinces.html b/tests/e2e/layers.spec.ts-snapshots/provinces.html new file mode 100644 index 00000000..3fe87d6e --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/provinces.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/regions.html b/tests/e2e/layers.spec.ts-snapshots/regions.html new file mode 100644 index 00000000..86187361 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/regions.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/relief.html b/tests/e2e/layers.spec.ts-snapshots/relief.html new file mode 100644 index 00000000..6883fe5b --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/relief.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/religions.html b/tests/e2e/layers.spec.ts-snapshots/religions.html new file mode 100644 index 00000000..85c96e30 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/religions.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/rivers.html b/tests/e2e/layers.spec.ts-snapshots/rivers.html new file mode 100644 index 00000000..087b4d8d --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/rivers.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/routes.html b/tests/e2e/layers.spec.ts-snapshots/routes.html new file mode 100644 index 00000000..16e6f5ec --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/routes.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/temperature.html b/tests/e2e/layers.spec.ts-snapshots/temperature.html new file mode 100644 index 00000000..36464dbd --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/temperature.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/terrain.html b/tests/e2e/layers.spec.ts-snapshots/terrain.html new file mode 100644 index 00000000..bc13f8be --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/terrain.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/e2e/layers.spec.ts-snapshots/zones.html b/tests/e2e/layers.spec.ts-snapshots/zones.html new file mode 100644 index 00000000..14cd5141 --- /dev/null +++ b/tests/e2e/layers.spec.ts-snapshots/zones.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8b583a9d..01672af5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/e2e"] } \ No newline at end of file diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts new file mode 100644 index 00000000..cf8528b8 --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' +import { playwright } from '@vitest/browser-playwright' + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright(), + // https://vitest.dev/config/browser/playwright + instances: [ + { name: 'chromium', browser: 'chromium' }, + ], + locators: { + testIdAttribute: 'id', + }, + }, + }, +})