diff --git a/public/modules/io/export.js b/public/modules/io/export.js
index e2ab82634..2f2677ad9 100644
--- a/public/modules/io/export.js
+++ b/public/modules/io/export.js
@@ -3,71 +3,103 @@
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");
+ try {
+ 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);
+ } catch (error) {
+ ERROR && console.error(error);
+ tip(`SVG export failed: ${error?.message || "Unknown error"}`, true, "error", 5000);
+ } finally {
+ 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);
+ try {
+ 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 blob = await new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = function () {
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+ canvas.toBlob(blob => {
+ if (!blob) return reject(new Error("Cannot render PNG image"));
+ resolve(blob);
+ }, "image/png");
+ };
+ img.onerror = () => reject(new Error("Cannot load map image for PNG export"));
+ img.src = url;
});
- };
- TIME && console.timeEnd("exportToPng");
+ link.download = getFileName() + ".png";
+ link.href = window.URL.createObjectURL(blob);
+ link.click();
+ window.setTimeout(function () {
+ canvas.remove();
+ window.URL.revokeObjectURL(link.href);
+ }, 1000);
+
+ 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);
+ } catch (error) {
+ ERROR && console.error(error);
+ tip(`PNG export failed: ${error?.message || "Unknown error"}`, true, "error", 5000);
+ } finally {
+ TIME && console.timeEnd("exportToPng");
+ }
}
async function exportToJpeg() {
TIME && console.time("exportToJpeg");
- const url = await getMapURL("png");
+ try {
+ const url = await getMapURL("png");
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ canvas.width = svgWidth * pngResolutionInput.value;
+ canvas.height = svgHeight * pngResolutionInput.value;
- 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 blob = await new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = function () {
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+ canvas.toBlob(
+ blob => {
+ if (!blob) return reject(new Error("Cannot render JPEG image"));
+ resolve(blob);
+ },
+ "image/jpeg",
+ quality
+ );
+ };
+ img.onerror = () => reject(new Error("Cannot load map image for JPEG export"));
+ img.src = url;
+ });
+
const link = document.createElement("a");
link.download = getFileName() + ".jpeg";
- link.href = URL;
+ link.href = window.URL.createObjectURL(blob);
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");
+ window.setTimeout(() => window.URL.revokeObjectURL(link.href), 5000);
+ } catch (error) {
+ ERROR && console.error(error);
+ tip(`JPEG export failed: ${error?.message || "Unknown error"}`, true, "error", 5000);
+ } finally {
+ TIME && console.timeEnd("exportToJpeg");
+ }
}
async function exportToPngTiles() {
@@ -132,17 +164,24 @@ async function exportToPngTiles() {
}
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);
- });
+ 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" (CTRL + J)';
+ setTimeout(() => URL.revokeObjectURL(link.href), 5000);
+ })
+ .catch(error => {
+ ERROR && console.error(error);
+ status.innerHTML = "Tiles export failed";
+ tip(`PNG tiles export failed: ${error?.message || "Unknown error"}`, true, "error", 5000);
+ });
// promisified img.onload
function loadImage(img) {
@@ -583,31 +622,31 @@ function saveGeoJsonZones() {
// Handles multiple disconnected components and holes properly
function getZonePolygonCoordinates(zoneCells) {
const cellsInZone = new Set(zoneCells);
- const ofSameType = (cellId) => cellsInZone.has(cellId);
- const ofDifferentType = (cellId) => !cellsInZone.has(cellId);
-
+ const ofSameType = cellId => cellsInZone.has(cellId);
+ const ofDifferentType = cellId => !cellsInZone.has(cellId);
+
const checkedCells = new Set();
const rings = []; // Array of LinearRings (each ring is an array of coordinates)
-
+
// Find all boundary components by tracing each connected region
for (const cellId of zoneCells) {
if (checkedCells.has(cellId)) continue;
-
+
// Check if this cell is on the boundary (has a neighbor outside the zone)
const neighbors = cells.c[cellId];
const onBorder = neighbors.some(ofDifferentType);
if (!onBorder) continue;
-
+
// Check if this is an inner lake (hole) - skip if so
const feature = pack.features[cells.f[cellId]];
if (feature.type === "lake" && feature.shoreline) {
if (feature.shoreline.every(ofSameType)) continue;
}
-
+
// Find a starting vertex that's on the boundary
const cellVertices = cells.v[cellId];
let startingVertex = null;
-
+
for (const vertexId of cellVertices) {
const vertexCells = vertices.c[vertexId];
if (vertexCells.some(ofDifferentType)) {
@@ -615,38 +654,38 @@ function saveGeoJsonZones() {
break;
}
}
-
+
if (startingVertex === null) continue;
-
+
// Use connectVertices to trace the boundary (reusing existing logic)
const vertexChain = connectVertices({
vertices,
startingVertex,
ofSameType,
- addToChecked: (cellId) => checkedCells.add(cellId),
- closeRing: false, // We'll close it manually after converting to coordinates
+ addToChecked: cellId => checkedCells.add(cellId),
+ closeRing: false // We'll close it manually after converting to coordinates
});
-
+
if (vertexChain.length < 3) continue;
-
+
// Convert vertex chain to coordinates
const coordinates = [];
for (const vertexId of vertexChain) {
const [x, y] = vertices.p[vertexId];
coordinates.push(getCoordinates(x, y, 4));
}
-
+
// Close the ring (first coordinate = last coordinate)
if (coordinates.length > 0) {
coordinates.push(coordinates[0]);
}
-
+
// Only add ring if it has at least 4 positions (minimum for valid LinearRing)
if (coordinates.length >= 4) {
rings.push(coordinates);
}
}
-
+
return rings;
}
@@ -656,10 +695,10 @@ function saveGeoJsonZones() {
if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
const rings = getZonePolygonCoordinates(zone.cells);
-
+
// Skip if no valid rings were generated
if (rings.length === 0) return;
-
+
const properties = {
id: zone.i,
name: zone.name,
@@ -667,7 +706,7 @@ function saveGeoJsonZones() {
color: zone.color,
cells: zone.cells
};
-
+
// If there's only one ring, use Polygon geometry
if (rings.length === 1) {
const feature = {
diff --git a/public/modules/io/load.js b/public/modules/io/load.js
index 26d77b703..ffcbacb1c 100644
--- a/public/modules/io/load.js
+++ b/public/modules/io/load.js
@@ -71,24 +71,30 @@ function loadMapPrompt(blob) {
}
}
-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();
- });
+async function loadMapFromURL(maplink, random) {
+ const controller = new AbortController();
+ const TIMEOUT = 120000; // 120 seconds
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
+
+ try {
+ const url = decodeURIComponent(maplink);
+ const response = await fetch(url, {method: "GET", mode: "cors", signal: controller.signal});
+ if (!response.ok) throw new Error("Cannot load map from URL");
+
+ const blob = await response.blob();
+ uploadMap(blob);
+ } catch (error) {
+ const message = error?.name === "AbortError" ? "Cannot load map from URL: request timed out" : error.message;
+ showUploadErrorMessage(message, maplink, random);
+ if (random) generateMapOnLoad();
+ } finally {
+ clearTimeout(timeoutId);
+ }
}
-function showUploadErrorMessage(error, URL, random) {
+function showUploadErrorMessage(error, maplink, random) {
ERROR && console.error(error);
- alertMessage.innerHTML = /* html */ `Cannot load map from the ${link(URL, "link provided")}. ${
+ alertMessage.innerHTML = /* html */ `Cannot load map from the ${link(maplink, "link provided")}. ${
random ? `A new random map is generated. ` : ""
} Please ensure the
linked file is reachable and CORS is allowed on server side`;
@@ -744,7 +750,7 @@ async function parseLoadedData(data, mapVersion) {
ERROR && console.error(error);
clearMainTip();
- alertMessage.innerHTML = /* html */ `An error is occured on map loading. Select a different file to load,
generate a new random map or cancel the loading.
Map version: ${mapVersion}. Generator version: ${VERSION}.
+ alertMessage.innerHTML = /* html */ `An error occurred while loading the map. Select a different file to load,
generate a new random map or cancel the loading.
Map version: ${mapVersion}. Generator version: ${VERSION}.
${parseError(error)}
`; $("#alert").dialog({ diff --git a/public/modules/io/save.js b/public/modules/io/save.js index 85601bfb5..6146d5190 100644 --- a/public/modules/io/save.js +++ b/public/modules/io/save.js @@ -9,12 +9,12 @@ async function saveMap(method) { const mapData = prepareMapData(); const filename = getFileName() + ".map"; - if (method === "storage") saveToStorage(mapData, true); + if (method === "storage") await saveToStorage(mapData, true); if (method === "machine") saveToMachine(mapData, filename); - if (method === "dropbox") saveToDropbox(mapData, filename); + if (method === "dropbox") await 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( + alertMessage.innerHTML = /* html */ `An error occurred while saving the map. If the issue persists, please copy the message below and report it on ${link( "https://github.com/Azgaar/Fantasy-Map-Generator/issues", "GitHub" )}.${parseError(error)}
`; @@ -180,7 +180,7 @@ function saveToMachine(mapData, filename) { link.click(); tip('Map is saved to the "Downloads" folder (CTRL + J to open)', true, "success", 8000); - window.URL.revokeObjectURL(URL); + setTimeout(() => window.URL.revokeObjectURL(URL), 5000); } async function saveToDropbox(mapData, filename) { @@ -189,7 +189,7 @@ async function saveToDropbox(mapData, filename) { } async function initiateAutosave() { - const MINUTE = 60000; // munite in milliseconds + const MINUTE = 60000; // minute in milliseconds let lastSavedAt = Date.now(); async function autosave() { @@ -209,24 +209,13 @@ async function initiateAutosave() { lastSavedAt = Date.now(); } catch (error) { ERROR && console.error(error); + tip(`Autosave failed: ${error?.message || "Unknown error"}`, true, "error", 4000); } } 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 = [ diff --git a/public/modules/ui/ai-generator.js b/public/modules/ui/ai-generator.js index 8ef13cf69..ae9056238 100644 --- a/public/modules/ui/ai-generator.js +++ b/public/modules/ui/ai-generator.js @@ -113,7 +113,9 @@ async function handleStream(response, getContent) { try { const json = await response.json(); errorMessage = json.error?.message || json.error || errorMessage; - } catch {} + } catch (error) { + ERROR && console.error("Failed to parse AI provider error response", error); + } throw new Error(errorMessage); } @@ -219,7 +221,8 @@ function generateWithAi(defaultPrompt, onApply) { await PROVIDERS[provider].generate({key, model, prompt, temperature, onContent}); } catch (error) { - return tip(error.message, true, "error", 4000); + const message = error?.message || String(error) || "Failed to generate text"; + return tip(message, true, "error", 4000); } finally { button.disabled = false; byId("aiGeneratorResult").disabled = false; diff --git a/public/modules/ui/general.js b/public/modules/ui/general.js index 1849f8a75..8792d712f 100644 --- a/public/modules/ui/general.js +++ b/public/modules/ui/general.js @@ -14,11 +14,12 @@ if (location.hostname !== "localhost" && location.hostname !== "127.0.0.1") { // Tooltips const tooltip = document.getElementById("tooltip"); +const onDataTipMove = debounce(showDataTip, 50); // show tip for non-svg elemets with data-tip -document.getElementById("dialogs").addEventListener("mousemove", showDataTip); -document.getElementById("optionsContainer").addEventListener("mousemove", showDataTip); -document.getElementById("exitCustomization").addEventListener("mousemove", showDataTip); +document.getElementById("dialogs").addEventListener("mousemove", onDataTipMove); +document.getElementById("optionsContainer").addEventListener("mousemove", onDataTipMove); +document.getElementById("exitCustomization").addEventListener("mousemove", onDataTipMove); const tipBackgroundMap = { info: "linear-gradient(0.1turn, #ffffff00, #5e5c5c80, #ffffff00)", @@ -129,8 +130,8 @@ function showMapTooltip(point, e, i, g) { parent.id === "burgEmblems" ? [pack.burgs, "burg"] : parent.id === "provinceEmblems" - ? [pack.provinces, "province"] - : [pack.states, "state"]; + ? [pack.provinces, "province"] + : [pack.states, "state"]; const i = +e.target.dataset.i; if (event.shiftKey) highlightEmblemElement(type, g[i]); @@ -346,7 +347,8 @@ function getFriendlyHeight([x, y]) { function getHeight(h, abs) { const unit = heightUnit.value; let unitRatio = 3.281; // default calculations are in feet - if (unit === "m") unitRatio = 1; // if meter + if (unit === "m") + unitRatio = 1; // if meter else if (unit === "f") unitRatio = 0.5468; // if fathom let height = -990; diff --git a/public/modules/ui/heightmap-editor.js b/public/modules/ui/heightmap-editor.js index 227c07920..4991a3f8b 100644 --- a/public/modules/ui/heightmap-editor.js +++ b/public/modules/ui/heightmap-editor.js @@ -732,7 +732,8 @@ function editHeightmap(options) { const heights = grid.cells.h; const brush = document.querySelector("#brushesButtons > button.pressed").id; - if (brush === "brushRaise") selection.forEach(i => (heights[i] = !ocean && heights[i] < 20 ? 20 : lim(heights[i] + power))); + if (brush === "brushRaise") + selection.forEach(i => (heights[i] = !ocean && heights[i] < 20 ? 20 : lim(heights[i] + power))); else if (brush === "brushElevate") selection.forEach( (i, d) => (heights[i] = lim(heights[i] + interpolate(d / Math.max(selection.length - 1, 1)))) @@ -747,7 +748,11 @@ function editHeightmap(options) { selection.forEach( i => (heights[i] = rn( - (d3.mean(grid.cells.c[i].filter(c => (land ? heights[c] >= 20 : ocean ? heights[c] < 20 : 1)).map(c => heights[c])) + + (d3.mean( + grid.cells.c[i] + .filter(c => (land ? heights[c] >= 20 : ocean ? heights[c] < 20 : 1)) + .map(c => heights[c]) + ) + heights[i] * (10 - power) + 0.6) / (11 - power), @@ -816,8 +821,10 @@ function editHeightmap(options) { } function startFromScratch() { - if (cellTypeFilter.value === "land") return tip("Not allowed when 'only land cells' filter is set", false, "error"); - if (cellTypeFilter.value === "water") return tip("Not allowed when 'only water cells' filter is set", false, "error"); + if (cellTypeFilter.value === "land") + return tip("Not allowed when 'only land cells' filter is set", false, "error"); + if (cellTypeFilter.value === "water") + return tip("Not allowed when 'only water cells' filter is set", false, "error"); const someHeights = grid.cells.h.some(h => h); if (!someHeights) return tip("Heightmap is already cleared, please do not click twice if not required", false, "error"); @@ -1479,8 +1486,8 @@ function editHeightmap(options) { function closeImageConverter(event) { event.preventDefault(); event.stopPropagation(); - alertMessage.innerHTML = /* html */ ` Are you sure you want to close the Image Converter? Click "Cancel" to geck back to convertion. Click "Complete" to apply - the conversion. Click "Close" to exit conversion mode and restore previous heightmap`; + alertMessage.innerHTML = /* html */ `Are you sure you want to close the Image Converter? Click "Cancel" to keep editing. Click "Complete" to apply + the conversion and close the tool. Click "Close" to discard the conversion and restore the previous heightmap.`; $("#alert").dialog({ resizable: false, diff --git a/public/modules/ui/options.js b/public/modules/ui/options.js index 07d77cb03..5935ce0bb 100644 --- a/public/modules/ui/options.js +++ b/public/modules/ui/options.js @@ -241,10 +241,22 @@ function toggleTranslateExtent(el) { } // add voice options +let voiceAttempts = 0; const voiceInterval = setInterval(function () { + voiceAttempts++; const voices = speechSynthesis.getVoices(); - if (voices.length) clearInterval(voiceInterval); - else return; + if (!voices.length) { + if (voiceAttempts < 10) return; + + clearInterval(voiceInterval); + const select = byId("speakerVoice"); + if (select && !select.options.length) { + select.options.add(new Option("No voices available", "", false)); + } + return; + } + + clearInterval(voiceInterval); const select = byId("speakerVoice"); voices.forEach((voice, i) => { diff --git a/public/modules/ui/world-configurator.js b/public/modules/ui/world-configurator.js index b3c6da39c..d0dafa186 100644 --- a/public/modules/ui/world-configurator.js +++ b/public/modules/ui/world-configurator.js @@ -97,7 +97,7 @@ function editWorld() { if (layerIsOn("toggleBiomes")) drawBiomes(); if (layerIsOn("toggleCoordinates")) drawCoordinates(); if (layerIsOn("toggleRivers")) drawRivers(); - if (byId("canvas3d")) setTimeout(ThreeD.update(), 500); + if (byId("canvas3d")) setTimeout(() => ThreeD.update(), 500); } function updateGlobePosition() { diff --git a/public/versioning.js b/public/versioning.js index 89cdc3c0a..a50aec15b 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -59,7 +59,7 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o ${latestPublicChanges.map(change => `Join our Discord server and Reddit community to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.
+Join our Discord server and Reddit community to ask questions, share maps, discuss the Generator and Worldbuilding, report bugs and propose new features.
Thanks for all supporters on Patreon!`; $("#alert").dialog({ @@ -96,8 +96,9 @@ function parseMapVersion(version) { if (patch === undefined) { // e.g. 1.732 - minor = minor.slice(0, 2); - patch = minor.slice(2); + const compactVersion = minor; + minor = compactVersion.slice(0, 2); + patch = compactVersion.slice(2); } // e.g. 0.7b diff --git a/src/index.html b/src/index.html index 023841d4c..27649c8e1 100644 --- a/src/index.html +++ b/src/index.html @@ -6220,11 +6220,6 @@Export in JSON format can be used as an API replacement.
- -- It's also possible to export map to Foundry VTT, see - the module. -