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 => `
  • ${change}
  • `).join("")} -

    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. -