diff --git a/public/main.js b/public/main.js index 6da462d5..16f0b302 100644 --- a/public/main.js +++ b/public/main.js @@ -632,6 +632,9 @@ async function generate(options) { Biomes.define(); Features.defineGroups(); + Ice.initialize(); + Ice.generate(); + rankCells(); Cultures.generate(); Cultures.expand(); diff --git a/public/modules/ice.js b/public/modules/ice.js new file mode 100644 index 00000000..18b535b1 --- /dev/null +++ b/public/modules/ice.js @@ -0,0 +1,170 @@ +"use strict"; + +// Ice layer data model - separates ice data from SVG rendering +const Ice = (() => { + // Initialize ice data structure + function initialize() { + pack.ice = { + glaciers: [], // auto-generated glaciers on cold land + icebergs: [] // manually edited and auto-generated icebergs on cold water + }; + } + + // Generate glaciers and icebergs based on temperature and height + function generate() { + 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.glaciers.push({ + points: clipped, + offset: null + }); + }); + } + } + + // 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.icebergs.push({ + cellId, + size, + points, + offset: null + }); + } + } + + // Add a new iceberg (manual editing) + 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) + ]); + + pack.ice.icebergs.push({ + cellId, + size, + points, + offset: null + }); + + return pack.ice.icebergs.length - 1; // return index + } + + // Remove ice element by index + function removeIce(type, index) { + if (type === "glacier" && pack.ice.glaciers[index]) { + pack.ice.glaciers.splice(index, 1); + } else if (type === "iceberg" && pack.ice.icebergs[index]) { + pack.ice.icebergs.splice(index, 1); + } + } + + // Update iceberg points and size + function updateIceberg(index, points, size) { + if (pack.ice.icebergs[index]) { + pack.ice.icebergs[index].points = points; + pack.ice.icebergs[index].size = size; + } + } + + // Randomize iceberg shape + function randomizeIcebergShape(index) { + const iceberg = pack.ice.icebergs[index]; + 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; + } + + // Change iceberg size and recalculate points + function changeIcebergSize(index, newSize) { + const iceberg = pack.ice.icebergs[index]; + if (!iceberg) return; + + const cellId = iceberg.cellId; + const [cx, cy] = grid.points[cellId]; + const oldSize = iceberg.size; + + // Recalculate points based on new 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; + } + + // Get all ice data + function getData() { + return pack.ice; + } + + // Clear all ice + function clear() { + pack.ice.glaciers = []; + pack.ice.icebergs = []; + } + + return { + initialize, + generate, + addIceberg, + removeIce, + updateIceberg, + randomizeIcebergShape, + changeIcebergSize, + getData, + clear + }; +})(); diff --git a/public/modules/io/load.js b/public/modules/io/load.js index 689757b2..5414f8dd 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]) : {glaciers: [], icebergs: []}; if (data[31]) { const namesDL = data[31].split("/"); @@ -449,7 +450,11 @@ 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 (pack.ice?.glaciers?.length || pack.ice?.icebergs?.length) { + ice.selectAll("*").remove(); // clear old SVG + drawIce(); // re-render ice from data + 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..4b60fa71 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -102,6 +102,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,7 +156,8 @@ function prepareMapData() { markers, cellRoutes, routes, - zones + zones, + ice ].join("\r\n"); return mapData; } diff --git a/public/modules/renderers/draw-ice.js b/public/modules/renderers/draw-ice.js new file mode 100644 index 00000000..37075275 --- /dev/null +++ b/public/modules/renderers/draw-ice.js @@ -0,0 +1,37 @@ +"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(); + + // Draw glaciers + pack.ice.glaciers.forEach((glacier, index) => { + ice + .append("polygon") + .attr("points", glacier.points) + .attr("type", "iceShield") + .attr("data-index", index) + .attr("class", "glacier"); + }); + + // Draw icebergs + pack.ice.icebergs.forEach((iceberg, index) => { + ice + .append("polygon") + .attr("points", iceberg.points) + .attr("cell", iceberg.cellId) + .attr("size", iceberg.size) + .attr("data-index", index) + .attr("class", "iceberg"); + }); + + TIME && console.timeEnd("drawIce"); +} + +// Re-render ice layer from data model +function redrawIce() { + drawIce(); +} diff --git a/public/modules/ui/ice-editor.js b/public/modules/ui/ice-editor.js index a9e6ff28..0919fcf6 100644 --- a/public/modules/ui/ice-editor.js +++ b/public/modules/ui/ice-editor.js @@ -5,10 +5,14 @@ function editIce() { 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 index = +elSelected.attr("data-index"); + const isGlacier = elSelected.attr("type") === "iceShield"; + 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 = +elSelected.attr("size"); + ice.selectAll("*").classed("draggable", true).call(d3.drag().on("drag", dragElement)); $("#iceEditor").dialog({ @@ -29,28 +33,18 @@ function editIce() { 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); + Ice.randomizeIcebergShape(index); + redrawIce(); + elSelected = ice.selectAll(`[data-index="${index}"]`).node(); + elSelected = d3.select(elSelected); } 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; + Ice.changeIcebergSize(index, newSize); + redrawIce(); + elSelected = ice.selectAll(`[data-index="${index}"]`).node(); + elSelected = d3.select(elSelected); } function toggleAdd() { @@ -67,17 +61,16 @@ 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); + redrawIce(); + if (d3.event.shiftKey === false) toggleAdd(); } function removeIce() { - const type = elSelected.attr("type") ? "Glacier" : "Iceberg"; + const type = isGlacier ? "Glacier" : "Iceberg"; alertMessage.innerHTML = /* html */ `Are you sure you want to remove the ${type}?`; $("#alert").dialog({ resizable: false, @@ -85,7 +78,8 @@ function editIce() { buttons: { Remove: function () { $(this).dialog("close"); - elSelected.remove(); + Ice.removeIce(isGlacier ? "glacier" : "iceberg", index); + redrawIce(); $("#iceEditor").dialog("close"); }, Cancel: function () { @@ -96,14 +90,25 @@ function editIce() { } function dragElement() { - const tr = parseTransform(this.getAttribute("transform")); - const dx = +tr[0] - d3.event.x, - dy = +tr[1] - d3.event.y; + const isGlacier = elSelected.attr("type") === "iceShield"; + const idx = +elSelected.attr("data-index"); + 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 = isGlacier ? pack.ice.glaciers[idx] : pack.ice.icebergs[idx]; + if (iceData) { + // Store offset for visual positioning, actual geometry stays in points + iceData.offset = offset; + } }); } @@ -114,3 +119,4 @@ function editIce() { unselect(); } } + diff --git a/public/modules/ui/layers.js b/public/modules/ui/layers.js index 5037a5ee..ce619937 100644 --- a/public/modules/ui/layers.js +++ b/public/modules/ui/layers.js @@ -420,42 +420,31 @@ function toggleIce(event) { function drawIce() { TIME && console.time("drawIce"); - const {cells, features} = grid; - const {temp, h} = cells; - Math.random = aleaPRNG(seed); + // Clear existing ice SVG + ice.selectAll("*").remove(); - const ICEBERG_MAX_TEMP = 0; - const GLACIER_MAX_TEMP = -8; - const minMaxTemp = d3.min(temp); + // Draw glaciers + pack.ice.glaciers.forEach((glacier, index) => { + ice + .append("polygon") + .attr("points", glacier.points) + .attr("type", "iceShield") + .attr("data-index", index) + .attr("class", "glacier") + .attr("transform", glacier.offset ? `translate(${glacier.offset[0]},${glacier.offset[1]})` : null); + }); - // 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); - } + // Draw icebergs + pack.ice.icebergs.forEach((iceberg, index) => { + ice + .append("polygon") + .attr("points", iceberg.points) + .attr("cell", iceberg.cellId) + .attr("size", iceberg.size) + .attr("data-index", index) + .attr("class", "iceberg") + .attr("transform", iceberg.offset ? `translate(${iceberg.offset[0]},${iceberg.offset[1]})` : null); + }); TIME && console.timeEnd("drawIce"); } 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/src/index.html b/src/index.html index 21d84187..8a5c4e6b 100644 --- a/src/index.html +++ b/src/index.html @@ -8491,40 +8491,43 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -8535,7 +8538,7 @@ - + @@ -8566,8 +8569,8 @@ - - + + @@ -8583,5 +8586,6 @@ +