From 0ff0311a98262488d0d7d677cf9674be77749686 Mon Sep 17 00:00:00 2001 From: Joe McMahon Date: Thu, 19 Feb 2026 16:29:06 -0500 Subject: [PATCH] Adding zone export to GeoJSON, added versioning and hash updates (#1312) * Adding zone export to GeoJSON, added versioning and hash updates * Fixing copilot findings and test not using production code call * Correcting collection of disconnected features --------- Co-authored-by: Joe McMahon Co-authored-by: Azgaar --- package-lock.json | 6 - public/modules/io/export.js | 118 +++++++++++ public/versioning.js | 3 +- src/index.html | 3 +- tests/e2e/zones-export.spec.ts | 349 +++++++++++++++++++++++++++++++++ 5 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/zones-export.spec.ts diff --git a/package-lock.json b/package-lock.json index 53616e24..25a3c267 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/public/modules/io/export.js b/public/modules/io/export.js index 51164c73..e2ab8263 100644 --- a/public/modules/io/export.js +++ b/public/modules/io/export.js @@ -574,3 +574,121 @@ 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 + // 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 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)) { + startingVertex = vertexId; + 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 + }); + + 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; + } + + // 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 rings = getZonePolygonCoordinates(zone.cells); + + // Skip if no valid rings were generated + if (rings.length === 0) return; + + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + // If there's only one ring, use Polygon geometry + if (rings.length === 1) { + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates: rings}, + properties + }; + json.features.push(feature); + } else { + // Multiple disconnected components: use MultiPolygon + // Each component is wrapped in its own array + const multiPolygonCoordinates = rings.map(ring => [ring]); + const feature = { + type: "Feature", + geometry: {type: "MultiPolygon", coordinates: multiPolygonCoordinates}, + properties + }; + json.features.push(feature); + } + }); + + const fileName = getFileName("Zones") + ".geojson"; + downloadFile(JSON.stringify(json), fileName, "application/json"); +} diff --git a/public/versioning.js b/public/versioning.js index 99f6a730..7861158d 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -13,7 +13,7 @@ * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.112.3"; +const VERSION = "1.112.4"; 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
  • New routes generation algorithm
  • Routes overview tool
  • Configurable longitude
  • +
  • Export zones to GeoJSON
  • Join our Discord server and Reddit community to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.

    diff --git a/src/index.html b/src/index.html index 8e73451d..9414a182 100644 --- a/src/index.html +++ b/src/index.html @@ -6152,6 +6152,7 @@ +

    GeoJSON format is used in GIS tools such as QGIS. Check out @@ -8553,6 +8554,6 @@ - + diff --git a/tests/e2e/zones-export.spec.ts b/tests/e2e/zones-export.spec.ts new file mode 100644 index 00000000..b2a8356d --- /dev/null +++ b/tests/e2e/zones-export.spec.ts @@ -0,0 +1,349 @@ +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 + // Uses BFS to select a contiguous set of land cells for stable, representative testing + async function createTestZone(page: any): Promise { + return await page.evaluate(() => { + const { cells, zones } = (window as any).pack; + + // Find a starting land cell (height >= 20) + const totalCells = cells.i.length; + let startCell = -1; + for (let i = 1; i < totalCells; i++) { + if (cells.h[i] >= 20) { + startCell = i; + break; + } + } + + if (startCell === -1) { + throw new Error("No land cells found to create a test zone"); + } + + // Use BFS to select a contiguous set of 10-20 land cells + const zoneCells: number[] = []; + const visited = new Set(); + const queue: number[] = []; + + visited.add(startCell); + queue.push(startCell); + + while (queue.length > 0 && zoneCells.length < 20) { + const current = queue.shift() as number; + + // Only include land cells in the zone + if (cells.h[current] >= 20) { + zoneCells.push(current); + } + + // Explore neighbors + const neighbors: number[] = cells.c[current] || []; + for (const neighbor of neighbors) { + if (neighbor && !visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + + if (zoneCells.length < 10) { + throw new Error(`Not enough contiguous land cells found: ${zoneCells.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 + // This calls the production code from public/modules/io/export.js + async function exportZonesToGeoJson(page: any): Promise { + return await page.evaluate(() => { + // Mock downloadFile to capture the JSON instead of downloading + const originalDownloadFile = (window as any).downloadFile; + let capturedJson: any = null; + + (window as any).downloadFile = (data: string) => { + capturedJson = JSON.parse(data); + }; + + // Call the production code + (window as any).saveGeoJsonZones(); + + // Restore original downloadFile + (window as any).downloadFile = originalDownloadFile; + + return capturedJson; + }); + } + + 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"); + // Note: Geometry type can be "Polygon" (single component) or "MultiPolygon" (multiple disconnected components) + // For this test with contiguous BFS-selected cells, we expect "Polygon" + 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 coordinates array is not empty + expect(coordinates.length).toBeGreaterThan(0); + + // Validate each LinearRing in the coordinates array + // Note: Zones can have multiple rings (holes) or be MultiPolygon (disconnected components) + for (const linearRing of coordinates) { + // Assert LinearRing is an array + expect(Array.isArray(linearRing)).toBe(true); + + // 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 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"); + + // Assert all positions are valid [longitude, latitude] pairs + // 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 a starting land cell that's not already in a zone + const totalCells = cells.i.length; + let startCell = -1; + for (let i = 1; i < totalCells; i++) { + const isLand = cells.h[i] >= 20; + const notInZone = !zones.some((z: any) => z.cells && z.cells.includes(i)); + if (isLand && notInZone) { + startCell = i; + break; + } + } + + if (startCell === -1) { + throw new Error("No available land cells found for hidden zone"); + } + + // Use BFS to select a contiguous set of 10-20 land cells + const zoneCells: number[] = []; + const visited = new Set(); + const queue: number[] = []; + + visited.add(startCell); + queue.push(startCell); + + while (queue.length > 0 && zoneCells.length < 20) { + const current = queue.shift() as number; + + // Only include land cells not already in a zone + const isLand = cells.h[current] >= 20; + const notInZone = !zones.some((z: any) => z.cells && z.cells.includes(current)); + if (isLand && notInZone) { + zoneCells.push(current); + } + + // Explore neighbors + const neighbors: number[] = cells.c[current] || []; + for (const neighbor of neighbors) { + if (neighbor && !visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + + if (zoneCells.length < 10) { + throw new Error(`Not enough contiguous land cells found: ${zoneCells.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(); + }); +}); \ No newline at end of file