diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..698d23ae
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,89 @@
+# Fantasy Map Generator
+
+Azgaar's Fantasy Map Generator is a client-side JavaScript web application for creating fantasy maps. It generates detailed fantasy worlds with countries, cities, rivers, biomes, and cultural elements.
+
+Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
+
+## Working Effectively
+
+- **CRITICAL**: This is a static web application - NO build process needed. No npm install, no compilation, no bundling.
+- Run the application using HTTP server (required - cannot run with file:// protocol):
+ - `python3 -m http.server 8000` - takes 2-3 seconds to start
+- Access at: `http://localhost:8000`
+
+## Validation
+
+- Always manually validate any changes by:
+ 1. Starting the HTTP server (NEVER CANCEL - wait for full startup)
+ 2. Navigate to the application in browser
+ 3. Click the "►" button to open the menu and generate a new map
+ 4. **CRITICAL VALIDATION**: Verify the map generates with countries, cities, roads, and geographic features
+ 5. Test UI interaction: click "Layers" button, verify layer controls work
+ 6. Test regeneration: click "New Map!" button, verify new map generates correctly
+- **Known Issues**: Google Analytics and font loading errors are normal (blocked external resources)
+
+## Repository Structure
+
+### Core Files
+
+- `index.html` - Main application entry point
+- `main.js` - Core application logic
+- `versioning.js` - Version management and update handling
+
+### Key Directories
+
+- `modules/` - core functionality modules:
+ - `modules/ui/` - UI components (editors, tools, style management)
+ - `modules/dynamic/` - runtime modules (export, installation)
+ - `modules/renderers/` - drawing and rendering logic
+- `utils/` - utility libraries (math, arrays, strings, etc.)
+- `styles/` - visual style presets (JSON files)
+- `libs/` - Third-party libraries (D3.js, TinyMCE, etc.)
+- `images/` - backgrounds, UI elements
+- `charges/` - heraldic symbols and coat of arms elements
+- `config/` - Heightmap templates and configurations
+- `heightmaps/` - Terrain generation data
+
+## Common Tasks
+
+### Making Code Changes
+
+1. Edit JavaScript files directly (no compilation needed)
+2. Refresh browser to see changes immediately
+3. **ALWAYS test map generation** after making changes
+4. Update version in `versioning.js` for all changes
+5. Update file hashes in `index.html` for changed files (format: `file.js?v=1.108.1`)
+
+### Debugging Map Generation
+
+- Open browser developer tools console
+- Look for timing logs, e.g. "TOTAL: ~0.76s"
+- Map generation logs show each step (heightmap, rivers, states, etc.)
+- Error messages will indicate specific generation failures
+
+### Testing Different Map Types
+
+- Use "New Map!" button for quick regeneration
+- Access "Layers" menu to change map visualization
+- Available presets: Political, Cultural, Religions, Biomes, Heightmap, Physical, Military
+
+## Troubleshooting
+
+### Application Won't Load
+
+- Ensure using HTTP server (not file://)
+- Check console for JavaScript errors
+- Verify all files are present in repository
+
+### Map Generation Fails
+
+- Check browser console for error messages
+- Look for specific module failures in generation logs
+- Try refreshing page and generating new map
+
+### Performance Issues
+
+- Map generation should complete in ~1 second for standard configurations
+- If slower, check browser console for errors
+
+Remember: This is a sophisticated client-side application that generates complete fantasy worlds with political systems, geography, cultures, and detailed cartographic elements. Always validate that your changes preserve the core map generation functionality.
diff --git a/index.html b/index.html
index 24ac3e80..80241d98 100644
--- a/index.html
+++ b/index.html
@@ -5009,11 +5009,16 @@
@@ -8329,7 +8334,7 @@
-
+
@@ -8389,9 +8394,9 @@
-
+
-
+
@@ -8399,7 +8404,7 @@
-
+
@@ -8414,7 +8419,7 @@
-
+
diff --git a/modules/io/export.js b/modules/io/export.js
index 075ac801..fcc94cb6 100644
--- a/modules/io/export.js
+++ b/modules/io/export.js
@@ -330,6 +330,40 @@ async function getMapURL(
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
@@ -495,11 +529,10 @@ function saveGeoJsonCells() {
function saveGeoJsonRoutes() {
const features = pack.routes.map(({i, points, group, name = null}) => {
const coordinates = points.map(([x, y]) => getCoordinates(x, y, 4));
- const id = `route${i}`;
return {
type: "Feature",
geometry: {type: "LineString", coordinates},
- properties: {id, group, name}
+ properties: {id: i, group, name}
};
});
const json = {type: "FeatureCollection", features};
@@ -514,11 +547,10 @@ function saveGeoJsonRivers() {
if (!cells || cells.length < 2) return;
const meanderedPoints = Rivers.addMeandering(cells, points);
const coordinates = meanderedPoints.map(([x, y]) => getCoordinates(x, y, 4));
- const id = `river${i}`;
return {
type: "Feature",
geometry: {type: "LineString", coordinates},
- properties: {id, source, mouth, parent, basin, widthFactor, sourceWidth, discharge, name, type}
+ properties: {id: i, source, mouth, parent, basin, widthFactor, sourceWidth, discharge, name, type}
};
}
);
@@ -532,9 +564,8 @@ 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 id = `marker${i}`;
const note = notes.find(note => note.id === id);
- const properties = {id, type, icon, x, y, ...note, size, fill, stroke};
+ const properties = {id: i, type, icon, x, y, ...note, size, fill, stroke};
return {type: "Feature", geometry: {type: "Point", coordinates}, properties};
});
diff --git a/modules/ui/ai-generator.js b/modules/ui/ai-generator.js
index 734f1246..8ef13cf6 100644
--- a/modules/ui/ai-generator.js
+++ b/modules/ui/ai-generator.js
@@ -8,6 +8,10 @@ const PROVIDERS = {
anthropic: {
keyLink: "https://console.anthropic.com/account/keys",
generate: generateWithAnthropic
+ },
+ ollama: {
+ keyLink: "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Ollama-text-generation",
+ generate: generateWithOllama
}
};
@@ -18,11 +22,16 @@ const MODELS = {
"chatgpt-4o-latest": "openai",
"gpt-4o": "openai",
"gpt-4-turbo": "openai",
- "o1-preview": "openai",
- "o1-mini": "openai",
+ o3: "openai",
+ "o3-mini": "openai",
+ "o3-pro": "openai",
+ "o4-mini": "openai",
+ "claude-opus-4-20250514": "anthropic",
+ "claude-sonnet-4-20250514": "anthropic",
"claude-3-5-haiku-latest": "anthropic",
"claude-3-5-sonnet-latest": "anthropic",
- "claude-3-opus-latest": "anthropic"
+ "claude-3-opus-latest": "anthropic",
+ "ollama (local models)": "ollama"
};
const SYSTEM_MESSAGE = "I'm working on my fantasy map.";
@@ -76,10 +85,36 @@ async function generateWithAnthropic({key, model, prompt, temperature, onContent
await handleStream(response, getContent);
}
+async function generateWithOllama({key, model, prompt, temperature, onContent}) {
+ const ollamaModelName = key; // for Ollama, 'key' is the actual model name entered by the user
+
+ const response = await fetch("http://localhost:11434/api/generate", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({
+ model: ollamaModelName,
+ prompt,
+ system: SYSTEM_MESSAGE,
+ options: {temperature},
+ stream: true
+ })
+ });
+
+ const getContent = json => {
+ if (json.response) onContent(json.response);
+ };
+
+ await handleStream(response, getContent);
+}
+
async function handleStream(response, getContent) {
if (!response.ok) {
- const json = await response.json();
- throw new Error(json?.error?.message || "Failed to generate");
+ let errorMessage = `Failed to generate (${response.status} ${response.statusText})`;
+ try {
+ const json = await response.json();
+ errorMessage = json.error?.message || json.error || errorMessage;
+ } catch {}
+ throw new Error(errorMessage);
}
const reader = response.body.getReader();
@@ -95,13 +130,14 @@ async function handleStream(response, getContent) {
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
- if (line.startsWith("data: ") && line !== "data: [DONE]") {
- try {
- const json = JSON.parse(line.slice(6));
- getContent(json);
- } catch (jsonError) {
- ERROR && console.error(`Failed to parse JSON:`, jsonError, `Line: ${line}`);
- }
+ if (!line) continue;
+ if (line === "data: [DONE]") break;
+
+ try {
+ const parsed = line.startsWith("data: ") ? JSON.parse(line.slice(6)) : JSON.parse(line);
+ getContent(parsed);
+ } catch (error) {
+ ERROR && console.error("Failed to parse line:", line, error);
}
}
diff --git a/modules/ui/markers-overview.js b/modules/ui/markers-overview.js
index bcb2efb0..02999eb0 100644
--- a/modules/ui/markers-overview.js
+++ b/modules/ui/markers-overview.js
@@ -214,15 +214,15 @@ function overviewMarkers() {
const body = pack.markers.map(marker => {
const {i, type, icon, x, y} = marker;
- const id = `marker${i}`;
- const note = notes.find(note => note.id === id);
+
+ const note = notes.find(note => note.id === "marker" + i);
const name = note ? quote(note.name) : "Unknown";
const legend = note ? quote(note.legend) : "";
const lat = getLatitude(y, 2);
const lon = getLongitude(x, 2);
- return [id, type, icon, name, legend, x, y, lat, lon].join(",");
+ return [i, type, icon, name, legend, x, y, lat, lon].join(",");
});
const data = headers + body.join("\n");
diff --git a/modules/ui/units-editor.js b/modules/ui/units-editor.js
index a30e9a7d..aa63e64a 100644
--- a/modules/ui/units-editor.js
+++ b/modules/ui/units-editor.js
@@ -121,11 +121,16 @@ function editUnits() {
function addRuler() {
if (!layerIsOn("toggleRulers")) toggleRulers();
+
+ const width = Math.min(graphWidth, svgWidth);
+ const height = Math.min(graphHeight, svgHeight);
const pt = byId("map").createSVGPoint();
- (pt.x = graphWidth / 2), (pt.y = graphHeight / 4);
+ pt.x = width / 2;
+ pt.y = height / 4;
const p = pt.matrixTransform(viewbox.node().getScreenCTM().inverse());
- const dx = graphWidth / 4 / scale;
- const dy = (rulers.data.length * 40) % (graphHeight / 2);
+
+ const dx = width / 4 / scale;
+ const dy = (rulers.data.length * 40) % (height / 2);
const from = [(p.x - dx) | 0, (p.y + dy) | 0];
const to = [(p.x + dx) | 0, (p.y + dy) | 0];
rulers.create(Ruler, [from, to]).draw();
diff --git a/run_python_server.sh b/run_python_server.sh
new file mode 100644
index 00000000..7ac82957
--- /dev/null
+++ b/run_python_server.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env sh
+if command -v python3 >/dev/null 2>&1; then
+ PYTHON=python3
+elif command -v python >/dev/null 2>&1; then
+ PYTHON=python
+else
+ echo "Neither 'python' nor 'python3' was found. Please install Python 3 package." >&2
+ exit 1
+fi
+
+chromium http://localhost:8000
+
+$PYTHON -m http.server 8000
diff --git a/utils/languageUtils.js b/utils/languageUtils.js
index 87f5d67d..9caa1b6f 100644
--- a/utils/languageUtils.js
+++ b/utils/languageUtils.js
@@ -135,7 +135,7 @@ const adjectivizationRules = [
{
name: "an",
probability: 0.5,
- condition: new RegExp("^[a-zA-Z]{0-7}$"),
+ condition: new RegExp("^[a-zA-Z]{0,7}$"),
action: noun => trimVowels(noun) + "an"
}
];