mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-23 15:47:24 +01:00
698 lines
23 KiB
JavaScript
698 lines
23 KiB
JavaScript
"use strict";
|
||
// Functions to export map to image or data files
|
||
|
||
async function exportToSvg() {
|
||
TIME && console.time("exportToSvg");
|
||
const url = await getMapURL("svg", {fullMap: true});
|
||
const link = document.createElement("a");
|
||
link.download = getFileName() + ".svg";
|
||
link.href = url;
|
||
link.click();
|
||
|
||
const message = `${link.download} is saved. Open 'Downloads' screen (ctrl + J) to check`;
|
||
tip(message, true, "success", 5000);
|
||
TIME && console.timeEnd("exportToSvg");
|
||
}
|
||
|
||
async function exportToPng() {
|
||
TIME && console.time("exportToPng");
|
||
const url = await getMapURL("png");
|
||
|
||
const link = document.createElement("a");
|
||
const canvas = document.createElement("canvas");
|
||
const ctx = canvas.getContext("2d");
|
||
canvas.width = svgWidth * pngResolutionInput.value;
|
||
canvas.height = svgHeight * pngResolutionInput.value;
|
||
const img = new Image();
|
||
img.src = url;
|
||
|
||
img.onload = function () {
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
link.download = getFileName() + ".png";
|
||
canvas.toBlob(function (blob) {
|
||
link.href = window.URL.createObjectURL(blob);
|
||
link.click();
|
||
window.setTimeout(function () {
|
||
canvas.remove();
|
||
window.URL.revokeObjectURL(link.href);
|
||
|
||
const message = `${link.download} is saved. Open 'Downloads' screen (ctrl + J) to check. You can set image scale in options`;
|
||
tip(message, true, "success", 5000);
|
||
}, 1000);
|
||
});
|
||
};
|
||
|
||
TIME && console.timeEnd("exportToPng");
|
||
}
|
||
|
||
async function exportToJpeg() {
|
||
TIME && console.time("exportToJpeg");
|
||
const url = await getMapURL("png");
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = svgWidth * pngResolutionInput.value;
|
||
canvas.height = svgHeight * pngResolutionInput.value;
|
||
const img = new Image();
|
||
img.src = url;
|
||
|
||
img.onload = async function () {
|
||
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
const quality = Math.min(rn(1 - pngResolutionInput.value / 20, 2), 0.92);
|
||
const URL = await canvas.toDataURL("image/jpeg", quality);
|
||
const link = document.createElement("a");
|
||
link.download = getFileName() + ".jpeg";
|
||
link.href = URL;
|
||
link.click();
|
||
tip(`${link.download} is saved. Open "Downloads" screen (CTRL + J) to check`, true, "success", 7000);
|
||
window.setTimeout(() => window.URL.revokeObjectURL(URL), 5000);
|
||
};
|
||
|
||
TIME && console.timeEnd("exportToJpeg");
|
||
}
|
||
|
||
async function exportToPngTiles() {
|
||
const status = byId("tileStatus");
|
||
status.innerHTML = "Preparing files...";
|
||
|
||
const urlSchema = await getMapURL("tiles", {debug: true, fullMap: true});
|
||
await import("../../libs/jszip.min.js");
|
||
const zip = new window.JSZip();
|
||
|
||
const canvas = document.createElement("canvas");
|
||
const ctx = canvas.getContext("2d");
|
||
canvas.width = graphWidth;
|
||
canvas.height = graphHeight;
|
||
|
||
const imgSchema = new Image();
|
||
imgSchema.src = urlSchema;
|
||
await loadImage(imgSchema);
|
||
|
||
status.innerHTML = "Rendering schema...";
|
||
ctx.drawImage(imgSchema, 0, 0, canvas.width, canvas.height);
|
||
const blob = await canvasToBlob(canvas, "image/png");
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
zip.file("schema.png", blob);
|
||
|
||
// download tiles
|
||
const url = await getMapURL("tiles", {fullMap: true});
|
||
const tilesX = +byId("tileColsOutput").value || 2;
|
||
const tilesY = +byId("tileRowsOutput").value || 2;
|
||
const scale = +byId("tileScaleOutput").value || 1;
|
||
const tolesTotal = tilesX * tilesY;
|
||
|
||
const tileW = (graphWidth / tilesX) | 0;
|
||
const tileH = (graphHeight / tilesY) | 0;
|
||
|
||
const width = graphWidth * scale;
|
||
const height = width * (tileH / tileW);
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
const img = new Image();
|
||
img.src = url;
|
||
await loadImage(img);
|
||
|
||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||
function getRowLabel(row) {
|
||
const first = row >= alphabet.length ? alphabet[Math.floor(row / alphabet.length) - 1] : "";
|
||
const last = alphabet[row % alphabet.length];
|
||
return first + last;
|
||
}
|
||
|
||
for (let y = 0, row = 0, id = 1; y + tileH <= graphHeight; y += tileH, row++) {
|
||
const rowName = getRowLabel(row);
|
||
|
||
for (let x = 0, cell = 1; x + tileW <= graphWidth; x += tileW, cell++, id++) {
|
||
status.innerHTML = `Rendering tile ${rowName}${cell} (${id} of ${tolesTotal})...`;
|
||
ctx.drawImage(img, x, y, tileW, tileH, 0, 0, width, height);
|
||
const blob = await canvasToBlob(canvas, "image/png");
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
zip.file(`${rowName}${cell}.png`, blob);
|
||
}
|
||
}
|
||
|
||
status.innerHTML = "Zipping files...";
|
||
zip.generateAsync({type: "blob"}).then(blob => {
|
||
status.innerHTML = "Downloading the archive...";
|
||
const link = document.createElement("a");
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = getFileName() + ".zip";
|
||
link.click();
|
||
link.remove();
|
||
|
||
status.innerHTML = 'Done. Check .zip file in "Downloads" (crtl + J)';
|
||
setTimeout(() => URL.revokeObjectURL(link.href), 5000);
|
||
});
|
||
|
||
// promisified img.onload
|
||
function loadImage(img) {
|
||
return new Promise((resolve, reject) => {
|
||
img.onload = () => resolve();
|
||
img.onerror = err => reject(err);
|
||
});
|
||
}
|
||
|
||
// promisified canvas.toBlob
|
||
function canvasToBlob(canvas, mimeType, qualityArgument = 1) {
|
||
return new Promise((resolve, reject) => {
|
||
canvas.toBlob(
|
||
blob => {
|
||
if (blob) resolve(blob);
|
||
else reject(new Error("Canvas toBlob() error"));
|
||
},
|
||
mimeType,
|
||
qualityArgument
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
// parse map svg to object url
|
||
async function getMapURL(
|
||
type,
|
||
{
|
||
debug = false,
|
||
noLabels = false,
|
||
noWater = false,
|
||
noScaleBar = false,
|
||
noIce = false,
|
||
noVignette = false,
|
||
fullMap = false
|
||
} = {}
|
||
) {
|
||
// Temporarily inject <use> elements so the clone includes relief icon data
|
||
if (typeof prepareReliefForSave === "function") prepareReliefForSave();
|
||
const cloneEl = byId("map").cloneNode(true); // clone svg
|
||
if (typeof restoreReliefAfterSave === "function") restoreReliefAfterSave();
|
||
cloneEl.id = "fantasyMap";
|
||
document.body.appendChild(cloneEl);
|
||
const clone = d3.select(cloneEl);
|
||
if (!debug) clone.select("#debug")?.remove();
|
||
|
||
const cloneDefs = cloneEl.getElementsByTagName("defs")[0];
|
||
const svgDefs = byId("defElements");
|
||
|
||
const isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
|
||
if (isFirefox && type === "mesh") clone.select("#oceanPattern")?.remove();
|
||
if (noLabels) {
|
||
clone.select("#labels #states")?.remove();
|
||
clone.select("#labels #burgLabels")?.remove();
|
||
clone.select("#icons #burgIcons")?.remove();
|
||
}
|
||
if (noWater) {
|
||
clone.select("#oceanBase").attr("opacity", 0);
|
||
clone.select("#oceanPattern").attr("opacity", 0);
|
||
}
|
||
if (noIce) clone.select("#ice")?.remove();
|
||
if (noVignette) clone.select("#vignette")?.remove();
|
||
if (fullMap) {
|
||
// reset transform to show the whole map
|
||
clone.attr("width", graphWidth).attr("height", graphHeight);
|
||
clone.select("#viewbox").attr("transform", null);
|
||
|
||
if (!noScaleBar) {
|
||
drawScaleBar(clone.select("#scaleBar"), 1);
|
||
fitScaleBar(clone.select("#scaleBar"), graphWidth, graphHeight);
|
||
}
|
||
}
|
||
if (noScaleBar) clone.select("#scaleBar")?.remove();
|
||
|
||
if (type === "svg") removeUnusedElements(clone);
|
||
if (customization && type === "mesh") updateMeshCells(clone);
|
||
inlineStyle(clone);
|
||
|
||
// remove unused filters
|
||
const filters = cloneEl.querySelectorAll("filter");
|
||
for (let i = 0; i < filters.length; i++) {
|
||
const id = filters[i].id;
|
||
if (cloneEl.querySelector("[filter='url(#" + id + ")']")) continue;
|
||
if (cloneEl.getAttribute("filter") === "url(#" + id + ")") continue;
|
||
filters[i].remove();
|
||
}
|
||
|
||
// remove unused patterns
|
||
const patterns = cloneEl.querySelectorAll("pattern");
|
||
for (let i = 0; i < patterns.length; i++) {
|
||
const id = patterns[i].id;
|
||
if (cloneEl.querySelector("[fill='url(#" + id + ")']")) continue;
|
||
patterns[i].remove();
|
||
}
|
||
|
||
// remove unused symbols
|
||
const symbols = cloneEl.querySelectorAll("symbol");
|
||
for (let i = 0; i < symbols.length; i++) {
|
||
const id = symbols[i].id;
|
||
if (cloneEl.querySelector("use[*|href='#" + id + "']")) continue;
|
||
symbols[i].remove();
|
||
}
|
||
|
||
// add displayed emblems
|
||
if (layerIsOn("toggleEmblems") && emblems.selectAll("use").size()) {
|
||
cloneEl
|
||
.getElementById("emblems")
|
||
?.querySelectorAll("use")
|
||
.forEach(el => {
|
||
const href = el.getAttribute("href") || el.getAttribute("xlink:href");
|
||
if (!href) return;
|
||
const emblem = byId(href.slice(1));
|
||
if (emblem) cloneDefs.append(emblem.cloneNode(true));
|
||
});
|
||
} else {
|
||
cloneDefs.querySelector("#defs-emblems")?.remove();
|
||
}
|
||
|
||
{
|
||
// replace ocean pattern href to base64
|
||
const image = cloneEl.getElementById("oceanicPattern");
|
||
const href = image?.getAttribute("href");
|
||
if (href) {
|
||
await new Promise(resolve => {
|
||
getBase64(href, base64 => {
|
||
image.setAttribute("href", base64);
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
{
|
||
// replace texture href to base64
|
||
const image = cloneEl.querySelector("#texture > image");
|
||
const href = image?.getAttribute("href");
|
||
if (href) {
|
||
await new Promise(resolve => {
|
||
getBase64(href, base64 => {
|
||
image.setAttribute("href", base64);
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// add relief icons (from <use> elements – canvas <image> is excluded)
|
||
if (cloneEl.getElementById("terrain")) {
|
||
const uniqueElements = new Set();
|
||
const terrainUses = cloneEl.getElementById("terrain").querySelectorAll("use");
|
||
for (let i = 0; i < terrainUses.length; i++) {
|
||
const href = terrainUses[i].getAttribute("href") || terrainUses[i].getAttribute("xlink:href");
|
||
if (href && href.startsWith("#")) uniqueElements.add(href);
|
||
}
|
||
|
||
const defsRelief = svgDefs.getElementById("defs-relief");
|
||
for (const terrain of [...uniqueElements]) {
|
||
const element = defsRelief.querySelector(terrain);
|
||
if (element) cloneDefs.appendChild(element.cloneNode(true));
|
||
}
|
||
}
|
||
|
||
// add wind rose
|
||
if (cloneEl.getElementById("compass")) {
|
||
const rose = svgDefs.getElementById("defs-compass-rose");
|
||
if (rose) cloneDefs.appendChild(rose.cloneNode(true));
|
||
}
|
||
|
||
// add burs icons
|
||
if (cloneEl.getElementById("burgIcons")) {
|
||
const groups = cloneEl.getElementById("burgIcons").querySelectorAll("g");
|
||
for (const group of Array.from(groups)) {
|
||
const icon = svgDefs.querySelector(group.dataset.icon);
|
||
if (icon) cloneDefs.appendChild(icon.cloneNode(true));
|
||
}
|
||
}
|
||
|
||
// add port icon
|
||
if (cloneEl.getElementById("anchors")) {
|
||
const anchor = svgDefs.getElementById("icon-anchor");
|
||
if (anchor) cloneDefs.appendChild(anchor.cloneNode(true));
|
||
}
|
||
|
||
// add grid pattern
|
||
if (cloneEl.getElementById("gridOverlay")?.hasChildNodes()) {
|
||
const type = cloneEl.getElementById("gridOverlay").getAttribute("type");
|
||
const pattern = svgDefs.getElementById("pattern_" + type);
|
||
if (pattern) cloneDefs.appendChild(pattern.cloneNode(true));
|
||
}
|
||
|
||
{
|
||
// replace external marker icons
|
||
const externalMarkerImages = cloneEl.querySelectorAll('#markers image[href]:not([href=""])');
|
||
const imageHrefs = Array.from(externalMarkerImages).map(img => img.getAttribute("href"));
|
||
|
||
for (const url of imageHrefs) {
|
||
await new Promise(resolve => {
|
||
getBase64(url, base64 => {
|
||
externalMarkerImages.forEach(img => {
|
||
if (img.getAttribute("href") === url) img.setAttribute("href", base64);
|
||
});
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
{
|
||
// replace external regiment icons
|
||
const externalRegimentImages = cloneEl.querySelectorAll('#armies image[href]:not([href=""])');
|
||
const imageHrefs = Array.from(externalRegimentImages).map(img => img.getAttribute("href"));
|
||
|
||
for (const url of imageHrefs) {
|
||
await new Promise(resolve => {
|
||
getBase64(url, base64 => {
|
||
externalRegimentImages.forEach(img => {
|
||
if (img.getAttribute("href") === url) img.setAttribute("href", base64);
|
||
});
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!cloneEl.getElementById("fogging-cont")) cloneEl.getElementById("fog")?.remove(); // remove unused fog
|
||
if (!cloneEl.getElementById("regions")) cloneEl.getElementById("statePaths")?.remove(); // removed unused statePaths
|
||
if (!cloneEl.getElementById("labels")) cloneEl.getElementById("textPaths")?.remove(); // removed unused textPaths
|
||
|
||
// add armies style
|
||
if (cloneEl.getElementById("armies")) {
|
||
cloneEl.insertAdjacentHTML(
|
||
"afterbegin",
|
||
"<style>#armies text {stroke: none; fill: #fff; text-shadow: 0 0 4px #000; dominant-baseline: central; text-anchor: middle; font-family: Helvetica; fill-opacity: 1;}#armies text.regimentIcon {font-size: .8em;}</style>"
|
||
);
|
||
}
|
||
|
||
// add xlink: for href to support svg 1.1
|
||
if (type === "svg") {
|
||
cloneEl.querySelectorAll("[href]").forEach(el => {
|
||
const href = el.getAttribute("href");
|
||
el.removeAttribute("href");
|
||
el.setAttribute("xlink:href", href);
|
||
});
|
||
}
|
||
|
||
// add hatchings
|
||
const hatchingUsers = cloneEl.querySelectorAll(`[fill^='url(#hatch']`);
|
||
const hatchingFills = unique(Array.from(hatchingUsers).map(el => el.getAttribute("fill")));
|
||
const hatchingIds = hatchingFills.map(fill => fill.slice(5, -1));
|
||
for (const hatchingId of hatchingIds) {
|
||
const hatching = svgDefs.getElementById(hatchingId);
|
||
if (hatching) cloneDefs.appendChild(hatching.cloneNode(true));
|
||
}
|
||
|
||
// load fonts
|
||
const usedFonts = getUsedFonts(cloneEl);
|
||
const fontsToLoad = usedFonts.filter(font => font.src);
|
||
if (fontsToLoad.length) {
|
||
const dataURLfonts = await loadFontsAsDataURI(fontsToLoad);
|
||
|
||
const fontFaces = dataURLfonts
|
||
.map(({family, src, unicodeRange = "", variant = "normal"}) => {
|
||
return `@font-face {font-family: "${family}"; src: ${src}; unicode-range: ${unicodeRange}; font-variant: ${variant};}`;
|
||
})
|
||
.join("\n");
|
||
|
||
const style = document.createElement("style");
|
||
style.setAttribute("type", "text/css");
|
||
style.innerHTML = fontFaces;
|
||
cloneEl.querySelector("defs").appendChild(style);
|
||
}
|
||
|
||
clone.remove();
|
||
|
||
const serialized =
|
||
`<?xml version="1.0" encoding="UTF-8" standalone="no"?>` + new XMLSerializer().serializeToString(cloneEl);
|
||
const blob = new Blob([serialized], {type: "image/svg+xml;charset=utf-8"});
|
||
const url = window.URL.createObjectURL(blob);
|
||
window.setTimeout(() => window.URL.revokeObjectURL(url), 5000);
|
||
return url;
|
||
}
|
||
|
||
// remove hidden g elements and g elements without children to make downloaded svg smaller in size
|
||
function removeUnusedElements(clone) {
|
||
// Check the clone (not the live terrain) so canvas-mode maps export correctly
|
||
if (!clone.select("#terrain use").size()) clone.select("#defs-relief")?.remove();
|
||
|
||
for (let empty = 1; empty; ) {
|
||
empty = 0;
|
||
clone.selectAll("g").each(function () {
|
||
if (!this.hasChildNodes() || this.style.display === "none" || this.classList.contains("hidden")) {
|
||
empty++;
|
||
this.remove();
|
||
}
|
||
if (this.hasAttribute("display") && this.style.display === "inline") this.removeAttribute("display");
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateMeshCells(clone) {
|
||
const data = renderOcean.checked ? grid.cells.i : grid.cells.i.filter(i => grid.cells.h[i] >= 20);
|
||
const scheme = getColorScheme(terrs.select("#landHeights").attr("scheme"));
|
||
clone.select("#heights").attr("filter", "url(#blur1)");
|
||
clone
|
||
.select("#heights")
|
||
.selectAll("polygon")
|
||
.data(data)
|
||
.join("polygon")
|
||
.attr("points", d => getGridPolygon(d))
|
||
.attr("id", d => "cell" + d)
|
||
.attr("stroke", d => getColor(grid.cells.h[d], scheme));
|
||
}
|
||
|
||
// for each g element get inline style
|
||
function inlineStyle(clone) {
|
||
const emptyG = clone.append("g").node();
|
||
const defaultStyles = window.getComputedStyle(emptyG);
|
||
|
||
clone.selectAll("g, #ruler *, #scaleBar > text").each(function () {
|
||
const compStyle = window.getComputedStyle(this);
|
||
let style = "";
|
||
|
||
for (let i = 0; i < compStyle.length; i++) {
|
||
const key = compStyle[i];
|
||
const value = compStyle.getPropertyValue(key);
|
||
|
||
if (key === "cursor") continue; // cursor should be default
|
||
if (this.hasAttribute(key)) continue; // don't add style if there is the same attribute
|
||
if (value === defaultStyles.getPropertyValue(key)) continue;
|
||
style += key + ":" + value + ";";
|
||
}
|
||
|
||
for (const key in compStyle) {
|
||
const value = compStyle.getPropertyValue(key);
|
||
|
||
if (key === "cursor") continue; // cursor should be default
|
||
if (this.hasAttribute(key)) continue; // don't add style if there is the same attribute
|
||
if (value === defaultStyles.getPropertyValue(key)) continue;
|
||
style += key + ":" + value + ";";
|
||
}
|
||
|
||
if (style != "") this.setAttribute("style", style);
|
||
});
|
||
|
||
emptyG.remove();
|
||
}
|
||
|
||
function saveGeoJsonCells() {
|
||
const {cells, vertices} = pack;
|
||
const json = {type: "FeatureCollection", features: []};
|
||
|
||
const getPopulation = i => {
|
||
const [r, u] = getCellPopulation(i);
|
||
return rn(r + u);
|
||
};
|
||
|
||
const getHeight = i => parseInt(getFriendlyHeight([...cells.p[i]]));
|
||
|
||
function getCellCoordinates(cellVertices) {
|
||
const coordinates = cellVertices.map(vertex => {
|
||
const [x, y] = vertices.p[vertex];
|
||
return getCoordinates(x, y, 4);
|
||
});
|
||
return [[...coordinates, coordinates[0]]];
|
||
}
|
||
|
||
cells.i.forEach(i => {
|
||
const coordinates = getCellCoordinates(cells.v[i]);
|
||
const height = getHeight(i);
|
||
const biome = cells.biome[i];
|
||
const type = pack.features[cells.f[i]].type;
|
||
const population = getPopulation(i);
|
||
const state = cells.state[i];
|
||
const province = cells.province[i];
|
||
const culture = cells.culture[i];
|
||
const religion = cells.religion[i];
|
||
const neighbors = cells.c[i];
|
||
|
||
const properties = {id: i, height, biome, type, population, state, province, culture, religion, neighbors};
|
||
const feature = {type: "Feature", geometry: {type: "Polygon", coordinates}, properties};
|
||
json.features.push(feature);
|
||
});
|
||
|
||
const fileName = getFileName("Cells") + ".geojson";
|
||
downloadFile(JSON.stringify(json), fileName, "application/json");
|
||
}
|
||
|
||
function saveGeoJsonRoutes() {
|
||
const features = pack.routes.map(({i, points, group, name = null}) => {
|
||
const coordinates = points.map(([x, y]) => getCoordinates(x, y, 4));
|
||
return {
|
||
type: "Feature",
|
||
geometry: {type: "LineString", coordinates},
|
||
properties: {id: i, group, name}
|
||
};
|
||
});
|
||
const json = {type: "FeatureCollection", features};
|
||
|
||
const fileName = getFileName("Routes") + ".geojson";
|
||
downloadFile(JSON.stringify(json), fileName, "application/json");
|
||
}
|
||
|
||
function saveGeoJsonRivers() {
|
||
const features = pack.rivers.map(
|
||
({i, cells, points, source, mouth, parent, basin, widthFactor, sourceWidth, discharge, name, type}) => {
|
||
if (!cells || cells.length < 2) return;
|
||
const meanderedPoints = Rivers.addMeandering(cells, points);
|
||
const coordinates = meanderedPoints.map(([x, y]) => getCoordinates(x, y, 4));
|
||
return {
|
||
type: "Feature",
|
||
geometry: {type: "LineString", coordinates},
|
||
properties: {id: i, source, mouth, parent, basin, widthFactor, sourceWidth, discharge, name, type}
|
||
};
|
||
}
|
||
);
|
||
const json = {type: "FeatureCollection", features};
|
||
|
||
const fileName = getFileName("Rivers") + ".geojson";
|
||
downloadFile(JSON.stringify(json), fileName, "application/json");
|
||
}
|
||
|
||
function saveGeoJsonMarkers() {
|
||
const features = pack.markers.map(marker => {
|
||
const {i, type, icon, x, y, size, fill, stroke} = marker;
|
||
const coordinates = getCoordinates(x, y, 4);
|
||
const note = notes.find(note => note.id === "marker" + i);
|
||
const properties = {id: i, type, icon, x, y, ...note, size, fill, stroke};
|
||
return {type: "Feature", geometry: {type: "Point", coordinates}, properties};
|
||
});
|
||
|
||
const json = {type: "FeatureCollection", features};
|
||
|
||
const fileName = getFileName("Markers") + ".geojson";
|
||
downloadFile(JSON.stringify(json), fileName, "application/json");
|
||
}
|
||
|
||
function saveGeoJsonZones() {
|
||
const {zones, cells, vertices} = pack;
|
||
const json = {type: "FeatureCollection", features: []};
|
||
|
||
// Helper function to convert zone cells to polygon coordinates
|
||
// Handles multiple disconnected components and holes properly
|
||
function getZonePolygonCoordinates(zoneCells) {
|
||
const cellsInZone = new Set(zoneCells);
|
||
const ofSameType = cellId => cellsInZone.has(cellId);
|
||
const ofDifferentType = cellId => !cellsInZone.has(cellId);
|
||
|
||
const checkedCells = new Set();
|
||
const rings = []; // Array of LinearRings (each ring is an array of coordinates)
|
||
|
||
// Find all boundary components by tracing each connected region
|
||
for (const cellId of zoneCells) {
|
||
if (checkedCells.has(cellId)) continue;
|
||
|
||
// Check if this cell is on the boundary (has a neighbor outside the zone)
|
||
const neighbors = cells.c[cellId];
|
||
const onBorder = neighbors.some(ofDifferentType);
|
||
if (!onBorder) continue;
|
||
|
||
// Check if this is an inner lake (hole) - skip if so
|
||
const feature = pack.features[cells.f[cellId]];
|
||
if (feature.type === "lake" && feature.shoreline) {
|
||
if (feature.shoreline.every(ofSameType)) continue;
|
||
}
|
||
|
||
// Find a starting vertex that's on the boundary
|
||
const cellVertices = cells.v[cellId];
|
||
let startingVertex = null;
|
||
|
||
for (const vertexId of cellVertices) {
|
||
const vertexCells = vertices.c[vertexId];
|
||
if (vertexCells.some(ofDifferentType)) {
|
||
startingVertex = vertexId;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (startingVertex === null) continue;
|
||
|
||
// Use connectVertices to trace the boundary (reusing existing logic)
|
||
const vertexChain = connectVertices({
|
||
vertices,
|
||
startingVertex,
|
||
ofSameType,
|
||
addToChecked: cellId => checkedCells.add(cellId),
|
||
closeRing: false // We'll close it manually after converting to coordinates
|
||
});
|
||
|
||
if (vertexChain.length < 3) continue;
|
||
|
||
// Convert vertex chain to coordinates
|
||
const coordinates = [];
|
||
for (const vertexId of vertexChain) {
|
||
const [x, y] = vertices.p[vertexId];
|
||
coordinates.push(getCoordinates(x, y, 4));
|
||
}
|
||
|
||
// Close the ring (first coordinate = last coordinate)
|
||
if (coordinates.length > 0) {
|
||
coordinates.push(coordinates[0]);
|
||
}
|
||
|
||
// Only add ring if it has at least 4 positions (minimum for valid LinearRing)
|
||
if (coordinates.length >= 4) {
|
||
rings.push(coordinates);
|
||
}
|
||
}
|
||
|
||
return rings;
|
||
}
|
||
|
||
// Filter and process zones
|
||
zones.forEach(zone => {
|
||
// Exclude hidden zones and zones with no cells
|
||
if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
|
||
|
||
const rings = getZonePolygonCoordinates(zone.cells);
|
||
|
||
// Skip if no valid rings were generated
|
||
if (rings.length === 0) return;
|
||
|
||
const properties = {
|
||
id: zone.i,
|
||
name: zone.name,
|
||
type: zone.type,
|
||
color: zone.color,
|
||
cells: zone.cells
|
||
};
|
||
|
||
// If there's only one ring, use Polygon geometry
|
||
if (rings.length === 1) {
|
||
const feature = {
|
||
type: "Feature",
|
||
geometry: {type: "Polygon", coordinates: rings},
|
||
properties
|
||
};
|
||
json.features.push(feature);
|
||
} else {
|
||
// Multiple disconnected components: use MultiPolygon
|
||
// Each component is wrapped in its own array
|
||
const multiPolygonCoordinates = rings.map(ring => [ring]);
|
||
const feature = {
|
||
type: "Feature",
|
||
geometry: {type: "MultiPolygon", coordinates: multiPolygonCoordinates},
|
||
properties
|
||
};
|
||
json.features.push(feature);
|
||
}
|
||
});
|
||
|
||
const fileName = getFileName("Zones") + ".geojson";
|
||
downloadFile(JSON.stringify(json), fileName, "application/json");
|
||
}
|