Adding zone export to GeoJSON, added versioning and hash updates

This commit is contained in:
Joe McMahon 2026-02-11 13:46:50 -05:00
parent 1100c7c53b
commit 4fa584a965
5 changed files with 497 additions and 8 deletions

6
package-lock.json generated
View file

@ -1353,7 +1353,6 @@
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -1394,7 +1393,6 @@
"integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/browser": "4.0.18",
"@vitest/mocker": "4.0.18",
@ -1876,7 +1874,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -2163,7 +2160,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2475,7 +2471,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -2551,7 +2546,6 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",

View file

@ -574,3 +574,116 @@ function saveGeoJsonMarkers() {
const fileName = getFileName("Markers") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}
function saveGeoJsonZones() {
const {zones, cells, vertices} = pack;
const json = {type: "FeatureCollection", features: []};
// Helper function to convert zone cells to polygon coordinates
function getZonePolygonCoordinates(zoneCells) {
// Create a set of cells in this zone for quick lookup
const cellsInZone = new Set(zoneCells);
const ofSameType = (cellId) => cellsInZone.has(cellId);
const ofDifferentType = (cellId) => !cellsInZone.has(cellId);
const checkedCells = new Set();
const coordinates = [];
// Find boundary vertices by tracing the zone boundary
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;
// 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)) {
startingVertex = vertexId;
break;
}
}
if (startingVertex === null) continue;
// Trace the boundary by connecting vertices
const vertexChain = [];
let current = startingVertex;
let previous = null;
const maxIterations = vertices.c.length;
for (let i = 0; i < maxIterations; i++) {
vertexChain.push(current);
// Mark cells adjacent to this vertex as checked
const adjacentCells = vertices.c[current];
adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c));
// Find the next vertex along the boundary
const [c1, c2, c3] = adjacentCells.map(ofSameType);
const [v1, v2, v3] = vertices.v[current];
let next = null;
if (v1 !== previous && c1 !== c2) next = v1;
else if (v2 !== previous && c2 !== c3) next = v2;
else if (v3 !== previous && c1 !== c3) next = v3;
if (next === null || next === current) break;
if (next === startingVertex) break; // Completed the ring
previous = current;
current = next;
}
// Convert vertex chain to coordinates
for (const vertexId of vertexChain) {
const [x, y] = vertices.p[vertexId];
coordinates.push(getCoordinates(x, y, 4));
}
}
// Close the polygon ring (first coordinate = last coordinate)
if (coordinates.length > 0) {
coordinates.push(coordinates[0]);
}
return [coordinates];
}
// Filter and process zones
zones.forEach(zone => {
// Exclude hidden zones and zones with no cells
if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
const coordinates = getZonePolygonCoordinates(zone.cells);
// Only add feature if we have valid coordinates
// GeoJSON LinearRing requires at least 4 positions (with first == last)
if (coordinates[0].length >= 4) {
const properties = {
id: zone.i,
name: zone.name,
type: zone.type,
color: zone.color,
cells: zone.cells
};
const feature = {
type: "Feature",
geometry: {type: "Polygon", coordinates},
properties
};
json.features.push(feature);
}
});
const fileName = getFileName("Zones") + ".geojson";
downloadFile(JSON.stringify(json), fileName, "application/json");
}

View file

