[Migration] NPM (#1266)

* chore: add npm + vite for progressive enhancement

* fix: update Dockerfile to copy only the dist folder contents

* fix: update Dockerfile to use multi-stage build for optimized production image

* fix: correct nginx config file copy command in Dockerfile

* chore: add netlify configuration for build and redirects

* fix: add NODE_VERSION to environment in Netlify configuration

* remove wrong dist folder

* Update package.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: split public and src

* migrating all util files from js to ts

* feat: Implement HeightmapGenerator and Voronoi module

- Added HeightmapGenerator class for generating heightmaps with various tools (Hill, Pit, Range, Trough, Strait, etc.).
- Introduced Voronoi class for creating Voronoi diagrams using Delaunator.
- Updated index.html to include new modules.
- Created index.ts to manage module imports.
- Enhanced arrayUtils and graphUtils with type definitions and improved functionality.
- Added utility functions for generating grids and calculating Voronoi cells.

* chore: add GitHub Actions workflow for deploying to GitHub Pages

* fix: update branch name in GitHub Actions workflow from 'main' to 'master'

* chore: update package.json to specify Node.js engine version and remove unused launch.json

* Initial plan

* Update copilot guidelines to reflect NPM/Vite/TypeScript migration

Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

* Update src/modules/heightmap-generator.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/utils/graphUtils.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/heightmap-generator.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat: Add TIME and ERROR variables to global scope in HeightmapGenerator

* fix: Update base path in vite.config.ts for Netlify deployment

* fix: Update Node.js version in Dockerfile to 24-alpine

---------

Co-authored-by: Marc Emmanuel <marc.emmanuel@tado.com>
Co-authored-by: Marc Emmanuel <marcwissler@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>
This commit is contained in:
Azgaar 2026-01-22 12:20:12 +01:00 committed by GitHub
parent 0c26f0831f
commit 9e0eb03618
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
713 changed files with 5182 additions and 2161 deletions

141
public/modules/io/cloud.js Normal file
View file

@ -0,0 +1,141 @@
"use strict";
/*
Cloud provider implementations (Dropbox only as now)
provider Interface:
name: name of the provider
async auth(): authenticate and get access tokens from provider
async save(filename): save map file to provider as filename
async load(filename): load filename from provider
async list(): list available filenames at provider
async getLink(filePath): get shareable link for file
restore(): restore access tokens from storage if possible
*/
window.Cloud = (function () {
// helpers to use in providers for token handling
const lSKey = x => `auth-${x}`;
const setToken = (prov, key) => localStorage.setItem(lSKey(prov), key);
const getToken = prov => localStorage.getItem(lSKey(prov));
/**********************************************************/
/* Dropbox provider */
/**********************************************************/
const DBP = {
name: "dropbox",
clientId: "pdr9ae64ip0qno4",
authWindow: undefined,
token: null, // Access token
api: null,
async call(name, param) {
try {
if (!this.api) await this.initialize();
return await this.api[name](param);
} catch (e) {
if (e.name !== "DropboxResponseError") throw e;
await this.auth(); // retry with auth
return await this.api[name](param);
}
},
initialize() {
const token = getToken(this.name);
if (token) {
return this.connect(token);
} else {
return this.auth();
}
},
async connect(token) {
await import("../../libs/dropbox-sdk.min.js");
const auth = new Dropbox.DropboxAuth({clientId: this.clientId});
auth.setAccessToken(token);
this.api = new Dropbox.Dropbox({auth});
},
async save(fileName, contents) {
const resp = await this.call("filesUpload", {path: "/" + fileName, contents});
DEBUG.cloud && console.info("Dropbox response:", resp);
return true;
},
async load(path) {
const resp = await this.call("filesDownload", {path});
const blob = resp.result.fileBlob;
if (!blob) throw new Error("Invalid response from dropbox.");
return blob;
},
async list() {
const resp = await this.call("filesListFolder", {path: ""});
const filesData = resp.result.entries.map(({name, client_modified, size, path_lower}) => ({
name: name,
updated: client_modified,
size,
path: path_lower
}));
return filesData.filter(({size}) => size).reverse();
},
auth() {
const width = 640;
const height = 480;
const left = window.innerWidth / 2 - width / 2;
const top = window.innerHeight / 2 - height / 2.5;
this.authWindow = window.open("./dropbox.html", "auth", `width=640, height=${height}, top=${top}, left=${left}}`);
return new Promise((resolve, reject) => {
const watchDog = setTimeout(() => {
this.authWindow.close();
reject(new Error("Timeout. No auth for Dropbox"));
}, 120 * 1000);
window.addEventListener("dropboxauth", e => {
clearTimeout(watchDog);
resolve();
});
});
},
// Callback function for auth window
async setDropBoxToken(token) {
DEBUG.cloud && console.info("Access token:", token);
setToken(this.name, token);
await this.connect(token);
this.authWindow.close();
window.dispatchEvent(new Event("dropboxauth"));
},
returnError(errorDescription) {
console.error(errorDescription);
tip(errorDescription.replaceAll("+", " "), true, "error", 4000);
this.authWindow.close();
},
async getLink(path) {
// return existing shared link
const sharedLinks = await this.call("sharingListSharedLinks", {path});
if (sharedLinks.result.links.length) return sharedLinks.result.links[0].url;
// create new shared link
const settings = {
require_password: false,
audience: "public",
access: "viewer",
requested_visibility: "public",
allow_download: true
};
const resp = await this.call("sharingCreateSharedLinkWithSettings", {path, settings});
DEBUG.cloud && console.info("Dropbox link object:", resp.result);
return resp.result.url;
}
};
const providers = {dropbox: DBP};
return {providers};
})();

576
public/modules/io/export.js Normal file
View file

@ -0,0 +1,576 @@
"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
} = {}
) {
const cloneEl = byId("map").cloneNode(true); // clone svg
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
if (cloneEl.getElementById("terrain")) {
const uniqueElements = new Set();
const terrainNodes = cloneEl.getElementById("terrain").childNodes;
for (let i = 0; i < terrainNodes.length; i++) {
const href = terrainNodes[i].getAttribute("href") || terrainNodes[i].getAttribute("xlink:href");
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) {
if (!terrain.selectAll("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");
}

769
public/modules/io/load.js Normal file
View file

@ -0,0 +1,769 @@
"use strict";
// Functions to load and parse .map/.gz files
async function quickLoad() {
const blob = await ldb.get("lastMap");
if (blob) loadMapPrompt(blob);
else {
tip("No map stored. Save map to browser storage first", true, "error", 2000);
ERROR && console.error("No map stored");
}
}
async function loadFromDropbox() {
const mapPath = byId("loadFromDropboxSelect")?.value;
console.info("Loading map from Dropbox:", mapPath);
const blob = await Cloud.providers.dropbox.load(mapPath);
uploadMap(blob);
}
async function createSharableDropboxLink() {
const mapFile = document.querySelector("#loadFromDropbox select").value;
const sharableLink = byId("sharableLink");
const sharableLinkContainer = byId("sharableLinkContainer");
try {
const previewLink = await Cloud.providers.dropbox.getLink(mapFile);
const directLink = previewLink.replace("www.dropbox.com", "dl.dropboxusercontent.com"); // DL allows CORS
const finalLink = `${location.origin}${location.pathname}?maplink=${directLink}`;
sharableLink.innerText = finalLink.slice(0, 45) + "...";
sharableLink.setAttribute("href", finalLink);
sharableLinkContainer.style.display = "block";
} catch (error) {
ERROR && console.error(error);
return tip("Dropbox API error. Can not create link.", true, "error", 2000);
}
}
function loadMapPrompt(blob) {
const workingTime = (Date.now() - last(mapHistory).created) / 60000; // minutes
if (workingTime < 5) {
loadLastSavedMap();
return;
}
alertMessage.innerHTML = /* html */ `Are you sure you want to load saved map?<br />
All unsaved changes made to the current map will be lost`;
$("#alert").dialog({
resizable: false,
title: "Load saved map",
buttons: {
Cancel: function () {
$(this).dialog("close");
},
Load: function () {
loadLastSavedMap();
$(this).dialog("close");
}
}
});
function loadLastSavedMap() {
WARN && console.warn("Load last saved map");
try {
uploadMap(blob);
} catch (error) {
ERROR && console.error(error);
tip("Cannot load last saved map", true, "error", 2000);
}
}
}
function loadMapFromURL(maplink, random) {
const URL = decodeURIComponent(maplink);
fetch(URL, {method: "GET", mode: "cors"})
.then(response => {
if (response.ok) return response.blob();
throw new Error("Cannot load map from URL");
})
.then(blob => uploadMap(blob))
.catch(error => {
showUploadErrorMessage(error.message, URL, random);
if (random) generateMapOnLoad();
});
}
function showUploadErrorMessage(error, URL, random) {
ERROR && console.error(error);
alertMessage.innerHTML = /* html */ `Cannot load map from the ${link(URL, "link provided")}. ${
random ? `A new random map is generated. ` : ""
} Please ensure the
linked file is reachable and CORS is allowed on server side`;
$("#alert").dialog({
title: "Loading error",
width: "32em",
buttons: {
"Clear cache": () => cleanupData(),
OK: function () {
$(this).dialog("close");
}
}
});
}
function uploadMap(file, callback) {
uploadMap.timeStart = performance.now();
const fileReader = new FileReader();
fileReader.onloadend = async function (fileLoadedEvent) {
if (callback) callback();
byId("coas").innerHTML = ""; // remove auto-generated emblems
const result = fileLoadedEvent.target.result;
const {mapData, mapVersion} = await parseLoadedResult(result);
const isInvalid = !mapData || !isValidVersion(mapVersion) || mapData.length < 10 || !mapData[5];
if (isInvalid) return showUploadMessage("invalid", mapData, mapVersion);
const isUpdated = compareVersions(mapVersion, VERSION).isEqual;
if (isUpdated) return showUploadMessage("updated", mapData, mapVersion);
const isAncient = compareVersions(mapVersion, "0.70.0").isOlder;
if (isAncient) return showUploadMessage("ancient", mapData, mapVersion);
const isNewer = compareVersions(mapVersion, VERSION).isNewer;
if (isNewer) return showUploadMessage("newer", mapData, mapVersion);
const isOutdated = compareVersions(mapVersion, VERSION).isOlder;
if (isOutdated) return showUploadMessage("outdated", mapData, mapVersion);
};
fileReader.readAsArrayBuffer(file);
}
async function uncompress(compressedData) {
try {
const uncompressedStream = new Blob([compressedData]).stream().pipeThrough(new DecompressionStream("gzip"));
let uncompressedData = [];
for await (const chunk of uncompressedStream) {
uncompressedData = uncompressedData.concat(Array.from(chunk));
}
return new Uint8Array(uncompressedData);
} catch (error) {
ERROR && console.error(error);
return null;
}
}
async function parseLoadedResult(result) {
try {
const resultAsString = new TextDecoder().decode(result);
// data can be in FMG internal format or base64 encoded
const isDelimited = resultAsString.substring(0, 10).includes("|");
let content = isDelimited ? resultAsString : decodeURIComponent(atob(resultAsString));
// fix if svg part has CRLF line endings instead of LF
const svgMatch = content.match(/<svg[^>]*id="map"[\s\S]*?<\/svg>/);
const svgContent = svgMatch[0];
const hasCrlfEndings = svgContent.includes("\r\n");
if (hasCrlfEndings) {
const correctedSvgContent = svgContent.replace(/\r\n/g, "\n");
content = content.replace(svgContent, correctedSvgContent);
}
const mapData = content.split("\r\n"); // split by CRLF
const mapVersion = parseMapVersion(mapData[0].split("|")[0] || mapData[0] || "");
return {mapData, mapVersion};
} catch (error) {
const uncompressedData = await uncompress(result); // file can be gzip compressed
if (uncompressedData) return parseLoadedResult(uncompressedData);
ERROR && console.error(error);
return {mapData: null, mapVersion: null};
}
}
function showUploadMessage(type, mapData, mapVersion) {
let message, title;
if (type === "invalid") {
message = "The file does not look like a valid save file.<br>Please check the data format";
title = "Invalid file";
} else if (type === "updated") {
parseLoadedData(mapData, mapVersion);
return;
} else if (type === "ancient") {
const archive = link("https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Changelog", "archived version");
message = `The map version you are trying to load (${mapVersion}) is too old and cannot be updated to the current version.<br>Please keep using an ${archive}`;
title = "Ancient file";
} else if (type === "newer") {
message = `The map version you are trying to load (${mapVersion}) is newer than the current version.<br>Please load the file in the appropriate version`;
title = "Newer file";
} else if (type === "outdated") {
INFO && console.info(`Loading map. Auto-updating from ${mapVersion} to ${VERSION}`);
parseLoadedData(mapData, mapVersion);
return;
}
alertMessage.innerHTML = message;
$("#alert").dialog({
title,
buttons: {
"Clear cache": () => cleanupData(),
OK: function () {
$(this).dialog("close");
}
}
});
}
async function parseLoadedData(data, mapVersion) {
try {
// exit customization
if (window.closeDialogs) closeDialogs();
customization = 0;
if (customizationMenu.offsetParent) styleTab.click();
{
const params = data[0].split("|");
if (params[3]) {
seed = params[3];
optionsSeed.value = seed;
INFO && console.group("Loaded Map " + seed);
} else INFO && console.group("Loaded Map");
if (params[4]) graphWidth = +params[4];
if (params[5]) graphHeight = +params[5];
mapId = params[6] ? +params[6] : Date.now();
}
{
const settings = data[1].split("|");
if (settings[0]) applyOption(distanceUnitInput, settings[0]);
if (settings[1]) distanceScale = distanceScaleInput.value = settings[1];
if (settings[2]) areaUnit.value = settings[2];
if (settings[3]) applyOption(heightUnit, settings[3]);
if (settings[4]) heightExponentInput.value = settings[4];
if (settings[5]) temperatureScale.value = settings[5];
// setting 6-11 (scaleBar) are part of style now, kept as "" in newer versions for compatibility
if (settings[12]) populationRate = populationRateInput.value = settings[12];
if (settings[13]) urbanization = urbanizationInput.value = settings[13];
if (settings[14]) mapSizeInput.value = mapSizeOutput.value = minmax(settings[14], 1, 100);
if (settings[15]) latitudeInput.value = latitudeOutput.value = minmax(settings[15], 0, 100);
if (settings[18]) precInput.value = precOutput.value = settings[18];
if (settings[19]) options = JSON.parse(settings[19]);
// setting 16 and 17 (temperature) are part of options now, kept as "" in newer versions for compatibility
if (settings[16]) options.temperatureEquator = +settings[16];
if (settings[17]) options.temperatureNorthPole = options.temperatureSouthPole = +settings[17];
if (settings[20]) mapName.value = settings[20];
if (settings[21]) hideLabels.checked = +settings[21];
if (settings[22]) stylePreset.value = settings[22];
if (settings[23]) rescaleLabels.checked = +settings[23];
if (settings[24]) urbanDensity = urbanDensityInput.value = +settings[24];
if (settings[25]) longitudeInput.value = longitudeOutput.value = minmax(settings[25] || 50, 0, 100);
if (settings[26]) growthRate.value = settings[26];
}
{
stateLabelsModeInput.value = options.stateLabelsMode;
yearInput.value = options.year;
eraInput.value = options.era;
shapeRendering.value = viewbox.attr("shape-rendering") || "geometricPrecision";
}
{
if (data[2]) mapCoordinates = JSON.parse(data[2]);
if (data[4]) notes = JSON.parse(data[4]);
if (data[33]) rulers.fromString(data[33]);
if (data[34]) {
const usedFonts = JSON.parse(data[34]);
usedFonts.forEach(usedFont => {
const {family: usedFamily, unicodeRange: usedRange, variant: usedVariant} = usedFont;
const defaultFont = fonts.find(
({family, unicodeRange, variant}) =>
family === usedFamily && unicodeRange === usedRange && variant === usedVariant
);
if (!defaultFont) fonts.push(usedFont);
declareFont(usedFont);
});
}
}
{
const biomes = data[3].split("|");
biomesData = Biomes.getDefault();
biomesData.color = biomes[0].split(",");
biomesData.habitability = biomes[1].split(",").map(h => +h);
biomesData.name = biomes[2].split(",");
// push custom biomes if any
for (let i = biomesData.i.length; i < biomesData.name.length; i++) {
biomesData.i.push(biomesData.i.length);
biomesData.iconsDensity.push(0);
biomesData.icons.push([]);
biomesData.cost.push(50);
}
}
{
svg.remove();
document.body.insertAdjacentHTML("afterbegin", data[5]);
}
{
svg = d3.select("#map");
defs = svg.select("#deftemp");
viewbox = svg.select("#viewbox");
scaleBar = svg.select("#scaleBar");
legend = svg.select("#legend");
ocean = viewbox.select("#ocean");
oceanLayers = ocean.select("#oceanLayers");
oceanPattern = ocean.select("#oceanPattern");
lakes = viewbox.select("#lakes");
landmass = viewbox.select("#landmass");
texture = viewbox.select("#texture");
terrs = viewbox.select("#terrs");
biomes = viewbox.select("#biomes");
ice = viewbox.select("#ice");
cells = viewbox.select("#cells");
gridOverlay = viewbox.select("#gridOverlay");
coordinates = viewbox.select("#coordinates");
compass = viewbox.select("#compass");
rivers = viewbox.select("#rivers");
terrain = viewbox.select("#terrain");
relig = viewbox.select("#relig");
cults = viewbox.select("#cults");
regions = viewbox.select("#regions");
statesBody = regions.select("#statesBody");
statesHalo = regions.select("#statesHalo");
provs = viewbox.select("#provs");
zones = viewbox.select("#zones");
borders = viewbox.select("#borders");
stateBorders = borders.select("#stateBorders");
provinceBorders = borders.select("#provinceBorders");
routes = viewbox.select("#routes");
roads = routes.select("#roads");
trails = routes.select("#trails");
searoutes = routes.select("#searoutes");
temperature = viewbox.select("#temperature");
coastline = viewbox.select("#coastline");
prec = viewbox.select("#prec");
population = viewbox.select("#population");
emblems = viewbox.select("#emblems");
labels = viewbox.select("#labels");
icons = viewbox.select("#icons");
burgIcons = icons.select("#burgIcons");
anchors = icons.select("#anchors");
armies = viewbox.select("#armies");
markers = viewbox.select("#markers");
ruler = viewbox.select("#ruler");
fogging = viewbox.select("#fogging");
debug = viewbox.select("#debug");
burgLabels = labels.select("#burgLabels");
if (!texture.size()) {
texture = viewbox
.insert("g", "#landmass")
.attr("id", "texture")
.attr("data-href", "./images/textures/plaster.jpg");
}
if (!emblems.size()) {
emblems = viewbox.insert("g", "#labels").attr("id", "emblems").style("display", "none");
}
}
{
grid = JSON.parse(data[6]);
const {cells, vertices} = calculateVoronoi(grid.points, grid.boundary);
grid.cells = cells;
grid.vertices = vertices;
grid.cells.h = Uint8Array.from(data[7].split(","));
grid.cells.prec = Uint8Array.from(data[8].split(","));
grid.cells.f = Uint16Array.from(data[9].split(","));
grid.cells.t = Int8Array.from(data[10].split(","));
grid.cells.temp = Int8Array.from(data[11].split(","));
}
{
reGraph();
Features.markupPack();
pack.features = JSON.parse(data[12]);
pack.cultures = JSON.parse(data[13]);
pack.states = JSON.parse(data[14]);
pack.burgs = JSON.parse(data[15]);
pack.religions = data[29] ? JSON.parse(data[29]) : [{i: 0, name: "No religion"}];
pack.provinces = data[30] ? JSON.parse(data[30]) : [0];
pack.rivers = data[32] ? JSON.parse(data[32]) : [];
pack.markers = data[35] ? JSON.parse(data[35]) : [];
pack.routes = data[37] ? JSON.parse(data[37]) : [];
pack.zones = data[38] ? JSON.parse(data[38]) : [];
pack.cells.biome = Uint8Array.from(data[16].split(","));
pack.cells.burg = Uint16Array.from(data[17].split(","));
pack.cells.conf = Uint8Array.from(data[18].split(","));
pack.cells.culture = Uint16Array.from(data[19].split(","));
pack.cells.fl = Uint16Array.from(data[20].split(","));
pack.cells.pop = Float32Array.from(data[21].split(","));
pack.cells.r = Uint16Array.from(data[22].split(","));
// data[23] had deprecated cells.road
pack.cells.s = Uint16Array.from(data[24].split(","));
pack.cells.state = Uint16Array.from(data[25].split(","));
pack.cells.religion = data[26] ? Uint16Array.from(data[26].split(",")) : new Uint16Array(pack.cells.i.length);
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]) : {};
if (data[31]) {
const namesDL = data[31].split("/");
namesDL.forEach((d, i) => {
const e = d.split("|");
if (!e.length) return;
const b = e[5].split(",").length > 2 || !nameBases[i] ? e[5] : nameBases[i].b;
nameBases[i] = {name: e[0], min: e[1], max: e[2], d: e[3], m: e[4], b};
});
}
}
{
const isVisible = selection => selection.node() && selection.style("display") !== "none";
const isVisibleNode = node => node && node.style.display !== "none";
const hasChildren = selection => selection.node()?.hasChildNodes();
const hasChild = (selection, selector) => selection.node()?.querySelector(selector);
const turnOn = el => byId(el).classList.remove("buttonoff");
// turn all layers off
byId("mapLayers")
.querySelectorAll("li")
.forEach(el => el.classList.add("buttonoff"));
// turn on active layers
if (hasChild(texture, "image")) turnOn("toggleTexture");
if (hasChildren(terrs.select("#landHeights"))) turnOn("toggleHeight");
if (hasChildren(biomes)) turnOn("toggleBiomes");
if (hasChildren(cells)) turnOn("toggleCells");
if (hasChildren(gridOverlay)) turnOn("toggleGrid");
if (hasChildren(coordinates)) turnOn("toggleCoordinates");
if (isVisible(compass) && hasChild(compass, "use")) turnOn("toggleCompass");
if (hasChildren(rivers)) turnOn("toggleRivers");
if (isVisible(terrain) && hasChildren(terrain)) turnOn("toggleRelief");
if (hasChildren(relig)) turnOn("toggleReligions");
if (hasChildren(cults)) turnOn("toggleCultures");
if (hasChildren(statesBody)) turnOn("toggleStates");
if (hasChildren(provs)) turnOn("toggleProvinces");
if (hasChildren(zones) && isVisible(zones)) turnOn("toggleZones");
if (isVisible(borders) && hasChild(borders, "path")) turnOn("toggleBorders");
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 (hasChild(prec, "circle")) turnOn("togglePrecipitation");
if (isVisible(emblems) && hasChild(emblems, "use")) turnOn("toggleEmblems");
if (isVisible(labels)) turnOn("toggleLabels");
if (isVisible(icons)) turnOn("toggleBurgIcons");
if (hasChildren(armies) && isVisible(armies)) turnOn("toggleMilitary");
if (hasChildren(markers)) turnOn("toggleMarkers");
if (isVisible(ruler)) turnOn("toggleRulers");
if (isVisible(scaleBar)) turnOn("toggleScaleBar");
if (isVisibleNode(byId("vignette"))) turnOn("toggleVignette");
getCurrentPreset();
}
{
scaleBar.on("mousemove", () => tip("Click to open Units Editor")).on("click", () => editUnits());
legend
.on("mousemove", () => tip("Drag to change the position. Click to hide the legend"))
.on("click", () => clearLegend());
}
{
// dynamically import and run auto-update script
const {resolveVersionConflicts} = await import("../dynamic/auto-update.js?v=1.109.4");
resolveVersionConflicts(mapVersion);
}
// add custom heightmap color scheme if any
if (heightmapColorSchemes) {
const oceanScheme = byId("oceanHeights")?.getAttribute("scheme");
if (oceanScheme && !(oceanScheme in heightmapColorSchemes)) addCustomColorScheme(oceanScheme);
const landScheme = byId("#landHeights")?.getAttribute("scheme");
if (landScheme && !(landScheme in heightmapColorSchemes)) addCustomColorScheme(landScheme);
}
{
// add custom texture if any
const textureHref = texture.attr("data-href");
if (textureHref) updateTextureSelectValue(textureHref);
}
// data integrity checks
{
const {cells, vertices} = pack;
const cellsMismatch = cells.i.length !== cells.state.length;
const featureVerticesMismatch = pack.features.some(f => f?.vertices?.some(vertex => !vertices.p[vertex]));
if (cellsMismatch || featureVerticesMismatch) {
const message = "[Data integrity] Striping issue detected. To fix try to edit the heightmap in ERASE mode";
throw new Error(message);
}
const invalidStates = [...new Set(cells.state)].filter(s => !pack.states[s] || pack.states[s].removed);
invalidStates.forEach(s => {
const invalidCells = cells.i.filter(i => cells.state[i] === s);
invalidCells.forEach(i => (cells.state[i] = 0));
ERROR && console.error("[Data integrity] Invalid state", s, "is assigned to cells", invalidCells);
});
const invalidProvinces = [...new Set(cells.province)].filter(
p => p && (!pack.provinces[p] || pack.provinces[p].removed)
);
invalidProvinces.forEach(p => {
const invalidCells = cells.i.filter(i => cells.province[i] === p);
invalidCells.forEach(i => (cells.province[i] = 0));
ERROR && console.error("[Data integrity] Invalid province", p, "is assigned to cells", invalidCells);
});
const invalidCultures = [...new Set(cells.culture)].filter(c => !pack.cultures[c] || pack.cultures[c].removed);
invalidCultures.forEach(c => {
const invalidCells = cells.i.filter(i => cells.culture[i] === c);
invalidCells.forEach(i => (cells.province[i] = 0));
ERROR && console.error("[Data integrity] Invalid culture", c, "is assigned to cells", invalidCells);
});
const invalidReligions = [...new Set(cells.religion)].filter(
r => !pack.religions[r] || pack.religions[r].removed
);
invalidReligions.forEach(r => {
const invalidCells = cells.i.filter(i => cells.religion[i] === r);
invalidCells.forEach(i => (cells.religion[i] = 0));
ERROR && console.error("[Data integrity] Invalid religion", r, "is assigned to cells", invalidCells);
});
const invalidFeatures = [...new Set(cells.f)].filter(f => f && !pack.features[f]);
invalidFeatures.forEach(f => {
const invalidCells = cells.i.filter(i => cells.f[i] === f);
// No fix as for now
ERROR && console.error("[Data integrity] Invalid feature", f, "is assigned to cells", invalidCells);
});
const invalidBurgs = [...new Set(cells.burg)].filter(
burgId => burgId && (!pack.burgs[burgId] || pack.burgs[burgId].removed)
);
invalidBurgs.forEach(burgId => {
const invalidCells = cells.i.filter(i => cells.burg[i] === burgId);
invalidCells.forEach(i => (cells.burg[i] = 0));
ERROR && console.error("[Data integrity] Invalid burg", burgId, "is assigned to cells", invalidCells);
});
const invalidRivers = [...new Set(cells.r)].filter(r => r && !pack.rivers.find(river => river.i === r));
invalidRivers.forEach(r => {
const invalidCells = cells.i.filter(i => cells.r[i] === r);
invalidCells.forEach(i => (cells.r[i] = 0));
rivers.select("river" + r).remove();
ERROR && console.error("[Data integrity] Invalid river", r, "is assigned to cells", invalidCells);
});
pack.burgs.forEach(burg => {
if (typeof burg.capital === "boolean") burg.capital = Number(burg.capital);
if (!burg.i && burg.lock) {
ERROR && console.error(`[Data integrity] Burg 0 is marked as locked, removing the status`);
delete burg.lock;
return;
}
if (burg.removed && burg.lock) {
ERROR && console.error(`[Data integrity] Removed burg ${burg.i} is marked as locked. Unlocking the burg`);
delete burg.lock;
return;
}
if (!burg.i || burg.removed) return;
if (burg.cell === undefined || burg.x === undefined || burg.y === undefined) {
ERROR &&
console.error(`[Data integrity] Burg ${burg.i} is missing cell info or coordinates. Removing the burg`);
burg.removed = true;
}
if (burg.port < 0) {
ERROR && console.error("[Data integrity] Burg", burg.i, "has invalid port value", burg.port);
burg.port = 0;
}
if (burg.cell >= cells.i.length) {
ERROR && console.error("[Data integrity] Burg", burg.i, "is linked to invalid cell", burg.cell);
burg.cell = findCell(burg.x, burg.y);
cells.i.filter(i => cells.burg[i] === burg.i).forEach(i => (cells.burg[i] = 0));
cells.burg[burg.cell] = burg.i;
}
if (burg.state && !pack.states[burg.state]) {
ERROR && console.error("[Data integrity] Burg", burg.i, "is linked to invalid state", burg.state);
burg.state = 0;
}
if (burg.state && pack.states[burg.state].removed) {
ERROR && console.error("[Data integrity] Burg", burg.i, "is linked to removed state", burg.state);
burg.state = 0;
}
if (burg.state === undefined) {
ERROR && console.error("[Data integrity] Burg", burg.i, "has no state data");
burg.state = 0;
}
});
pack.states.forEach(state => {
if (state.removed) return;
const stateBurgs = pack.burgs.filter(b => b.state === state.i && !b.removed);
const capitalBurgs = stateBurgs.filter(b => b.capital);
if (!state.i && capitalBurgs.length) {
ERROR &&
console.error(
`[Data integrity] Neutral burgs (${capitalBurgs.map(b => b.i).join(", ")}) marked as capitals`
);
capitalBurgs.forEach(burg => {
burg.capital = 0;
Burgs.changeGroup(burg);
});
return;
}
if (capitalBurgs.length > 1) {
const message = `[Data integrity] State ${state.i} has multiple capitals (${capitalBurgs
.map(b => b.i)
.join(", ")}) assigned. Keeping the first as capital and moving others`;
ERROR && console.error(message);
capitalBurgs.forEach((burg, i) => {
if (!i) return;
burg.capital = 0;
Burgs.changeGroup(burg);
});
return;
}
if (state.i && stateBurgs.length && !capitalBurgs.length) {
ERROR && console.error(`[Data integrity] State ${state.i} has no capital. Making the first burg capital`);
const capital = stateBurgs[0];
capital.capital = 1;
Burgs.changeGroup(capital);
}
});
pack.provinces.forEach(p => {
if (!p.i || p.removed) return;
if (pack.states[p.state] && !pack.states[p.state].removed) return;
ERROR &&
console.error(
`[Data integrity] Province ${p.i} is linked to removed state ${p.state}. Removing the province`
);
p.removed = true;
});
pack.routes.forEach(route => {
if (!route.points || route.points.length < 2) {
ERROR && console.error(`[Data integrity] Route ${route.i} has less than 2 points. Removing the route`);
Routes.remove(route);
}
});
for (const from in pack.cells.routes) {
const value = pack.cells.routes[from];
if (!value) continue;
if (Object.keys(value).length === 0) {
// remove empty object
delete pack.cells.routes[from];
continue;
}
for (const to in value) {
const routeId = value[to];
const route = pack.routes.find(r => r.i === routeId);
if (!route) {
ERROR &&
console.error(`[Data integrity] Route ${routeId} from ${from} to ${to} is missing. Removing the route`);
delete pack.cells.routes[from][to];
}
}
}
{
const markerIds = [];
let nextId = last(pack.markers)?.i + 1 || 0;
pack.markers.forEach(marker => {
if (markerIds[marker.i]) {
ERROR && console.error("[Data integrity] Marker", marker.i, "has non-unique id. Changing to", nextId);
const domElements = document.querySelectorAll("#marker" + marker.i);
if (domElements[1]) domElements[1].id = "marker" + nextId; // rename 2nd dom element
const noteElements = notes.filter(note => note.id === "marker" + marker.i);
if (noteElements[1]) noteElements[1].id = "marker" + nextId; // rename 2nd note
marker.i = nextId;
nextId += 1;
} else {
markerIds[marker.i] = true;
}
});
// sort markers by index
pack.markers.sort((a, b) => a.i - b.i);
}
}
{
// remove href from emblems, to trigger rendering on load
emblems.selectAll("use").attr("href", null);
}
{
// draw data layers (not kept in svg)
if (rulers && layerIsOn("toggleRulers")) rulers.draw();
if (layerIsOn("toggleGrid")) drawGrid();
}
{
if (window.restoreDefaultEvents) restoreDefaultEvents();
focusOn(); // based on searchParams focus on point, cell or burg
invokeActiveZooming();
fitMapToScreen();
}
WARN && console.warn(`TOTAL: ${rn((performance.now() - uploadMap.timeStart) / 1000, 2)}s`);
showStatistics();
INFO && console.groupEnd("Loaded Map " + seed);
tip("Map is successfully loaded", true, "success", 7000);
} catch (error) {
ERROR && console.error(error);
clearMainTip();
alertMessage.innerHTML = /* html */ `An error is occured on map loading. Select a different file to load, <br>generate a new random map or cancel the loading.<br>Map version: ${mapVersion}. Generator version: ${VERSION}.
<p id="errorBox">${parseError(error)}</p>`;
$("#alert").dialog({
resizable: false,
title: "Loading error",
maxWidth: "40em",
buttons: {
"Clear cache": () => cleanupData(),
"Select file": function () {
$(this).dialog("close");
mapToLoad.click();
},
"New map": function () {
$(this).dialog("close");
regenerateMap("loading error");
},
Cancel: function () {
$(this).dialog("close");
}
},
position: {my: "center", at: "center", of: "svg"}
});
}
}

261
public/modules/io/save.js Normal file
View file

@ -0,0 +1,261 @@
"use strict";
// functions to save the project to a file
async function saveMap(method) {
if (customization) return tip("Map cannot be saved in EDIT mode, please complete the edit and retry", false, "error");
closeDialogs("#alert");
try {
const mapData = prepareMapData();
const filename = getFileName() + ".map";
saveToStorage(mapData, method === "storage"); // any method saves to indexedDB
if (method === "machine") saveToMachine(mapData, filename);
if (method === "dropbox") saveToDropbox(mapData, filename);
} catch (error) {
ERROR && console.error(error);
alertMessage.innerHTML = /* html */ `An error is occured on map saving. If the issue persists, please copy the message below and report it on ${link(
"https://github.com/Azgaar/Fantasy-Map-Generator/issues",
"GitHub"
)}. <p id="errorBox">${parseError(error)}</p>`;
$("#alert").dialog({
resizable: false,
title: "Saving error",
width: "28em",
buttons: {
Retry: function () {
$(this).dialog("close");
saveMap(method);
},
Close: function () {
$(this).dialog("close");
}
},
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";
const params = [VERSION, license, dateString, seed, graphWidth, graphHeight, mapId].join("|");
const settings = [
distanceUnitInput.value,
distanceScale,
areaUnit.value,
heightUnit.value,
heightExponentInput.value,
temperatureScale.value,
"", // previously used for barSize.value
"", // previously used for barLabel.value
"", // previously used for barBackColor.value
"", // previously used for barBackColor.value
"", // previously used for barPosX.value
"", // previously used for barPosY.value
populationRate,
urbanization,
mapSizeOutput.value,
latitudeOutput.value,
"", // previously used for temperatureEquatorOutput.value
"", // previously used for tempNorthOutput.value
precOutput.value,
JSON.stringify(options),
mapName.value,
+hideLabels.checked,
stylePreset.value,
+rescaleLabels.checked,
urbanDensity,
longitudeOutput.value,
growthRate.value
].join("|");
const coords = JSON.stringify(mapCoordinates);
const biomes = [biomesData.color, biomesData.habitability, biomesData.name].join("|");
const notesData = JSON.stringify(notes);
const rulersString = rulers.toString();
const fonts = JSON.stringify(getUsedFonts(svg.node()));
// save svg
const cloneEl = document.getElementById("map").cloneNode(true);
// reset transform values to default
cloneEl.setAttribute("width", graphWidth);
cloneEl.setAttribute("height", graphHeight);
cloneEl.querySelector("#viewbox").removeAttribute("transform");
cloneEl.querySelector("#ruler").innerHTML = ""; // always remove rulers
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 packFeatures = JSON.stringify(pack.features);
const cultures = JSON.stringify(pack.cultures);
const states = JSON.stringify(pack.states);
const burgs = JSON.stringify(pack.burgs);
const religions = JSON.stringify(pack.religions);
const provinces = JSON.stringify(pack.provinces);
const rivers = JSON.stringify(pack.rivers);
const markers = JSON.stringify(pack.markers);
const cellRoutes = JSON.stringify(pack.cells.routes);
const routes = JSON.stringify(pack.routes);
const zones = JSON.stringify(pack.zones);
// store name array only if not the same as default
const defaultNB = Names.getNameBases();
const namesData = nameBases
.map((b, i) => {
const names = defaultNB[i] && defaultNB[i].b === b.b ? "" : b.b;
return `${b.name}|${b.min}|${b.max}|${b.d}|${b.m}|${names}`;
})
.join("/");
// round population to save space
const pop = Array.from(pack.cells.pop).map(p => rn(p, 4));
// data format as below
const mapData = [
params,
settings,
coords,
biomes,
notesData,
serializedSVG,
gridGeneral,
grid.cells.h,
grid.cells.prec,
grid.cells.f,
grid.cells.t,
grid.cells.temp,
packFeatures,
cultures,
states,
burgs,
pack.cells.biome,
pack.cells.burg,
pack.cells.conf,
pack.cells.culture,
pack.cells.fl,
pop,
pack.cells.r,
[], // deprecated pack.cells.road
pack.cells.s,
pack.cells.state,
pack.cells.religion,
pack.cells.province,
[], // deprecated pack.cells.crossroad
religions,
provinces,
namesData,
rivers,
rulersString,
fonts,
markers,
cellRoutes,
routes,
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"});
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 URL = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = filename;
link.href = URL;
link.click();
tip('Map is saved to the "Downloads" folder (CTRL + J to open)', true, "success", 8000);
window.URL.revokeObjectURL(URL);
}
async function saveToDropbox(mapData, filename) {
await Cloud.providers.dropbox.save(filename, mapData);
tip("Map is saved to your Dropbox", true, "success", 8000);
}
async function initiateAutosave() {
const MINUTE = 60000; // munite in milliseconds
let lastSavedAt = Date.now();
async function autosave() {
const timeoutMinutes = byId("autosaveIntervalOutput").valueAsNumber;
if (!timeoutMinutes) return;
const diffInMinutes = (Date.now() - lastSavedAt) / MINUTE;
if (diffInMinutes < timeoutMinutes) return;
if (customization) return tip("Autosave: map cannot be saved in edit mode", false, "warning", 2000);
try {
tip("Autosave: saving map...", false, "warning", 3000);
const mapData = prepareMapData();
await saveToStorage(mapData);
tip("Autosave: map is saved", false, "success", 2000);
lastSavedAt = Date.now();
} catch (error) {
ERROR && console.error(error);
}
}
setInterval(autosave, MINUTE / 2);
}
// TODO: unused code
async function compressData(uncompressedData) {
const compressedStream = new Blob([uncompressedData]).stream().pipeThrough(new CompressionStream("gzip"));
let compressedData = [];
for await (const chunk of compressedStream) {
compressedData = compressedData.concat(Array.from(chunk));
}
return new Uint8Array(compressedData);
}
const saveReminder = function () {
if (localStorage.getItem("noReminder")) return;
const message = [
"Please don't forget to save the project to desktop from time to time",
"Please remember to save the map to your desktop",
"Saving will ensure your data won't be lost in case of issues",
"Safety is number one priority. Please save the map",
"Don't forget to save your map on a regular basis!",
"Just a gentle reminder for you to save the map",
"Please don't forget to save your progress (saving to desktop is the best option)",
"Don't want to get reminded about need to save? Press CTRL+Q"
];
const interval = 15 * 60 * 1000; // remind every 15 minutes
saveReminder.reminder = setInterval(() => {
if (customization) return;
tip(ra(message), true, "warn", 2500);
}, interval);
saveReminder.status = 1;
};
saveReminder();
function toggleSaveReminder() {
if (saveReminder.status) {
tip("Save reminder is turned off. Press CTRL+Q again to re-initiate", true, "warn", 2000);
clearInterval(saveReminder.reminder);
localStorage.setItem("noReminder", true);
saveReminder.status = 0;
} else {
tip("Save reminder is turned on. Press CTRL+Q to turn off", true, "warn", 2000);
localStorage.removeItem("noReminder");
saveReminder();
}
}