qgis additions

This commit is contained in:
barrulus 2025-09-02 14:10:23 +01:00
parent fecbae826c
commit 20dfb7cfcb
17 changed files with 969 additions and 17 deletions

27
TODO.md Normal file
View file

@ -0,0 +1,27 @@
# TODO
## GeoJSON Exports (RFC 7946 compliance)
- Geometry in WGS84: Output `geometry.coordinates` as `[lon, lat]` (degrees). Do not include a top-level `crs` member (deprecated in RFC 7946).
- Move custom coords to properties: Keep fantasy/cartesian meters and pixel positions under `properties` (e.g., `fantasy_coordinates: [x_m, y_m]`, `x_px`, `y_px`, `meters_per_pixel`).
- Preserve fields: Continue exporting `id`, `type`, `name`, `icon` (where applicable), style fields (`size`, `fill`, `stroke`), and `note` (legend) if present.
- Update exporters: Apply to all GeoJSON exporters in `modules/io/export.js`:
- `saveGeoJsonMarkers`
- `saveGeoJsonRivers`
- `saveGeoJsonBurgs`
- `saveGeoJsonRoutes`
- `saveGeoJsonCells`
- `saveGeoJsonRegiments`
- Geometry specifics:
- Points (markers/burgs): `[lon, lat]` via `getLongitude(x)`, `getLatitude(y)`.
- Lines (rivers/routes): arrays of `[lon, lat]`; keep width/length and any fantasy metrics in `properties`.
- Polygons (cells): rings in `[lon, lat]`; move fantasy/cartesian vertices to `properties` if needed.
- Metadata: Keep projection info only as a custom field (e.g., `metadata.projection: "Fantasy Map Cartesian (meters)"`). Avoid reintroducing `crs`.
- Acceptance criteria:
- Files validate without CRS/projection warnings in common validators.
- QGIS/geojson.io load geometries correctly as WGS84.
- Internal consumers retain access to fantasy coords via `properties`.
- Backward compatibility: Consider a toggle to export in either WGS84 or fantasy-cartesian for users relying on previous behavior; otherwise bump export format version in `metadata`.
Note: `saveGeoJsonMarkers` now includes `name` (mirrors CSV). Ensure other exporters include analogous name fields where applicable.

File diff suppressed because one or more lines are too long

View file

@ -6053,6 +6053,14 @@
<button onclick="saveGeoJsonMarkers()" data-tip="Download markers data in GeoJSON format">markers</button> <button onclick="saveGeoJsonMarkers()" data-tip="Download markers data in GeoJSON format">markers</button>
<button onclick="saveGeoJsonBurgs()" data-tip="Download burgs data in GeoJSON format">burgs</button> <button onclick="saveGeoJsonBurgs()" data-tip="Download burgs data in GeoJSON format">burgs</button>
<button onclick="saveGeoJsonRegiments()" data-tip="Download regiments data in GeoJSON format">regiments</button> <button onclick="saveGeoJsonRegiments()" data-tip="Download regiments data in GeoJSON format">regiments</button>
<br />
<button onclick="saveGeoJsonStates()" data-tip="Download states in GeoJSON format">states</button>
<button onclick="saveGeoJsonProvinces()" data-tip="Download provinces in GeoJSON format">provinces</button>
<button onclick="saveGeoJsonCultures()" data-tip="Download cultures in GeoJSON format">cultures</button>
<button onclick="saveGeoJsonReligions()" data-tip="Download religions in GeoJSON format">religions</button>
<button onclick="saveGeoJsonZones()" data-tip="Download zones in GeoJSON format">zones</button>
<br />
<button onclick="saveAllGeoJson()" data-tip="Download all GeoJSON datasets as a ZIP">all (zip)</button>
</div> </div>
<p> <p>
GeoJSON format is used in GIS tools such as QGIS. Check out GeoJSON format is used in GIS tools such as QGIS. Check out
@ -6060,6 +6068,17 @@
for guidance. for guidance.
</p> </p>
<div style="margin: 1em 0 0.3em; font-weight: bold">Export height raster (QGIS)</div>
<div>
<button onclick="saveAsciiGridHeightmap()" data-tip="Export heightmap as ESRI ASCII Grid (.asc) for QGIS">
height (.asc)
</button>
</div>
<p>
Load the .asc in QGIS as a raster layer. Use Raster → Extraction → Contour to make contour lines,
and Raster → Analysis → Hillshade for shaded relief. CRS: Fantasy Map Cartesian (meters).
</p>
<div style="margin: 1em 0 0.3em; font-weight: bold">Export To JSON</div> <div style="margin: 1em 0 0.3em; font-weight: bold">Export To JSON</div>
<div> <div>
<button onclick="exportToJson('Full')" data-tip="Download full data in JSON">full</button> <button onclick="exportToJson('Full')" data-tip="Download full data in JSON">full</button>

View file

@ -561,7 +561,7 @@ function getFantasyCoordinates(x, y, decimals = 2) {
]; ];
} }
function saveGeoJsonCells() { function buildGeoJsonCells() {
const {cells, vertices} = pack; const {cells, vertices} = pack;
// Calculate meters per pixel based on unit // Calculate meters per pixel based on unit
@ -646,11 +646,16 @@ function saveGeoJsonCells() {
json.features.push(feature); json.features.push(feature);
}); });
return json;
}
function saveGeoJsonCells() {
const json = buildGeoJsonCells();
const fileName = getFileName("Cells") + ".geojson"; const fileName = getFileName("Cells") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json"); downloadFile(JSON.stringify(json), fileName, "application/json");
} }
function saveGeoJsonRoutes() { function buildGeoJsonRoutes() {
const metersPerPixel = getMetersPerPixel(); const metersPerPixel = getMetersPerPixel();
const features = pack.routes.map(({i, points, group, name = null, type, feature}) => { const features = pack.routes.map(({i, points, group, name = null, type, feature}) => {
const coordinates = points.map(([x, y]) => getFantasyCoordinates(x, y, 2)); const coordinates = points.map(([x, y]) => getFantasyCoordinates(x, y, 2));
@ -674,12 +679,16 @@ function saveGeoJsonRoutes() {
} }
} }
}; };
return json;
}
function saveGeoJsonRoutes() {
const json = buildGeoJsonRoutes();
const fileName = getFileName("Routes") + ".geojson"; const fileName = getFileName("Routes") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json"); downloadFile(JSON.stringify(json), fileName, "application/json");
} }
function saveGeoJsonRivers() { function buildGeoJsonRivers() {
const metersPerPixel = getMetersPerPixel(); const metersPerPixel = getMetersPerPixel();
const features = pack.rivers.map( const features = pack.rivers.map(
({i, cells, points, source, mouth, parent, basin, widthFactor, sourceWidth, discharge, length, width, name, type}) => { ({i, cells, points, source, mouth, parent, basin, widthFactor, sourceWidth, discharge, length, width, name, type}) => {
@ -707,21 +716,27 @@ function saveGeoJsonRivers() {
} }
} }
}; };
return json;
}
function saveGeoJsonRivers() {
const json = buildGeoJsonRivers();
const fileName = getFileName("Rivers") + ".geojson"; const fileName = getFileName("Rivers") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json"); downloadFile(JSON.stringify(json), fileName, "application/json");
} }
function saveGeoJsonMarkers() { function buildGeoJsonMarkers() {
const metersPerPixel = getMetersPerPixel(); const metersPerPixel = getMetersPerPixel();
const features = pack.markers.map(marker => { const features = pack.markers.map(marker => {
const {i, type, icon, x, y, size, fill, stroke} = marker; const {i, type, icon, x, y, size, fill, stroke} = marker;
const coordinates = getFantasyCoordinates(x, y, 2); const coordinates = getFantasyCoordinates(x, y, 2);
// Find the associated note if it exists // Find the associated note if it exists
const note = notes.find(note => note.id === `marker${i}`); const note = notes.find(note => note.id === `marker${i}`);
const name = note ? note.name : "Unknown";
const properties = { const properties = {
id: i, id: i,
type, type,
name,
icon, icon,
x_px: x, x_px: x,
y_px: y, y_px: y,
@ -746,24 +761,28 @@ function saveGeoJsonMarkers() {
} }
} }
}; };
return json;
}
function saveGeoJsonMarkers() {
const json = buildGeoJsonMarkers();
const fileName = getFileName("Markers") + ".geojson"; const fileName = getFileName("Markers") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json"); downloadFile(JSON.stringify(json), fileName, "application/json");
} }
function saveGeoJsonBurgs() { function buildGeoJsonBurgs() {
const metersPerPixel = getMetersPerPixel(); const metersPerPixel = getMetersPerPixel();
const valid = pack.burgs.filter(b => b.i && !b.removed); const valid = pack.burgs.filter(b => b.i && !b.removed);
const features = valid.map(b => { const features = valid.map(b => {
const coordinates = getFantasyCoordinates(b.x, b.y, 2); const coordinates = getFantasyCoordinates(b.x, b.y, 2);
const province = pack.cells.province[b.cell]; const province = pack.cells.province[b.cell];
const temperature = grid.cells.temp[pack.cells.g[b.cell]]; const temperature = grid.cells.temp[pack.cells.g[b.cell]];
// Calculate world coordinates same as CSV export // Calculate world coordinates same as CSV export
const xWorld = b.x * metersPerPixel; const xWorld = b.x * metersPerPixel;
const yWorld = -b.y * metersPerPixel; const yWorld = -b.y * metersPerPixel;
return { return {
type: "Feature", type: "Feature",
geometry: {type: "Point", coordinates}, geometry: {type: "Point", coordinates},
@ -811,15 +830,19 @@ function saveGeoJsonBurgs() {
} }
} }
}; };
return json;
}
function saveGeoJsonBurgs() {
const json = buildGeoJsonBurgs();
const fileName = getFileName("Burgs") + ".geojson"; const fileName = getFileName("Burgs") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json"); downloadFile(JSON.stringify(json), fileName, "application/json");
} }
function saveGeoJsonRegiments() { function buildGeoJsonRegiments() {
const metersPerPixel = getMetersPerPixel(); const metersPerPixel = getMetersPerPixel();
const allRegiments = []; const allRegiments = [];
// Collect all regiments from all states // Collect all regiments from all states
for (const s of pack.states) { for (const s of pack.states) {
if (!s.i || s.removed || !s.military.length) continue; if (!s.i || s.removed || !s.military.length) continue;
@ -827,23 +850,23 @@ function saveGeoJsonRegiments() {
allRegiments.push({regiment: r, state: s}); allRegiments.push({regiment: r, state: s});
} }
} }
const features = allRegiments.map(({regiment: r, state: s}) => { const features = allRegiments.map(({regiment: r, state: s}) => {
const coordinates = getFantasyCoordinates(r.x, r.y, 2); const coordinates = getFantasyCoordinates(r.x, r.y, 2);
const baseCoordinates = getFantasyCoordinates(r.bx, r.by, 2); const baseCoordinates = getFantasyCoordinates(r.bx, r.by, 2);
// Calculate world coordinates same as CSV export // Calculate world coordinates same as CSV export
const xWorld = r.x * metersPerPixel; const xWorld = r.x * metersPerPixel;
const yWorld = -r.y * metersPerPixel; const yWorld = -r.y * metersPerPixel;
const bxWorld = r.bx * metersPerPixel; const bxWorld = r.bx * metersPerPixel;
const byWorld = -r.by * metersPerPixel; const byWorld = -r.by * metersPerPixel;
// Collect military unit data // Collect military unit data
const units = {}; const units = {};
options.military.forEach(u => { options.military.forEach(u => {
units[u.name] = r.u[u.name] || 0; units[u.name] = r.u[u.name] || 0;
}); });
return { return {
type: "Feature", type: "Feature",
geometry: {type: "Point", coordinates}, geometry: {type: "Point", coordinates},
@ -885,7 +908,401 @@ function saveGeoJsonRegiments() {
} }
} }
}; };
return json;
}
function saveGeoJsonRegiments() {
const json = buildGeoJsonRegiments();
const fileName = getFileName("Regiments") + ".geojson"; const fileName = getFileName("Regiments") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json"); downloadFile(JSON.stringify(json), fileName, "application/json");
} }
// Export heightmap as ESRI ASCII Grid (.asc) for QGIS
function saveAsciiGridHeightmap() {
if (!grid?.cells?.h || !grid.cellsX || !grid.cellsY) {
tip("Height grid is not available", false, "error");
return;
}
const ncols = grid.cellsX;
const nrows = grid.cellsY;
const metersPerPixel = getMetersPerPixel();
const cellsize = (graphWidth / ncols) * metersPerPixel; // meters per grid cell
// Lower-left origin in world meters matches other exports
const xllcorner = 0;
const yllcorner = -(graphHeight * metersPerPixel);
const NODATA = -9999;
// Convert FMG height (0..100, 20 sea level) to meters (signed)
const exp = +heightExponentInput.value;
function elevationInMeters(h) {
if (h >= 20) return Math.pow(h - 18, exp); // above sea level
if (h > 0) return ((h - 20) / h) * 50; // below sea level (negative)
return 0; // treat 0 as 0
}
let lines = [];
lines.push(`ncols ${ncols}`);
lines.push(`nrows ${nrows}`);
lines.push(`xllcorner ${xllcorner}`);
lines.push(`yllcorner ${yllcorner}`);
lines.push(`cellsize ${cellsize}`);
lines.push(`NODATA_value ${NODATA}`);
// ESRI ASCII expects rows from top (north) to bottom (south)
for (let row = 0; row < nrows; row++) {
const vals = new Array(ncols);
for (let col = 0; col < ncols; col++) {
const i = col + row * ncols;
const h = grid.cells.h[i];
const z = elevationInMeters(h);
vals[col] = Number.isFinite(z) ? rn(z, 2) : NODATA;
}
lines.push(vals.join(" "));
}
const content = lines.join("\n");
const fileName = getFileName("Heightmap") + ".asc";
downloadFile(content, fileName, "text/plain");
}
// Helpers to build MultiPolygons from cell sets
function getCellPolygonCoordinates(cellVertices) {
const {vertices} = pack;
const coordinates = cellVertices.map(vertex => {
const [x, y] = vertices.p[vertex];
return getFantasyCoordinates(x, y, 2);
});
// Close the ring
return [[...coordinates, coordinates[0]]];
}
function buildMultiPolygonFromCells(cellIds) {
const {cells} = pack;
const polygons = cellIds.map(i => getCellPolygonCoordinates(cells.v[i]));
// polygons is an array of [ [ ring ] ] — wrap for MultiPolygon
return polygons;
}
function aggregatePopulationByCells(cellIds) {
// Follow editor logic: population lives in burgs; rural is accounted for via small burgs only
// Return values in absolute people, matching CSV exports
let ruralK = 0; // thousands-equivalent for rural (as tracked in states)
let urbanK = 0; // thousands for urban from burgs
for (const i of cellIds) {
const burgId = pack.cells.burg[i];
if (!burgId) continue;
const k = pack.burgs[burgId].population; // in thousands
// Mirror states stats split: <= 0.1k as rural, otherwise urban
if (k > 0.1) urbanK += k; else ruralK += k;
}
const rural = Math.round(ruralK * populationRate);
const urban = Math.round(urbanK * 1000 * urbanization);
return {rural, urban, total: rural + urban};
}
function sumAreaByCells(cellIds) {
const sum = cellIds.reduce((acc, i) => acc + (pack.cells.area[i] || 0), 0);
return getArea(sum);
}
function getCellsFor(type, id) {
const {cells} = pack;
switch (type) {
case "state":
return cells.i.filter(i => cells.h[i] >= 20 && cells.state[i] === id);
case "province":
return cells.i.filter(i => cells.h[i] >= 20 && cells.province[i] === id);
case "culture":
return cells.i.filter(i => cells.h[i] >= 20 && cells.culture[i] === id);
case "religion":
return cells.i.filter(i => cells.h[i] >= 20 && cells.religion[i] === id);
default:
return [];
}
}
function buildGeoJsonCultures() {
const metersPerPixel = getMetersPerPixel();
const features = pack.cultures
.filter(c => c.i && !c.removed)
.map(c => {
const cellIds = getCellsFor("culture", c.i);
if (!cellIds.length) return null;
const geometry = {type: "MultiPolygon", coordinates: buildMultiPolygonFromCells(cellIds)};
const {total} = aggregatePopulationByCells(cellIds);
const area = sumAreaByCells(cellIds);
const namesbase = nameBases[c.base]?.name;
const origins = (c.origins || []).filter(o => o).map(o => pack.cultures[o]?.name).filter(Boolean);
const properties = {
id: c.i,
name: c.name,
color: c.color,
cells: cellIds.length,
expansionism: c.expansionism,
type: c.type,
area,
population: rn(total),
namesbase: namesbase || "",
emblemsShape: c.emblemsShape || "",
origins
};
return {type: "Feature", geometry, properties};
})
.filter(Boolean);
const json = {
type: "FeatureCollection",
features,
metadata: {
crs: "Fantasy Map Cartesian (meters)",
mapName: mapName.value,
scale: {
distance: distanceScale,
unit: distanceUnitInput.value,
meters_per_pixel: metersPerPixel
}
}
};
return json;
}
function saveGeoJsonCultures() {
const json = buildGeoJsonCultures();
const fileName = getFileName("Cultures") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function buildGeoJsonReligions() {
const metersPerPixel = getMetersPerPixel();
const features = pack.religions
.filter(r => r.i && !r.removed)
.map(r => {
const cellIds = getCellsFor("religion", r.i);
if (!cellIds.length) return null;
const geometry = {type: "MultiPolygon", coordinates: buildMultiPolygonFromCells(cellIds)};
const {total} = aggregatePopulationByCells(cellIds);
const area = sumAreaByCells(cellIds);
const origins = (r.origins || []).filter(o => o).map(o => pack.religions[o]?.name).filter(Boolean);
const properties = {
id: r.i,
name: r.name,
color: r.color,
type: r.type,
form: r.form,
deity: r.deity || "",
area,
believers: rn(total),
origins,
potential: r.expansion,
expansionism: r.expansionism
};
return {type: "Feature", geometry, properties};
})
.filter(Boolean);
const json = {
type: "FeatureCollection",
features,
metadata: {
crs: "Fantasy Map Cartesian (meters)",
mapName: mapName.value,
scale: {
distance: distanceScale,
unit: distanceUnitInput.value,
meters_per_pixel: metersPerPixel
}
}
};
return json;
}
function saveGeoJsonReligions() {
const json = buildGeoJsonReligions();
const fileName = getFileName("Religions") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function buildGeoJsonStates() {
const metersPerPixel = getMetersPerPixel();
const features = pack.states
.filter(s => s.i && !s.removed)
.map(s => {
const cellIds = getCellsFor("state", s.i);
if (!cellIds.length) return null;
const geometry = {type: "MultiPolygon", coordinates: buildMultiPolygonFromCells(cellIds)};
const {rural, urban, total} = aggregatePopulationByCells(cellIds);
const area = sumAreaByCells(cellIds);
const properties = {
id: s.i,
name: s.name,
fullName: s.fullName || "",
form: s.form || "",
color: s.color,
capital: s.capital || 0,
culture: s.culture,
type: s.type,
expansionism: s.expansionism,
cells: cellIds.length,
burgs: s.burgs || 0,
area,
totalPopulation: total,
ruralPopulation: rural,
urbanPopulation: urban
};
return {type: "Feature", geometry, properties};
})
.filter(Boolean);
const json = {
type: "FeatureCollection",
features,
metadata: {
crs: "Fantasy Map Cartesian (meters)",
mapName: mapName.value,
scale: {
distance: distanceScale,
unit: distanceUnitInput.value,
meters_per_pixel: metersPerPixel
}
}
};
return json;
}
function saveGeoJsonStates() {
const json = buildGeoJsonStates();
const fileName = getFileName("States") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function buildGeoJsonProvinces() {
const metersPerPixel = getMetersPerPixel();
const features = pack.provinces
.filter(p => p.i && !p.removed)
.map(p => {
const cellIds = getCellsFor("province", p.i);
if (!cellIds.length) return null;
const geometry = {type: "MultiPolygon", coordinates: buildMultiPolygonFromCells(cellIds)};
const {rural, urban, total} = aggregatePopulationByCells(cellIds);
const area = sumAreaByCells(cellIds);
const properties = {
id: p.i,
name: p.name,
fullName: p.fullName || "",
form: p.form || "",
state: p.state,
color: p.color,
capital: p.burg || 0,
area,
totalPopulation: total,
ruralPopulation: rural,
urbanPopulation: urban,
burgs: (p.burgs && p.burgs.length) || 0
};
return {type: "Feature", geometry, properties};
})
.filter(Boolean);
const json = {
type: "FeatureCollection",
features,
metadata: {
crs: "Fantasy Map Cartesian (meters)",
mapName: mapName.value,
scale: {
distance: distanceScale,
unit: distanceUnitInput.value,
meters_per_pixel: metersPerPixel
}
}
};
return json;
}
function saveGeoJsonProvinces() {
const json = buildGeoJsonProvinces();
const fileName = getFileName("Provinces") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function buildGeoJsonZones() {
const metersPerPixel = getMetersPerPixel();
const features = (pack.zones || [])
.map(z => {
if (!z || z.hidden) return null;
const cellIds = (z.cells || []).filter(i => pack.cells.h[i] >= 20);
if (!cellIds.length) return null;
const geometry = {type: "MultiPolygon", coordinates: buildMultiPolygonFromCells(cellIds)};
const {total} = aggregatePopulationByCells(cellIds);
const area = sumAreaByCells(cellIds);
const properties = {
id: z.i,
color: z.color,
description: z.name,
type: z.type,
cells: cellIds.length,
area,
population: rn(total)
};
return {type: "Feature", geometry, properties};
})
.filter(Boolean);
const json = {
type: "FeatureCollection",
features,
metadata: {
crs: "Fantasy Map Cartesian (meters)",
mapName: mapName.value,
scale: {
distance: distanceScale,
unit: distanceUnitInput.value,
meters_per_pixel: metersPerPixel
}
}
};
return json;
}
function saveGeoJsonZones() {
const json = buildGeoJsonZones();
const fileName = getFileName("Zones") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
// Convenience: export all GeoJSONs into a single ZIP
async function saveAllGeoJson() {
await import("../../libs/jszip.min.js");
const zip = new window.JSZip();
const files = [
{name: getFileName("Cells") + ".geojson", json: buildGeoJsonCells()},
{name: getFileName("Routes") + ".geojson", json: buildGeoJsonRoutes()},
{name: getFileName("Rivers") + ".geojson", json: buildGeoJsonRivers()},
{name: getFileName("Markers") + ".geojson", json: buildGeoJsonMarkers()},
{name: getFileName("Burgs") + ".geojson", json: buildGeoJsonBurgs()},
{name: getFileName("Regiments") + ".geojson", json: buildGeoJsonRegiments()},
{name: getFileName("States") + ".geojson", json: buildGeoJsonStates()},
{name: getFileName("Provinces") + ".geojson", json: buildGeoJsonProvinces()},
{name: getFileName("Cultures") + ".geojson", json: buildGeoJsonCultures()},
{name: getFileName("Religions") + ".geojson", json: buildGeoJsonReligions()},
{name: getFileName("Zones") + ".geojson", json: buildGeoJsonZones()}
];
for (const f of files) {
try {
zip.file(f.name, JSON.stringify(f.json));
} catch (e) {
console.error("Failed to add", f.name, e);
}
}
const blob = await zip.generateAsync({type: "blob"});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = getFileName("GeoJSON") + ".zip";
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 5000);
}

39
qgis/README.md Normal file
View file

@ -0,0 +1,39 @@
# QGIS Styles for Fantasy Map GeoJSON
This folder contains ready-to-use QGIS (.qml) styles that match the Fantasy Map Generator exports.
How to use
- Import each GeoJSON into QGIS.
- Rightclick the layer → Properties → Symbology → Style → Load Style… → pick the matching .qml from `qgis/styles`.
- Set layer CRS to the custom Fantasy Map Cartesian CRS:
```
ENGCRS["Fantasy Map Cartesian (meters)",
EDATUM["Fantasy Map Datum"],
CS[Cartesian,2],
AXIS["easting (X)",east,
ORDER[1],
LENGTHUNIT["metre",1]],
AXIS["northing (Y)",north,
ORDER[2],
LENGTHUNIT["metre",1]]]
```
Included styles
- cells.qml: Graduated fill by `height` (water → mountains).
- rivers.qml: Blue lines, width driven by `width` attribute.
- routes.qml: Rule-based by `type`/`group` (sea routes dashed blue; roads brown; trails dashed, etc.).
- markers.qml: Simple point symbols, categorized by `type` where present.
- burgs.qml: Rule-based (capitals, ports, fortified, towns).
- regiments.qml: Square markers with label = `totalUnits`.
- states.qml: Polygon fill color from `color` attribute, labeled with `name`.
- provinces.qml: Polygon fill color from `color`, labeled with `name`.
- cultures.qml: Polygon fill color from `color`, labeled with `name`.
- religions.qml: Polygon fill color from `color`, labeled with `name`.
- zones.qml: Polygon fill color from `color`, labeled with `description`.
Notes
- Color fields for polygons use data-defined overrides; make sure your exported GeoJSON includes a `color` property (added by the new exporters).
- You can tweak line widths and colors per project scale.
- For cells, you can switch to a categorized style by `biome` if you prefer; this style uses elevation for a generic land scheme.
- For `markers.qml` font icons: ensure an emoji-capable font is installed and available to QGIS (e.g., `Noto Color Emoji` on Linux, `Segoe UI Emoji` on Windows, `Apple Color Emoji` on macOS). The style binds the Font Markers character directly to the `icon` attribute; the `icon` field should contain the desired glyph (e.g., 🏰, ⛏️). Some QGIS/Qt builds may render emoji as monochrome.

45
qgis/styles/burgs.qml Normal file
View file

@ -0,0 +1,45 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="RuleRenderer">
<rules>
<rule filter="\"capital\" = 1" symbol="0" label="Capital"/>
<rule filter="\"port\" = 1" symbol="1" label="Port town"/>
<rule filter="\"citadel\" = 1 OR \"walls\" = 1" symbol="2" label="Fortified"/>
<rule filter="ELSE" symbol="3" label="Town"/>
</rules>
<symbols>
<symbol type="marker" name="0">
<layer class="SimpleMarker">
<prop k="name" v="circle"/>
<prop k="color" v="30,30,30,255"/>
<prop k="outline_color" v="255,255,255,255"/>
<prop k="size" v="3"/>
</layer>
</symbol>
<symbol type="marker" name="1">
<layer class="SimpleMarker">
<prop k="name" v="circle"/>
<prop k="color" v="30,30,30,255"/>
<prop k="outline_color" v="0,137,202,255"/>
<prop k="size" v="2.5"/>
</layer>
</symbol>
<symbol type="marker" name="2">
<layer class="SimpleMarker">
<prop k="name" v="square"/>
<prop k="color" v="30,30,30,255"/>
<prop k="outline_color" v="140,90,50,255"/>
<prop k="size" v="2.3"/>
</layer>
</symbol>
<symbol type="marker" name="3">
<layer class="SimpleMarker">
<prop k="name" v="circle"/>
<prop k="color" v="30,30,30,220"/>
<prop k="outline_color" v="255,255,255,180"/>
<prop k="size" v="2"/>
</layer>
</symbol>
</symbols>
</renderer-v2>
</qgis>

49
qgis/styles/cells.qml Normal file
View file

@ -0,0 +1,49 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="graduatedSymbol" attr="height" graduatedMethod="GraduatedColor" symbollevels="0">
<ranges>
<range symbol="0" lower="0" upper="20" label="Water (h &lt; 20)"/>
<range symbol="1" lower="20" upper="40" label="Lowlands (20-40)"/>
<range symbol="2" lower="40" upper="60" label="Hills (40-60)"/>
<range symbol="3" lower="60" upper="80" label="Highlands (60-80)"/>
<range symbol="4" lower="80" upper="200" label="Mountains (80+)"/>
</ranges>
<symbols>
<symbol type="fill" name="0">
<layer class="SimpleFill">
<prop k="color" v="180,210,243,255"/>
<prop k="outline_color" v="120,120,120,100"/>
<prop k="outline_width" v="0.1"/>
</layer>
</symbol>
<symbol type="fill" name="1">
<layer class="SimpleFill">
<prop k="color" v="196,230,188,255"/>
<prop k="outline_color" v="120,120,120,60"/>
<prop k="outline_width" v="0.1"/>
</layer>
</symbol>
<symbol type="fill" name="2">
<layer class="SimpleFill">
<prop k="color" v="161,207,148,255"/>
<prop k="outline_color" v="120,120,120,60"/>
<prop k="outline_width" v="0.1"/>
</layer>
</symbol>
<symbol type="fill" name="3">
<layer class="SimpleFill">
<prop k="color" v="196,183,151,255"/>
<prop k="outline_color" v="120,120,120,80"/>
<prop k="outline_width" v="0.1"/>
</layer>
</symbol>
<symbol type="fill" name="4">
<layer class="SimpleFill">
<prop k="color" v="180,170,160,255"/>
<prop k="outline_color" v="120,120,120,120"/>
<prop k="outline_width" v="0.1"/>
</layer>
</symbol>
</symbols>
</renderer-v2>
</qgis>

36
qgis/styles/cultures.qml Normal file
View file

@ -0,0 +1,36 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="singleSymbol">
<symbols>
<symbol type="fill" name="0">
<layer class="SimpleFill">
<prop k="color" v="220,220,220,160"/>
<prop k="outline_color" v="60,60,60,180"/>
<prop k="outline_width" v="0.4"/>
<data_defined_properties>
<Option type="Map">
<Option name="properties" type="Map">
<Option name="fillColor" type="Map">
<Option name="active" type="bool" value="true"/>
<Option name="expression" type="QString" value="attribute('color')"/>
<Option name="type" type="int" value="3"/>
</Option>
</Option>
<Option name="type" type="int" value="2"/>
</Option>
</data_defined_properties>
</layer>
</symbol>
</symbols>
</renderer-v2>
<labeling type="rule-based">
<rules>
<rule>
<settings>
<text-style field="name" fontSize="9" namedStyle="Normal" isExpression="0"/>
<text-buffer bufferDraw="1" bufferColor="255,255,255,255" bufferSize="1"/>
</settings>
</rule>
</rules>
</labeling>
</qgis>

56
qgis/styles/markers.qml Normal file
View file

@ -0,0 +1,56 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="RuleRenderer">
<rules>
<rule filter="regexp_match(&quot;icon&quot;, '^https?://|^data:')" symbol="0" label="Image icon"/>
<rule filter="NOT regexp_match(&quot;icon&quot;, '^https?://|^data:')" symbol="1" label="Emoji/character icon"/>
</rules>
<symbols>
<!-- Image-based icons (URL or data URI). QGIS will try to load the image path from the 'icon' attribute. -->
<symbol type="marker" name="0">
<layer class="RasterImageMarker">
<prop k="size" v="3"/>
<data_defined_properties>
<Option type="Map">
<Option name="properties" type="Map">
<Option name="imageFile" type="Map">
<Option name="active" type="bool" value="true"/>
<Option name="expression" type="QString" value="attribute('icon')"/>
<Option name="type" type="int" value="3"/>
</Option>
<Option name="size" type="Map">
<Option name="active" type="bool" value="true"/>
<Option name="expression" type="QString" value="coalesce(attribute('size'), 3)"/>
<Option name="type" type="int" value="3"/>
</Option>
</Option>
<Option name="type" type="int" value="2"/>
</Option>
</data_defined_properties>
</layer>
</symbol>
<!-- Emoji/character-based icons: use label rendering for full emoji support.
Keep symbol invisible to avoid double-drawing; labels will show the emoji. -->
<symbol type="marker" name="1">
<layer class="SimpleMarker">
<prop k="name" v="circle"/>
<prop k="size" v="0"/>
<prop k="color" v="0,0,0,0"/>
<prop k="outline_color" v="0,0,0,0"/>
</layer>
</symbol>
</symbols>
</renderer-v2>
<!-- Rule-based labels to render emojis from the 'icon' attribute.
This path supports multi-codepoint and non-BMP emoji (e.g., 💧). -->
<labeling type="rule-based">
<rules>
<rule filter="NOT regexp_match(&quot;icon&quot;, '^https?://|^data:')">
<settings>
<text-style field="icon" fontFamily="Noto Color Emoji" fontSize="9" isExpression="0"/>
<text-buffer bufferDraw="0"/>
</settings>
</rule>
</rules>
</labeling>
</qgis>

36
qgis/styles/provinces.qml Normal file
View file

@ -0,0 +1,36 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="singleSymbol">
<symbols>
<symbol type="fill" name="0">
<layer class="SimpleFill">
<prop k="color" v="230,230,230,120"/>
<prop k="outline_color" v="80,80,80,200"/>
<prop k="outline_width" v="0.4"/>
<data_defined_properties>
<Option type="Map">
<Option name="properties" type="Map">
<Option name="fillColor" type="Map">
<Option name="active" type="bool" value="true"/>
<Option name="expression" type="QString" value="attribute('color')"/>
<Option name="type" type="int" value="3"/>
</Option>
</Option>
<Option name="type" type="int" value="2"/>
</Option>
</data_defined_properties>
</layer>
</symbol>
</symbols>
</renderer-v2>
<labeling type="rule-based">
<rules>
<rule>
<settings>
<text-style field="name" fontSize="8" namedStyle="Normal" isExpression="0"/>
<text-buffer bufferDraw="1" bufferColor="255,255,255,255" bufferSize="1"/>
</settings>
</rule>
</rules>
</labeling>
</qgis>

25
qgis/styles/regiments.qml Normal file
View file

@ -0,0 +1,25 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="singleSymbol">
<symbols>
<symbol type="marker" name="0">
<layer class="SimpleMarker">
<prop k="name" v="square"/>
<prop k="color" v="200,0,0,220"/>
<prop k="outline_color" v="0,0,0,255"/>
<prop k="size" v="3"/>
</layer>
</symbol>
</symbols>
</renderer-v2>
<labeling type="rule-based">
<rules>
<rule>
<settings>
<text-style field="totalUnits" fontSize="8" namedStyle="Normal" isExpression="0"/>
<text-buffer bufferDraw="1" bufferColor="255,255,255,255" bufferSize="1"/>
</settings>
</rule>
</rules>
</labeling>
</qgis>

36
qgis/styles/religions.qml Normal file
View file

@ -0,0 +1,36 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="singleSymbol">
<symbols>
<symbol type="fill" name="0">
<layer class="SimpleFill">
<prop k="color" v="220,220,220,140"/>
<prop k="outline_color" v="60,60,60,200"/>
<prop k="outline_width" v="0.3"/>
<data_defined_properties>
<Option type="Map">
<Option name="properties" type="Map">
<Option name="fillColor" type="Map">
<Option name="active" type="bool" value="true"/>
<Option name="expression" type="QString" value="attribute('color')"/>
<Option name="type" type="int" value="3"/>
</Option>
</Option>
<Option name="type" type="int" value="2"/>
</Option>
</data_defined_properties>
</layer>
</symbol>
</symbols>
</renderer-v2>
<labeling type="rule-based">
<rules>
<rule>
<settings>
<text-style field="name" fontSize="9" namedStyle="Normal" isExpression="0"/>
<text-buffer bufferDraw="1" bufferColor="255,255,255,255" bufferSize="1"/>
</settings>
</rule>
</rules>
</labeling>
</qgis>

26
qgis/styles/rivers.qml Normal file
View file

@ -0,0 +1,26 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="singleSymbol">
<symbols>
<symbol type="line" name="0">
<layer class="SimpleLine">
<prop k="line_color" v="0,137,202,255"/>
<prop k="line_width" v="0.6"/>
<prop k="capstyle" v="round"/>
<prop k="joinstyle" v="round"/>
<data_defined_properties>
<Option type="Map">
<Option name="properties" type="Map">
<Option name="width" type="Map">
<Option name="active" type="bool" value="true"/>
<Option name="expression" type="QString" value="coalesce(&quot;width&quot;, 1)"/>
<Option name="type" type="int" value="3"/>
</Option>
</Option>
<Option name="type" type="int" value="2"/>
</Option>
</data_defined_properties>
</layer>
</symbol>
</symbols>
</renderer-v2>
</qgis>

68
qgis/styles/routes.qml Normal file
View file

@ -0,0 +1,68 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="RuleRenderer">
<rules>
<rule filter="&quot;type&quot; IN ('majorSea','regional') OR &quot;group&quot; = 'searoutes'" symbol="0" label="Sea routes"/>
<rule filter="&quot;type&quot; = 'royal' OR (&quot;group&quot; = 'roads' AND coalesce(&quot;type&quot;,'')='')" symbol="1" label="Royal road"/>
<rule filter="&quot;type&quot; = 'market'" symbol="2" label="Market road"/>
<rule filter="&quot;type&quot; = 'local' OR &quot;group&quot; = 'secondary'" symbol="3" label="Local road"/>
<rule filter="&quot;type&quot; = 'footpath' OR &quot;group&quot; = 'trails'" symbol="4" label="Footpath"/>
<rule else="1" symbol="5" label="Other"/>
</rules>
<symbols>
<symbol type="line" name="0">
<layer class="SimpleLine">
<prop k="line_color" v="0,137,202,200"/>
<prop k="line_width" v="0.8"/>
<prop k="customdash" v="6;2"/>
<prop k="use_custom_dash" v="1"/>
<prop k="capstyle" v="round"/>
<prop k="joinstyle" v="round"/>
</layer>
</symbol>
<symbol type="line" name="1">
<layer class="SimpleLine">
<prop k="line_color" v="159,81,34,255"/>
<prop k="line_width" v="1.2"/>
<prop k="capstyle" v="round"/>
<prop k="joinstyle" v="round"/>
</layer>
</symbol>
<symbol type="line" name="2">
<layer class="SimpleLine">
<prop k="line_color" v="159,81,34,220"/>
<prop k="line_width" v="1.0"/>
<prop k="customdash" v="4;2"/>
<prop k="use_custom_dash" v="1"/>
<prop k="capstyle" v="round"/>
<prop k="joinstyle" v="round"/>
</layer>
</symbol>
<symbol type="line" name="3">
<layer class="SimpleLine">
<prop k="line_color" v="159,81,34,180"/>
<prop k="line_width" v="0.8"/>
<prop k="customdash" v="2;2"/>
<prop k="use_custom_dash" v="1"/>
<prop k="capstyle" v="round"/>
<prop k="joinstyle" v="round"/>
</layer>
</symbol>
<symbol type="line" name="4">
<layer class="SimpleLine">
<prop k="line_color" v="120,120,120,200"/>
<prop k="line_width" v="0.5"/>
<prop k="customdash" v="1;2"/>
<prop k="use_custom_dash" v="1"/>
<prop k="capstyle" v="round"/>
<prop k="joinstyle" v="round"/>
</layer>
</symbol>
<symbol type="line" name="5">
<layer class="SimpleLine">
<prop k="line_color" v="0,0,0,150"/>
<prop k="line_width" v="0.6"/>
</layer>
</symbol>
</symbols>
</renderer-v2>
</qgis>

36
qgis/styles/states.qml Normal file
View file

@ -0,0 +1,36 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="singleSymbol">
<symbols>
<symbol type="fill" name="0">
<layer class="SimpleFill">
<prop k="color" v="220,220,220,160"/>
<prop k="outline_color" v="60,60,60,220"/>
<prop k="outline_width" v="0.6"/>
<data_defined_properties>
<Option type="Map">
<Option name="properties" type="Map">
<Option name="fillColor" type="Map">
<Option name="active" type="bool" value="true"/>
<Option name="expression" type="QString" value="attribute('color')"/>
<Option name="type" type="int" value="3"/>
</Option>
</Option>
<Option name="type" type="int" value="2"/>
</Option>
</data_defined_properties>
</layer>
</symbol>
</symbols>
</renderer-v2>
<labeling type="rule-based">
<rules>
<rule>
<settings>
<text-style field="name" fontSize="10" namedStyle="Bold" isExpression="0"/>
<text-buffer bufferDraw="1" bufferColor="255,255,255,255" bufferSize="1.5"/>
</settings>
</rule>
</rules>
</labeling>
</qgis>

36
qgis/styles/zones.qml Normal file
View file

@ -0,0 +1,36 @@
<qgis styleCategories="Symbology" version="3.28.0">
<renderer-v2 type="singleSymbol">
<symbols>
<symbol type="fill" name="0">
<layer class="SimpleFill">
<prop k="color" v="255,255,0,80"/>
<prop k="outline_color" v="0,0,0,180"/>
<prop k="outline_width" v="0.3"/>
<data_defined_properties>
<Option type="Map">
<Option name="properties" type="Map">
<Option name="fillColor" type="Map">
<Option name="active" type="bool" value="true"/>
<Option name="expression" type="QString" value="attribute('color')"/>
<Option name="type" type="int" value="3"/>
</Option>
</Option>
<Option name="type" type="int" value="2"/>
</Option>
</data_defined_properties>
</layer>
</symbol>
</symbols>
</renderer-v2>
<labeling type="rule-based">
<rules>
<rule>
<settings>
<text-style field="description" fontSize="8" namedStyle="Italic" isExpression="0"/>
<text-buffer bufferDraw="1" bufferColor="255,255,255,255" bufferSize="0.8"/>
</settings>
</rule>
</rules>
</labeling>
</qgis>

View file

@ -1,3 +1,3 @@
start chrome.exe http://localhost:8000/ start chrome.exe http://localhost:9000/
@echo off @echo off
python -m http.server 8000 python -m http.server 9000