@ -13,7 +13,7 @@
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
*/
const VERSION = "1.112.1";
const VERSION = "1.112.2";
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
{
@ -49,6 +49,7 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o
<li>New routes generation algorithm</li>
<li>Routes overview tool</li>
<li>Configurable longitude</li>
<li>Export zones to GeoJSON</li>
</ul>
<p>Join our <a href="${discord}" target="_blank">Discord server</a> and <a href="${reddit}" target="_blank">Reddit community</a> to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.</p>

View file

@ -6152,6 +6152,7 @@
<button onclick="saveGeoJsonRoutes()" data-tip="Download routes data in GeoJSON format">routes</button>
<button onclick="saveGeoJsonRivers()" data-tip="Download rivers data in GeoJSON format">rivers</button>
<button onclick="saveGeoJsonMarkers()" data-tip="Download markers data in GeoJSON format">markers</button>
<button onclick="saveGeoJsonZones()" data-tip="Download zones data in GeoJSON format">zones</button>
</div>
<p>
GeoJSON format is used in GIS tools such as QGIS. Check out
@ -8557,6 +8558,6 @@
<script defer src="modules/io/save.js?v=1.111.0"></script>
<script defer src="modules/io/load.js?v=1.111.0"></script>
<script defer src="modules/io/cloud.js?v=1.106.0"></script>
<script defer src="modules/io/export.js?v=1.108.13"></script>
<script defer src="modules/io/export.js?v=1.112.2"></script>
</body>
</html>

View file

@ -0,0 +1,380 @@
import { test, expect } from "@playwright/test";
test.describe("Zone Export", () => {
test.beforeEach(async ({ context, page }) => {
await context.clearCookies();
await page.goto("/");
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
// Navigate with seed parameter and wait for full load
await page.goto("/?seed=test-zones-export&width=1280&height=720");
// Wait for map generation to complete
await page.waitForFunction(
() => (window as any).mapId !== undefined,
{ timeout: 60000 }
);
// Additional wait for any rendering/animations to settle
await page.waitForTimeout(500);
});
// Helper function to create a test zone programmatically
async function createTestZone(page: any): Promise<number> {
return await page.evaluate(() => {
const { cells, zones } = (window as any).pack;
// Find 10-20 land cells (height >= 20)
const landCells: number[] = [];
for (let i = 1; i < cells.i.length && landCells.length < 20; i++) {
const isLand = cells.h[i] >= 20;
if (isLand) {
landCells.push(i);
}
}
if (landCells.length < 10) {
throw new Error(`Not enough land cells found: ${landCells.length}`);
}
// Take exactly 10-20 cells
const zoneCells = landCells.slice(0, Math.min(20, landCells.length));
// Generate unique zone ID
const zoneId = zones.length;
// Create zone object
const zone = {
i: zoneId,
name: "Test Export Zone",
type: "Test",
color: "#FF0000",
cells: zoneCells,
};
// Add zone to pack.zones array
zones.push(zone);
return zoneId;
});
}
// Helper function to export zones to GeoJSON without file download
async function exportZonesToGeoJson(page: any): Promise<any> {
return await page.evaluate(() => {
const { zones, cells, vertices } = (window as any).pack;
const json = { type: "FeatureCollection", features: [] as any[] };
// Use the global getCoordinates function from window
const getCoordinates = (window as any).getCoordinates;
// Helper function to convert zone cells to polygon coordinates
function getZonePolygonCoordinates(zoneCells: number[]) {
const cellsInZone = new Set(zoneCells);
const ofSameType = (cellId: number) => cellsInZone.has(cellId);
const ofDifferentType = (cellId: number) => !cellsInZone.has(cellId);
const checkedCells = new Set<number>();
const coordinates: [number, number][] = [];
// Find boundary vertices by tracing the zone boundary
for (const cellId of zoneCells) {
if (checkedCells.has(cellId)) continue;
// Check if this cell is on the boundary
const neighbors = cells.c[cellId];
const onBorder = neighbors.some(ofDifferentType);
if (!onBorder) continue;
// Find a starting vertex that's on the boundary
const cellVertices = cells.v[cellId];
let startingVertex: number | null = null;
for (const vertexId of cellVertices) {
const vertexCells = vertices.c[vertexId];
if (vertexCells.some(ofDifferentType)) {
startingVertex = vertexId;
break;
}
}
if (startingVertex === null) continue;
// Trace the boundary by connecting vertices
const vertexChain: number[] = [];
let current = startingVertex;
let previous: number | null = null;
const maxIterations = vertices.c.length;
for (let i = 0; i < maxIterations; i++) {
vertexChain.push(current);
// Mark cells adjacent to this vertex as checked
const adjacentCells = vertices.c[current];
adjacentCells.filter(ofSameType).forEach((c: number) => checkedCells.add(c));
// Find the next vertex along the boundary
const [c1, c2, c3] = adjacentCells.map(ofSameType);
const [v1, v2, v3] = vertices.v[current];
let next: number | null = null;
if (v1 !== previous && c1 !== c2) next = v1;
else if (v2 !== previous && c2 !== c3) next = v2;
else if (v3 !== previous && c1 !== c3) next = v3;
if (next === null || next === current) break;
if (next === startingVertex) break; // Completed the ring
previous = current;
current = next;
}
// Convert vertex chain to coordinates
for (const vertexId of vertexChain) {
const [x, y] = vertices.p[vertexId];
coordinates.push(getCoordinates(x, y, 4));
}
}
// Close the polygon ring (first coordinate = last coordinate)
if (coordinates.length > 0) {
coordinates.push(coordinates[0]);
}
return [coordinates];
}
// Filter and process zones
zones.forEach((zone: any) => {
// Exclude hidden zones and zones with no cells
if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
const coordinates = getZonePolygonCoordinates(zone.cells);
// Only add feature if we have valid coordinates
// GeoJSON LinearRing requires at least 4 positions (with first == last)
if (coordinates[0].length >= 4) {
const properties = {
id: zone.i,
name: zone.name,
type: zone.type,
color: zone.color,
cells: zone.cells,
};
const feature = {
type: "Feature",
geometry: { type: "Polygon", coordinates },
properties,
};
json.features.push(feature);
}
});
return json;
});
}
test("should export zone with valid GeoJSON root structure", async ({ page }) => {
// Create a test zone
const zoneId = await createTestZone(page);
expect(zoneId).toBeGreaterThanOrEqual(0);
// Export zones to GeoJSON
const geoJson = await exportZonesToGeoJson(page);
// Validate root GeoJSON structure (Task 5.1)
expect(geoJson).toBeDefined();
expect(geoJson).toHaveProperty("type");
expect(geoJson.type).toBe("FeatureCollection");
expect(geoJson).toHaveProperty("features");
expect(Array.isArray(geoJson.features)).toBe(true);
expect(geoJson.features.length).toBeGreaterThan(0);
// Verify the test zone is in the export
const testZoneFeature = geoJson.features.find((f: any) => f.properties.id === zoneId);
expect(testZoneFeature).toBeDefined();
expect(testZoneFeature.properties.name).toBe("Test Export Zone");
// Validate Feature structure (Task 5.2)
expect(testZoneFeature).toHaveProperty("type");
expect(testZoneFeature.type).toBe("Feature");
expect(testZoneFeature).toHaveProperty("geometry");
expect(testZoneFeature.geometry).toBeDefined();
expect(typeof testZoneFeature.geometry).toBe("object");
expect(testZoneFeature.geometry).toHaveProperty("type");
expect(testZoneFeature.geometry.type).toBe("Polygon");
expect(testZoneFeature.geometry).toHaveProperty("coordinates");
expect(Array.isArray(testZoneFeature.geometry.coordinates)).toBe(true);
expect(testZoneFeature).toHaveProperty("properties");
expect(testZoneFeature.properties).toBeDefined();
expect(typeof testZoneFeature.properties).toBe("object");
// Task 6.1: Validate zone property mapping
// Get the test zone from pack.zones in browser context
const testZone = await page.evaluate((id: number) => {
const { zones } = (window as any).pack;
return zones.find((z: any) => z.i === id);
}, zoneId);
expect(testZone).toBeDefined();
// Assert feature.properties match zone properties
expect(testZoneFeature.properties.id).toBe(testZone.i);
expect(testZoneFeature.properties.name).toBe(testZone.name);
expect(testZoneFeature.properties.type).toBe(testZone.type);
expect(testZoneFeature.properties.color).toBe(testZone.color);
expect(testZoneFeature.properties.cells).toEqual(testZone.cells);
// Task 7.1: Validate coordinate array structure
const { coordinates } = testZoneFeature.geometry;
// Assert geometry.coordinates is an array
expect(Array.isArray(coordinates)).toBe(true);
// Assert outer array has length 1 (single LinearRing)
expect(coordinates.length).toBe(1);
// Assert LinearRing is an array
const linearRing = coordinates[0];
expect(Array.isArray(linearRing)).toBe(true);
// Assert each position in LinearRing is an array of 2 numbers
for (const position of linearRing) {
expect(Array.isArray(position)).toBe(true);
expect(position.length).toBe(2);
expect(typeof position[0]).toBe("number");
expect(typeof position[1]).toBe("number");
}
// Task 7.2: Validate LinearRing validity
// Assert LinearRing has at least 4 positions
expect(linearRing.length).toBeGreaterThanOrEqual(4);
// Assert first position equals last position (closed ring)
const firstPosition = linearRing[0];
const lastPosition = linearRing[linearRing.length - 1];
expect(firstPosition[0]).toBe(lastPosition[0]);
expect(firstPosition[1]).toBe(lastPosition[1]);
// Assert all positions are valid [longitude, latitude] pairs
for (const position of linearRing) {
// Longitude should be between -180 and 180
expect(position[0]).toBeGreaterThanOrEqual(-180);
expect(position[0]).toBeLessThanOrEqual(180);
// Latitude should be between -90 and 90
expect(position[1]).toBeGreaterThanOrEqual(-90);
expect(position[1]).toBeLessThanOrEqual(90);
}
});
test("should exclude hidden zones from GeoJSON export", async ({ page }) => {
// Create a regular test zone
const regularZoneId = await createTestZone(page);
expect(regularZoneId).toBeGreaterThanOrEqual(0);
// Create a hidden zone
const hiddenZoneId = await page.evaluate(() => {
const { cells, zones } = (window as any).pack;
// Find 10-20 land cells (height >= 20)
const landCells: number[] = [];
for (let i = 1; i < cells.i.length && landCells.length < 20; i++) {
const isLand = cells.h[i] >= 20;
if (isLand && !zones.some((z: any) => z.cells && z.cells.includes(i))) {
landCells.push(i);
}
}
if (landCells.length < 10) {
throw new Error(`Not enough land cells found: ${landCells.length}`);
}
// Take exactly 10-20 cells
const zoneCells = landCells.slice(0, Math.min(20, landCells.length));
// Generate unique zone ID
const zoneId = zones.length;
// Create hidden zone object
const zone = {
i: zoneId,
name: "Hidden Test Zone",
type: "Test",
color: "#00FF00",
cells: zoneCells,
hidden: true, // Mark as hidden
};
// Add zone to pack.zones array
zones.push(zone);
return zoneId;
});
expect(hiddenZoneId).toBeGreaterThanOrEqual(0);
// Export zones to GeoJSON
const geoJson = await exportZonesToGeoJson(page);
// Validate that the regular zone is in the export
const regularZoneFeature = geoJson.features.find((f: any) => f.properties.id === regularZoneId);
expect(regularZoneFeature).toBeDefined();
expect(regularZoneFeature.properties.name).toBe("Test Export Zone");
// Validate that the hidden zone is NOT in the export
const hiddenZoneFeature = geoJson.features.find((f: any) => f.properties.id === hiddenZoneId);
expect(hiddenZoneFeature).toBeUndefined();
});
test("should exclude zones with empty cells array from GeoJSON export", async ({ page }) => {
// Create a regular test zone
const regularZoneId = await createTestZone(page);
expect(regularZoneId).toBeGreaterThanOrEqual(0);
// Create a zone with empty cells array
const emptyZoneId = await page.evaluate(() => {
const { zones } = (window as any).pack;
// Generate unique zone ID
const zoneId = zones.length;
// Create zone object with empty cells array
const zone = {
i: zoneId,
name: "Empty Test Zone",
type: "Test",
color: "#0000FF",
cells: [], // Empty cells array
};
// Add zone to pack.zones array
zones.push(zone);
return zoneId;
});
expect(emptyZoneId).toBeGreaterThanOrEqual(0);
// Export zones to GeoJSON
const geoJson = await exportZonesToGeoJson(page);
// Validate that the regular zone is in the export
const regularZoneFeature = geoJson.features.find((f: any) => f.properties.id === regularZoneId);
expect(regularZoneFeature).toBeDefined();
expect(regularZoneFeature.properties.name).toBe("Test Export Zone");
// Validate that the empty zone is NOT in the export
const emptyZoneFeature = geoJson.features.find((f: any) => f.properties.id === emptyZoneId);
expect(emptyZoneFeature).toBeUndefined();
});
});