diff --git a/package-lock.json b/package-lock.json index 53616e24..f7db7069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/polylabel": "^1.1.3", "@vitest/browser": "^4.0.18", "@vitest/browser-playwright": "^4.0.18", + "fast-check": "^4.5.3", "playwright": "^1.57.0", "typescript": "^5.9.3", "vite": "^7.3.1", @@ -1353,7 +1354,6 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1394,7 +1394,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 +1875,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" } @@ -2039,6 +2037,28 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz", + "integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2163,7 +2183,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2279,6 +2298,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -2475,7 +2510,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2551,7 +2585,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/package.json b/package.json index 37c2ba5a..160ee5e3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/polylabel": "^1.1.3", "@vitest/browser": "^4.0.18", "@vitest/browser-playwright": "^4.0.18", + "fast-check": "^4.5.3", "playwright": "^1.57.0", "typescript": "^5.9.3", "vite": "^7.3.1", diff --git a/public/modules/io/export.js b/public/modules/io/export.js index 51164c73..f4166f96 100644 --- a/public/modules/io/export.js +++ b/public/modules/io/export.js @@ -574,3 +574,115 @@ 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 + if (coordinates[0].length > 1) { + 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"); +} diff --git a/src/index.html b/src/index.html index f44cda5a..6af93062 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
diff --git a/src/modules/io/export.zones.property.test.ts b/src/modules/io/export.zones.property.test.ts
new file mode 100644
index 00000000..3a5b0de5
--- /dev/null
+++ b/src/modules/io/export.zones.property.test.ts
@@ -0,0 +1,1567 @@
+/**
+ * Property-based tests for zones GeoJSON export
+ * Feature: zones-geojson-export
+ */
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import * as fc from "fast-check";
+
+// Mock global functions and objects
+declare global {
+ var pack: any;
+ var getCoordinates: (x: number, y: number, decimals: number) => [number, number];
+ var getFileName: (dataType: string) => string;
+ var downloadFile: (data: string, fileName: string, mimeType: string) => void;
+}
+
+describe("zones GeoJSON export - Property-Based Tests", () => {
+ beforeEach(() => {
+ // Mock getCoordinates function
+ globalThis.getCoordinates = vi.fn((x: number, y: number, decimals: number) => {
+ const lon = Number((x / 10).toFixed(decimals));
+ const lat = Number((y / 10).toFixed(decimals));
+ return [lon, lat];
+ });
+
+ // Mock getFileName function
+ globalThis.getFileName = vi.fn((dataType: string) => {
+ return `TestMap_${dataType}_20240101`;
+ });
+
+ // Mock downloadFile function
+ globalThis.downloadFile = vi.fn();
+ });
+
+ /**
+ * Property 1: Valid GeoJSON Structure
+ * Feature: zones-geojson-export, Property 1: Valid GeoJSON Structure
+ * Validates: Requirements 1.1, 5.4
+ *
+ * For any exported zones data, the output SHALL be a valid GeoJSON FeatureCollection
+ * with a "type" field equal to "FeatureCollection" and a "features" array.
+ */
+ it("Property 1: exported data is a valid GeoJSON FeatureCollection with type and features array", () => {
+ fc.assert(
+ fc.property(
+ // Generate random zones with varying properties
+ fc.array(
+ fc.record({
+ i: fc.integer({ min: 0, max: 1000 }),
+ name: fc.string({ minLength: 1, maxLength: 50 }),
+ type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")),
+ color: fc.oneof(
+ fc.constant("#ff0000"),
+ fc.constant("#00ff00"),
+ fc.constant("url(#hatch1)")
+ ),
+ cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 0, maxLength: 10 }),
+ hidden: fc.boolean(),
+ }),
+ { minLength: 0, maxLength: 20 }
+ ).map(zones => {
+ // Ensure unique zone IDs
+ return zones.map((zone, index) => ({ ...zone, i: index }));
+ }),
+ (zones) => {
+ // Setup mock pack data
+ const mockCells = {
+ v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells
+ c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors
+ };
+
+ const mockVertices = {
+ p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]),
+ c: Array(3).fill(null).map(() => [0, 1, 2]),
+ v: Array(3).fill(null).map(() => [0, 1, 2]),
+ };
+
+ globalThis.pack = {
+ zones,
+ cells: mockCells,
+ vertices: mockVertices,
+ };
+
+ // Execute the function that generates GeoJSON
+ const saveGeoJsonZones = new Function(`
+ const {zones, cells, vertices} = pack;
+ const json = {type: "FeatureCollection", features: []};
+
+ 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 coordinates = [];
+
+ for (const cellId of zoneCells) {
+ if (checkedCells.has(cellId)) continue;
+
+ const neighbors = cells.c[cellId];
+ const onBorder = neighbors.some(ofDifferentType);
+ if (!onBorder) continue;
+
+ 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;
+
+ const vertexChain = [];
+ let current = startingVertex;
+ let previous = null;
+ const maxIterations = vertices.c.length;
+
+ for (let i = 0; i < maxIterations; i++) {
+ vertexChain.push(current);
+
+ const adjacentCells = vertices.c[current];
+ adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c));
+
+ 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;
+
+ previous = current;
+ current = next;
+ }
+
+ for (const vertexId of vertexChain) {
+ const [x, y] = vertices.p[vertexId];
+ coordinates.push(getCoordinates(x, y, 4));
+ }
+ }
+
+ if (coordinates.length > 0) {
+ coordinates.push(coordinates[0]);
+ }
+
+ return [coordinates];
+ }
+
+ zones.forEach(zone => {
+ if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
+
+ const coordinates = getZonePolygonCoordinates(zone.cells);
+
+ if (coordinates[0].length > 1) {
+ 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;
+ `);
+
+ const result = saveGeoJsonZones();
+
+ // Verify valid GeoJSON FeatureCollection structure
+ expect(result).toBeDefined();
+ expect(result).toHaveProperty("type");
+ expect(result.type).toBe("FeatureCollection");
+ expect(result).toHaveProperty("features");
+ expect(Array.isArray(result.features)).toBe(true);
+
+ // Verify each feature has the correct structure
+ for (const feature of result.features) {
+ expect(feature).toHaveProperty("type", "Feature");
+ expect(feature).toHaveProperty("geometry");
+ expect(feature).toHaveProperty("properties");
+ expect(feature.geometry).toHaveProperty("type");
+ expect(feature.geometry).toHaveProperty("coordinates");
+ }
+ }
+ ),
+ { numRuns: 100 }
+ );
+ });
+
+ /**
+ * Property 2: Visible Zones Only
+ * Feature: zones-geojson-export, Property 2: Visible Zones Only
+ * Validates: Requirements 1.3, 1.4
+ *
+ * For any zone in the exported GeoJSON, that zone SHALL NOT be marked as hidden
+ * in pack.zones and SHALL have at least one cell.
+ */
+ it("Property 2: only visible zones with cells are exported (no hidden zones, no empty zones)", () => {
+ fc.assert(
+ fc.property(
+ // Generate random zones with mixed visibility and cell counts
+ fc.array(
+ fc.record({
+ i: fc.integer({ min: 0, max: 1000 }),
+ name: fc.string({ minLength: 1, maxLength: 50 }),
+ type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")),
+ color: fc.oneof(
+ fc.constant("#ff0000"),
+ fc.constant("#00ff00"),
+ fc.constant("url(#hatch1)")
+ ),
+ cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 0, maxLength: 10 }),
+ hidden: fc.boolean(), // Mix of hidden and visible zones
+ }),
+ { minLength: 0, maxLength: 20 }
+ ).map(zones => {
+ // Ensure unique zone IDs
+ return zones.map((zone, index) => ({ ...zone, i: index }));
+ }),
+ (zones) => {
+ // Setup mock pack data
+ const mockCells = {
+ v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells
+ c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors
+ };
+
+ const mockVertices = {
+ p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]),
+ c: Array(3).fill(null).map(() => [0, 1, 2]),
+ v: Array(3).fill(null).map(() => [0, 1, 2]),
+ };
+
+ globalThis.pack = {
+ zones,
+ cells: mockCells,
+ vertices: mockVertices,
+ };
+
+ // Execute the function that generates GeoJSON
+ const saveGeoJsonZones = new Function(`
+ const {zones, cells, vertices} = pack;
+ const json = {type: "FeatureCollection", features: []};
+
+ 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 coordinates = [];
+
+ for (const cellId of zoneCells) {
+ if (checkedCells.has(cellId)) continue;
+
+ const neighbors = cells.c[cellId];
+ const onBorder = neighbors.some(ofDifferentType);
+ if (!onBorder) continue;
+
+ 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;
+
+ const vertexChain = [];
+ let current = startingVertex;
+ let previous = null;
+ const maxIterations = vertices.c.length;
+
+ for (let i = 0; i < maxIterations; i++) {
+ vertexChain.push(current);
+
+ const adjacentCells = vertices.c[current];
+ adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c));
+
+ 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;
+
+ previous = current;
+ current = next;
+ }
+
+ for (const vertexId of vertexChain) {
+ const [x, y] = vertices.p[vertexId];
+ coordinates.push(getCoordinates(x, y, 4));
+ }
+ }
+
+ if (coordinates.length > 0) {
+ coordinates.push(coordinates[0]);
+ }
+
+ return [coordinates];
+ }
+
+ zones.forEach(zone => {
+ if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
+
+ const coordinates = getZonePolygonCoordinates(zone.cells);
+
+ if (coordinates[0].length > 1) {
+ 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;
+ `);
+
+ const result = saveGeoJsonZones();
+
+ // Calculate expected visible zones (not hidden AND has cells)
+ const expectedVisibleZones = zones.filter(
+ zone => !zone.hidden && zone.cells && zone.cells.length > 0
+ );
+
+ // Verify that all exported features correspond to visible zones only
+ for (const feature of result.features) {
+ const zoneId = feature.properties.id;
+ const originalZone = zones.find(z => z.i === zoneId);
+
+ // Verify the zone exists
+ expect(originalZone).toBeDefined();
+
+ if (originalZone) {
+ // Verify the zone is not hidden
+ expect(originalZone.hidden).not.toBe(true);
+
+ // Verify the zone has cells
+ expect(originalZone.cells).toBeDefined();
+ expect(originalZone.cells.length).toBeGreaterThan(0);
+ }
+ }
+
+ // Verify no hidden zones are in the export
+ const exportedZoneIds = new Set(result.features.map(f => f.properties.id));
+ const hiddenZones = zones.filter(z => z.hidden === true);
+
+ for (const hiddenZone of hiddenZones) {
+ expect(exportedZoneIds.has(hiddenZone.i)).toBe(false);
+ }
+
+ // Verify no zones with empty cells are in the export
+ const emptyZones = zones.filter(z => !z.cells || z.cells.length === 0);
+
+ for (const emptyZone of emptyZones) {
+ expect(exportedZoneIds.has(emptyZone.i)).toBe(false);
+ }
+ }
+ ),
+ { numRuns: 100 }
+ );
+ });
+
+ /**
+ * Property 3: Polygon Geometry Type
+ * Feature: zones-geojson-export, Property 3: Polygon Geometry Type
+ * Validates: Requirements 1.2
+ *
+ * For any exported zone feature, the geometry SHALL have type "Polygon" with a coordinates
+ * array containing at least one coordinate ring.
+ */
+ it("Property 3: all exported zone features have Polygon geometry type with coordinate rings", () => {
+ fc.assert(
+ fc.property(
+ // Generate random zones with varying properties
+ fc.array(
+ fc.record({
+ i: fc.integer({ min: 0, max: 1000 }),
+ name: fc.string({ minLength: 1, maxLength: 50 }),
+ type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")),
+ color: fc.oneof(
+ fc.constant("#ff0000"),
+ fc.constant("#00ff00"),
+ fc.constant("url(#hatch1)")
+ ),
+ cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }),
+ hidden: fc.constant(false), // Only visible zones
+ }),
+ { minLength: 1, maxLength: 20 }
+ ).map(zones => {
+ // Ensure unique zone IDs
+ return zones.map((zone, index) => ({ ...zone, i: index }));
+ }),
+ (zones) => {
+ // Setup mock pack data
+ const mockCells = {
+ v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells
+ c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors
+ };
+
+ const mockVertices = {
+ p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]),
+ c: Array(3).fill(null).map(() => [0, 1, 2]),
+ v: Array(3).fill(null).map(() => [0, 1, 2]),
+ };
+
+ globalThis.pack = {
+ zones,
+ cells: mockCells,
+ vertices: mockVertices,
+ };
+
+ // Execute the function that generates GeoJSON
+ const saveGeoJsonZones = new Function(`
+ const {zones, cells, vertices} = pack;
+ const json = {type: "FeatureCollection", features: []};
+
+ 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 coordinates = [];
+
+ for (const cellId of zoneCells) {
+ if (checkedCells.has(cellId)) continue;
+
+ const neighbors = cells.c[cellId];
+ const onBorder = neighbors.some(ofDifferentType);
+ if (!onBorder) continue;
+
+ 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;
+
+ const vertexChain = [];
+ let current = startingVertex;
+ let previous = null;
+ const maxIterations = vertices.c.length;
+
+ for (let i = 0; i < maxIterations; i++) {
+ vertexChain.push(current);
+
+ const adjacentCells = vertices.c[current];
+ adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c));
+
+ 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;
+
+ previous = current;
+ current = next;
+ }
+
+ for (const vertexId of vertexChain) {
+ const [x, y] = vertices.p[vertexId];
+ coordinates.push(getCoordinates(x, y, 4));
+ }
+ }
+
+ if (coordinates.length > 0) {
+ coordinates.push(coordinates[0]);
+ }
+
+ return [coordinates];
+ }
+
+ zones.forEach(zone => {
+ if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
+
+ const coordinates = getZonePolygonCoordinates(zone.cells);
+
+ if (coordinates[0].length > 1) {
+ 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;
+ `);
+
+ const result = saveGeoJsonZones();
+
+ // Verify all features have Polygon geometry type
+ expect(result.features.length).toBeGreaterThan(0);
+
+ for (const feature of result.features) {
+ // Verify geometry exists
+ expect(feature.geometry).toBeDefined();
+
+ // Verify geometry type is "Polygon"
+ expect(feature.geometry.type).toBe("Polygon");
+
+ // Verify coordinates array exists
+ expect(feature.geometry.coordinates).toBeDefined();
+ expect(Array.isArray(feature.geometry.coordinates)).toBe(true);
+
+ // Verify at least one coordinate ring exists
+ expect(feature.geometry.coordinates.length).toBeGreaterThanOrEqual(1);
+
+ // Verify the first element is a coordinate ring (array of coordinates)
+ const firstRing = feature.geometry.coordinates[0];
+ expect(Array.isArray(firstRing)).toBe(true);
+ expect(firstRing.length).toBeGreaterThan(0);
+
+ // Verify each coordinate in the ring is a [lon, lat] pair
+ for (const coord of firstRing) {
+ expect(Array.isArray(coord)).toBe(true);
+ expect(coord.length).toBe(2);
+ expect(typeof coord[0]).toBe("number"); // longitude
+ expect(typeof coord[1]).toBe("number"); // latitude
+ }
+ }
+ }
+ ),
+ { numRuns: 100 }
+ );
+ });
+
+ /**
+ * Property 4: Closed Polygon Rings
+ * Feature: zones-geojson-export, Property 4: Closed Polygon Rings
+ * Validates: Requirements 3.2
+ *
+ * For any zone feature's polygon coordinates, the first coordinate SHALL equal the last
+ * coordinate (closed ring requirement).
+ */
+ it("Property 4: all polygon coordinate rings are closed (first coordinate equals last coordinate)", () => {
+ fc.assert(
+ fc.property(
+ // Generate random zones with varying properties
+ fc.array(
+ fc.record({
+ i: fc.integer({ min: 0, max: 1000 }),
+ name: fc.string({ minLength: 1, maxLength: 50 }),
+ type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")),
+ color: fc.oneof(
+ fc.constant("#ff0000"),
+ fc.constant("#00ff00"),
+ fc.constant("url(#hatch1)")
+ ),
+ cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }),
+ hidden: fc.constant(false), // Only visible zones
+ }),
+ { minLength: 1, maxLength: 20 }
+ ).map(zones => {
+ // Ensure unique zone IDs
+ return zones.map((zone, index) => ({ ...zone, i: index }));
+ }),
+ (zones) => {
+ // Setup mock pack data
+ const mockCells = {
+ v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells
+ c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors
+ };
+
+ const mockVertices = {
+ p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]),
+ c: Array(3).fill(null).map(() => [0, 1, 2]),
+ v: Array(3).fill(null).map(() => [0, 1, 2]),
+ };
+
+ globalThis.pack = {
+ zones,
+ cells: mockCells,
+ vertices: mockVertices,
+ };
+
+ // Execute the function that generates GeoJSON
+ const saveGeoJsonZones = new Function(`
+ const {zones, cells, vertices} = pack;
+ const json = {type: "FeatureCollection", features: []};
+
+ 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 coordinates = [];
+
+ for (const cellId of zoneCells) {
+ if (checkedCells.has(cellId)) continue;
+
+ const neighbors = cells.c[cellId];
+ const onBorder = neighbors.some(ofDifferentType);
+ if (!onBorder) continue;
+
+ 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;
+
+ const vertexChain = [];
+ let current = startingVertex;
+ let previous = null;
+ const maxIterations = vertices.c.length;
+
+ for (let i = 0; i < maxIterations; i++) {
+ vertexChain.push(current);
+
+ const adjacentCells = vertices.c[current];
+ adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c));
+
+ 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;
+
+ previous = current;
+ current = next;
+ }
+
+ for (const vertexId of vertexChain) {
+ const [x, y] = vertices.p[vertexId];
+ coordinates.push(getCoordinates(x, y, 4));
+ }
+ }
+
+ if (coordinates.length > 0) {
+ coordinates.push(coordinates[0]);
+ }
+
+ return [coordinates];
+ }
+
+ zones.forEach(zone => {
+ if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
+
+ const coordinates = getZonePolygonCoordinates(zone.cells);
+
+ if (coordinates[0].length > 1) {
+ 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;
+ `);
+
+ const result = saveGeoJsonZones();
+
+ // Verify all polygon rings are closed
+ expect(result.features.length).toBeGreaterThan(0);
+
+ for (const feature of result.features) {
+ expect(feature.geometry.type).toBe("Polygon");
+ expect(feature.geometry.coordinates).toBeDefined();
+ expect(Array.isArray(feature.geometry.coordinates)).toBe(true);
+
+ // Check each coordinate ring in the polygon
+ for (const ring of feature.geometry.coordinates) {
+ expect(Array.isArray(ring)).toBe(true);
+ expect(ring.length).toBeGreaterThanOrEqual(2);
+
+ // Verify the ring is closed: first coordinate equals last coordinate
+ const firstCoord = ring[0];
+ const lastCoord = ring[ring.length - 1];
+
+ expect(Array.isArray(firstCoord)).toBe(true);
+ expect(Array.isArray(lastCoord)).toBe(true);
+ expect(firstCoord.length).toBe(2);
+ expect(lastCoord.length).toBe(2);
+
+ // Check that first and last coordinates are equal
+ expect(firstCoord[0]).toBe(lastCoord[0]); // longitude
+ expect(firstCoord[1]).toBe(lastCoord[1]); // latitude
+ }
+ }
+ }
+ ),
+ { numRuns: 100 }
+ );
+ });
+
+ /**
+ * Property 5: Complete Zone Properties
+ * Feature: zones-geojson-export, Property 5: Complete Zone Properties
+ * Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5
+ *
+ * For any exported zone feature, the properties object SHALL contain all required fields:
+ * id, name, type, color, and cells array.
+ */
+ it("Property 5: all exported zone features contain complete properties (id, name, type, color, cells)", () => {
+ fc.assert(
+ fc.property(
+ // Generate random zones with all required properties
+ fc.array(
+ fc.record({
+ i: fc.integer({ min: 0, max: 1000 }),
+ name: fc.string({ minLength: 1, maxLength: 50 }),
+ type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")),
+ color: fc.oneof(
+ fc.constant("#ff0000"),
+ fc.constant("#00ff00"),
+ fc.constant("url(#hatch1)")
+ ),
+ cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }),
+ hidden: fc.constant(false), // Only visible zones
+ }),
+ { minLength: 1, maxLength: 20 }
+ ).map(zones => {
+ // Ensure unique zone IDs
+ return zones.map((zone, index) => ({ ...zone, i: index }));
+ }),
+ (zones) => {
+ // Setup mock pack data
+ const mockCells = {
+ v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells
+ c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors
+ };
+
+ const mockVertices = {
+ p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]),
+ c: Array(3).fill(null).map(() => [0, 1, 2]),
+ v: Array(3).fill(null).map(() => [0, 1, 2]),
+ };
+
+ globalThis.pack = {
+ zones,
+ cells: mockCells,
+ vertices: mockVertices,
+ };
+
+ // Import and execute the function
+ const saveGeoJsonZones = new Function(`
+ const {zones, cells, vertices} = pack;
+ const json = {type: "FeatureCollection", features: []};
+
+ 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 coordinates = [];
+
+ for (const cellId of zoneCells) {
+ if (checkedCells.has(cellId)) continue;
+
+ const neighbors = cells.c[cellId];
+ const onBorder = neighbors.some(ofDifferentType);
+ if (!onBorder) continue;
+
+ 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;
+
+ const vertexChain = [];
+ let current = startingVertex;
+ let previous = null;
+ const maxIterations = vertices.c.length;
+
+ for (let i = 0; i < maxIterations; i++) {
+ vertexChain.push(current);
+
+ const adjacentCells = vertices.c[current];
+ adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c));
+
+ 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;
+
+ previous = current;
+ current = next;
+ }
+
+ for (const vertexId of vertexChain) {
+ const [x, y] = vertices.p[vertexId];
+ coordinates.push(getCoordinates(x, y, 4));
+ }
+ }
+
+ if (coordinates.length > 0) {
+ coordinates.push(coordinates[0]);
+ }
+
+ return [coordinates];
+ }
+
+ zones.forEach(zone => {
+ if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
+
+ const coordinates = getZonePolygonCoordinates(zone.cells);
+
+ if (coordinates[0].length > 1) {
+ 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;
+ `);
+
+ const result = saveGeoJsonZones();
+
+ // Verify all features have complete properties
+ expect(result.features.length).toBeGreaterThan(0);
+
+ for (const feature of result.features) {
+ expect(feature.properties).toBeDefined();
+ expect(feature.properties).toHaveProperty("id");
+ expect(feature.properties).toHaveProperty("name");
+ expect(feature.properties).toHaveProperty("type");
+ expect(feature.properties).toHaveProperty("color");
+ expect(feature.properties).toHaveProperty("cells");
+
+ // Verify types
+ expect(typeof feature.properties.id).toBe("number");
+ expect(typeof feature.properties.name).toBe("string");
+ expect(typeof feature.properties.type).toBe("string");
+ expect(typeof feature.properties.color).toBe("string");
+ expect(Array.isArray(feature.properties.cells)).toBe(true);
+
+ // Verify values match input zones
+ const matchingZone = zones.find(z => z.i === feature.properties.id);
+ expect(matchingZone).toBeDefined();
+ expect(feature.properties.name).toBe(matchingZone.name);
+ expect(feature.properties.type).toBe(matchingZone.type);
+ expect(feature.properties.color).toBe(matchingZone.color);
+ expect(feature.properties.cells).toEqual(matchingZone.cells);
+ }
+ }
+ ),
+ { numRuns: 100 }
+ );
+ });
+
+ /**
+ * Property 6: Coordinate Precision
+ * Feature: zones-geojson-export, Property 6: Coordinate Precision
+ * Validates: Requirements 3.1
+ *
+ * For any coordinate in the exported GeoJSON, both longitude and latitude SHALL be
+ * rounded to 4 decimal places.
+ */
+ it("Property 6: all coordinates are rounded to 4 decimal places", () => {
+ fc.assert(
+ fc.property(
+ // Generate random zones with varying properties
+ fc.array(
+ fc.record({
+ i: fc.integer({ min: 0, max: 1000 }),
+ name: fc.string({ minLength: 1, maxLength: 50 }),
+ type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")),
+ color: fc.oneof(
+ fc.constant("#ff0000"),
+ fc.constant("#00ff00"),
+ fc.constant("url(#hatch1)")
+ ),
+ cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }),
+ hidden: fc.constant(false), // Only visible zones
+ }),
+ { minLength: 1, maxLength: 20 }
+ ).map(zones => {
+ // Ensure unique zone IDs
+ return zones.map((zone, index) => ({ ...zone, i: index }));
+ }),
+ // Generate random vertex coordinates with varying precision
+ fc.array(
+ fc.tuple(
+ fc.float({ min: -1000, max: 1000 }),
+ fc.float({ min: -1000, max: 1000 })
+ ),
+ { minLength: 3, maxLength: 10 }
+ ),
+ (zones, vertexCoords) => {
+ // Setup mock pack data with random vertex coordinates
+ const mockCells = {
+ v: Array(101).fill(null).map((_, i) =>
+ Array.from({ length: Math.min(vertexCoords.length, 10) }, (_, j) => j)
+ ),
+ c: Array(101).fill(null).map(() => [0, 1, 2]),
+ };
+
+ const mockVertices = {
+ p: vertexCoords,
+ c: Array(vertexCoords.length).fill(null).map(() => [0, 1, 2]),
+ v: Array(vertexCoords.length).fill(null).map(() => [0, 1, 2]),
+ };
+
+ globalThis.pack = {
+ zones,
+ cells: mockCells,
+ vertices: mockVertices,
+ };
+
+ // Execute the function that generates GeoJSON
+ const saveGeoJsonZones = new Function(`
+ const {zones, cells, vertices} = pack;
+ const json = {type: "FeatureCollection", features: []};
+
+ 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 coordinates = [];
+
+ for (const cellId of zoneCells) {
+ if (checkedCells.has(cellId)) continue;
+
+ const neighbors = cells.c[cellId];
+ const onBorder = neighbors.some(ofDifferentType);
+ if (!onBorder) continue;
+
+ 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;
+
+ const vertexChain = [];
+ let current = startingVertex;
+ let previous = null;
+ const maxIterations = vertices.c.length;
+
+ for (let i = 0; i < maxIterations; i++) {
+ vertexChain.push(current);
+
+ const adjacentCells = vertices.c[current];
+ adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c));
+
+ 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;
+
+ previous = current;
+ current = next;
+ }
+
+ for (const vertexId of vertexChain) {
+ const [x, y] = vertices.p[vertexId];
+ coordinates.push(getCoordinates(x, y, 4));
+ }
+ }
+
+ if (coordinates.length > 0) {
+ coordinates.push(coordinates[0]);
+ }
+
+ return [coordinates];
+ }
+
+ zones.forEach(zone => {
+ if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
+
+ const coordinates = getZonePolygonCoordinates(zone.cells);
+
+ if (coordinates[0].length > 1) {
+ 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;
+ `);
+
+ const result = saveGeoJsonZones();
+
+ // Helper function to count decimal places
+ const countDecimals = (num: number): number => {
+ const str = num.toString();
+ if (!str.includes('.')) return 0;
+ return str.split('.')[1].length;
+ };
+
+ // Verify all coordinates have at most 4 decimal places
+ expect(result.features.length).toBeGreaterThan(0);
+
+ for (const feature of result.features) {
+ expect(feature.geometry.type).toBe("Polygon");
+ expect(feature.geometry.coordinates).toBeDefined();
+
+ // Check each coordinate ring
+ for (const ring of feature.geometry.coordinates) {
+ expect(Array.isArray(ring)).toBe(true);
+
+ // Check each coordinate in the ring
+ for (const coord of ring) {
+ expect(Array.isArray(coord)).toBe(true);
+ expect(coord.length).toBe(2);
+
+ const [lon, lat] = coord;
+
+ // Verify both longitude and latitude are numbers
+ expect(typeof lon).toBe("number");
+ expect(typeof lat).toBe("number");
+
+ // Verify precision is at most 4 decimal places
+ expect(countDecimals(lon)).toBeLessThanOrEqual(4);
+ expect(countDecimals(lat)).toBeLessThanOrEqual(4);
+
+ // Verify that the coordinate matches what getCoordinates would return
+ // with precision 4 (i.e., it's properly rounded)
+ // Note: We need to handle -0 vs +0 edge case in JavaScript
+ const lonRounded = Number(lon.toFixed(4));
+ const latRounded = Number(lat.toFixed(4));
+
+ // Use Math.abs to handle -0 vs +0 comparison
+ if (lon === 0 && lonRounded === 0) {
+ // Both are zero (either +0 or -0), which is acceptable
+ expect(Math.abs(lon)).toBe(Math.abs(lonRounded));
+ } else {
+ expect(lonRounded).toBe(lon);
+ }
+
+ if (lat === 0 && latRounded === 0) {
+ // Both are zero (either +0 or -0), which is acceptable
+ expect(Math.abs(lat)).toBe(Math.abs(latRounded));
+ } else {
+ expect(latRounded).toBe(lat);
+ }
+ }
+ }
+ }
+ }
+ ),
+ { numRuns: 100 }
+ );
+ });
+
+ /**
+ * Property 7: Single Polygon Per Zone
+ * Feature: zones-geojson-export, Property 7: Single Polygon Per Zone
+ * Validates: Requirements 3.3
+ *
+ * For any zone with multiple cells, the export SHALL produce exactly one Feature with
+ * one Polygon geometry (not MultiPolygon).
+ */
+ it("Property 7: each zone produces exactly one Feature with Polygon geometry (not MultiPolygon)", () => {
+ fc.assert(
+ fc.property(
+ // Generate random zones with multiple cells to test merging
+ fc.array(
+ fc.record({
+ i: fc.integer({ min: 0, max: 1000 }),
+ name: fc.string({ minLength: 1, maxLength: 50 }),
+ type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")),
+ color: fc.oneof(
+ fc.constant("#ff0000"),
+ fc.constant("#00ff00"),
+ fc.constant("url(#hatch1)")
+ ),
+ // Generate zones with multiple cells (2-10 cells per zone)
+ cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 2, maxLength: 10 }),
+ hidden: fc.constant(false), // Only visible zones
+ }),
+ { minLength: 1, maxLength: 20 }
+ ).map(zones => {
+ // Ensure unique zone IDs
+ return zones.map((zone, index) => ({ ...zone, i: index }));
+ }),
+ (zones) => {
+ // Setup mock pack data
+ const mockCells = {
+ v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells
+ c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors
+ };
+
+ const mockVertices = {
+ p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]),
+ c: Array(3).fill(null).map(() => [0, 1, 2]),
+ v: Array(3).fill(null).map(() => [0, 1, 2]),
+ };
+
+ globalThis.pack = {
+ zones,
+ cells: mockCells,
+ vertices: mockVertices,
+ };
+
+ // Execute the function that generates GeoJSON
+ const saveGeoJsonZones = new Function(`
+ const {zones, cells, vertices} = pack;
+ const json = {type: "FeatureCollection", features: []};
+
+ 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 coordinates = [];
+
+ for (const cellId of zoneCells) {
+ if (checkedCells.has(cellId)) continue;
+
+ const neighbors = cells.c[cellId];
+ const onBorder = neighbors.some(ofDifferentType);
+ if (!onBorder) continue;
+
+ 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;
+
+ const vertexChain = [];
+ let current = startingVertex;
+ let previous = null;
+ const maxIterations = vertices.c.length;
+
+ for (let i = 0; i < maxIterations; i++) {
+ vertexChain.push(current);
+
+ const adjacentCells = vertices.c[current];
+ adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c));
+
+ 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;
+
+ previous = current;
+ current = next;
+ }
+
+ for (const vertexId of vertexChain) {
+ const [x, y] = vertices.p[vertexId];
+ coordinates.push(getCoordinates(x, y, 4));
+ }
+ }
+
+ if (coordinates.length > 0) {
+ coordinates.push(coordinates[0]);
+ }
+
+ return [coordinates];
+ }
+
+ zones.forEach(zone => {
+ if (zone.hidden || !zone.cells || zone.cells.length === 0) return;
+
+ const coordinates = getZonePolygonCoordinates(zone.cells);
+
+ if (coordinates[0].length > 1) {
+ 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;
+ `);
+
+ const result = saveGeoJsonZones();
+
+ // Verify that we have features to test
+ expect(result.features.length).toBeGreaterThan(0);
+
+ // Create a map of zone IDs to their feature count
+ const zoneIdToFeatureCount = new Map