mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-02-04 17:41:23 +01:00
[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:
parent
0c26f0831f
commit
9e0eb03618
713 changed files with 5182 additions and 2161 deletions
141
public/modules/io/cloud.js
Normal file
141
public/modules/io/cloud.js
Normal 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
576
public/modules/io/export.js
Normal 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
769
public/modules/io/load.js
Normal 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
261
public/modules/io/save.js
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue