Adding zones export to GeoJSON

This commit is contained in:
Joe McMahon 2026-02-10 19:56:16 -05:00
parent 1100c7c53b
commit 31fef04ce3
7 changed files with 2413 additions and 6 deletions

45
package-lock.json generated
View file

@ -23,6 +23,7 @@
"@types/polylabel": "^1.1.3", "@types/polylabel": "^1.1.3",
"@vitest/browser": "^4.0.18", "@vitest/browser": "^4.0.18",
"@vitest/browser-playwright": "^4.0.18", "@vitest/browser-playwright": "^4.0.18",
"fast-check": "^4.5.3",
"playwright": "^1.57.0", "playwright": "^1.57.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
@ -1353,7 +1354,6 @@
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -1394,7 +1394,6 @@
"integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/browser": "4.0.18", "@vitest/browser": "4.0.18",
"@vitest/mocker": "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", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -2039,6 +2037,28 @@
"node": ">=12.0.0" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2163,7 +2183,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2279,6 +2298,22 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/robust-predicates": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
@ -2475,7 +2510,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -2551,7 +2585,6 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.18", "@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18", "@vitest/mocker": "4.0.18",

View file

@ -32,6 +32,7 @@
"@types/polylabel": "^1.1.3", "@types/polylabel": "^1.1.3",
"@vitest/browser": "^4.0.18", "@vitest/browser": "^4.0.18",
"@vitest/browser-playwright": "^4.0.18", "@vitest/browser-playwright": "^4.0.18",
"fast-check": "^4.5.3",
"playwright": "^1.57.0", "playwright": "^1.57.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",

View file

@ -574,3 +574,115 @@ function saveGeoJsonMarkers() {
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 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");
}

View file

@ -6152,6 +6152,7 @@
<button onclick="saveGeoJsonRoutes()" data-tip="Download routes data in GeoJSON format">routes</button> <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="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="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> </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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,96 @@
/**
* UI Integration tests for zones GeoJSON export button
* Feature: zones-geojson-export
*
* These tests verify the zones export button is correctly integrated into the UI
* Validates: Requirements 4.1, 4.2, 4.3, 4.4
*/
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
describe("zones GeoJSON export - UI Integration Tests", () => {
let htmlContent: string;
beforeAll(() => {
// Read the index.html file
const htmlPath = join(__dirname, "../../index.html");
htmlContent = readFileSync(htmlPath, "utf-8");
});
/**
* Test 4.2.1: Button exists in correct location
* Validates: Requirement 4.1, 4.4
* The zones button should be in the "Export to GeoJSON" section after the markers button
*/
it("should have zones button in correct location after markers button", () => {
// Find the GeoJSON export section
expect(htmlContent).toContain("Export to GeoJSON");
// Find the markers button
const markersButtonPattern = /<button[^>]*onclick="saveGeoJsonMarkers\(\)"[^>]*>markers<\/button>/;
const markersMatch = htmlContent.match(markersButtonPattern);
expect(markersMatch).toBeTruthy();
// Find the zones button
const zonesButtonPattern = /<button[^>]*onclick="saveGeoJsonZones\(\)"[^>]*>zones<\/button>/;
const zonesMatch = htmlContent.match(zonesButtonPattern);
expect(zonesMatch).toBeTruthy();
// Verify zones button comes after markers button in the HTML
const markersIndex = htmlContent.indexOf(markersMatch![0]);
const zonesIndex = htmlContent.indexOf(zonesMatch![0]);
expect(zonesIndex).toBeGreaterThan(markersIndex);
});
/**
* Test 4.2.2: Button has correct tooltip
* Validates: Requirement 4.2
* The zones button should have a data-tip attribute with the correct tooltip text
*/
it("should have correct tooltip on zones button", () => {
// Find the zones button with data-tip attribute
const zonesButtonPattern = /<button[^>]*onclick="saveGeoJsonZones\(\)"[^>]*data-tip="([^"]*)"[^>]*>zones<\/button>/;
const match = htmlContent.match(zonesButtonPattern);
expect(match).toBeTruthy();
expect(match![1]).toBe("Download zones data in GeoJSON format");
});
/**
* Test 4.2.3: Button has correct onclick handler
* Validates: Requirement 4.3
* The zones button should have onclick="saveGeoJsonZones()"
*/
it("should have correct onclick handler", () => {
// Find the zones button with onclick attribute
const zonesButtonPattern = /<button[^>]*onclick="(saveGeoJsonZones\(\))"[^>]*>zones<\/button>/;
const match = htmlContent.match(zonesButtonPattern);
expect(match).toBeTruthy();
expect(match![1]).toBe("saveGeoJsonZones()");
});
/**
* Test 4.2.4: Button is in the GeoJSON export section
* Validates: Requirement 4.1
* The zones button should be in the same div as other GeoJSON export buttons
*/
it("should be in the GeoJSON export section with other export buttons", () => {
// Find the GeoJSON export section
const geojsonSectionPattern = /<div[^>]*>Export to GeoJSON<\/div>\s*<div>([\s\S]*?)<\/div>/;
const match = htmlContent.match(geojsonSectionPattern);
expect(match).toBeTruthy();
const exportButtonsSection = match![1];
// Verify all expected buttons are in the section
expect(exportButtonsSection).toContain('onclick="saveGeoJsonCells()"');
expect(exportButtonsSection).toContain('onclick="saveGeoJsonRoutes()"');
expect(exportButtonsSection).toContain('onclick="saveGeoJsonRivers()"');
expect(exportButtonsSection).toContain('onclick="saveGeoJsonMarkers()"');
expect(exportButtonsSection).toContain('onclick="saveGeoJsonZones()"');
});
});

View file

@ -0,0 +1,597 @@
/**
* Unit tests for zones GeoJSON export - Edge Cases
* Feature: zones-geojson-export
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
// 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 - Edge Case Unit 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();
});
/**
* Test 6.1: Empty zones export
* Tests export when all zones are hidden or have no cells
* Validates: Requirements 1.3, 1.4
*/
describe("6.1 Empty zones export", () => {
it("should generate empty FeatureCollection when all zones are hidden", () => {
// Setup: All zones are hidden
const zones = [
{ i: 0, name: "Zone 1", type: "Territory", color: "#ff0000", cells: [0, 1], hidden: true },
{ i: 1, name: "Zone 2", type: "Climate", color: "#00ff00", cells: [2, 3], hidden: true },
];
const mockCells = {
v: [[0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2]],
c: [[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2]],
};
const mockVertices = {
p: [[0, 0], [10, 0], [5, 10]],
c: [[0, 1], [0, 1], [0, 1]],
v: [[1, 2], [0, 2], [0, 1]],
};
globalThis.pack = { zones, cells: mockCells, vertices: mockVertices };
// Execute
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
expect(result.type).toBe("FeatureCollection");
expect(result.features).toEqual([]);
expect(result.features.length).toBe(0);
});
it("should generate empty FeatureCollection when all zones have no cells", () => {
// Setup: All zones have empty cells arrays
const zones = [
{ i: 0, name: "Zone 1", type: "Territory", color: "#ff0000", cells: [], hidden: false },
{ i: 1, name: "Zone 2", type: "Climate", color: "#00ff00", cells: [], hidden: false },
];
const mockCells = {
v: [[0, 1, 2]],
c: [[1, 2, 3]],
};
const mockVertices = {
p: [[0, 0], [10, 0], [5, 10]],
c: [[0], [0], [0]],
v: [[1, 2], [0, 2], [0, 1]],
};
globalThis.pack = { zones, cells: mockCells, vertices: mockVertices };
// Execute
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
expect(result.type).toBe("FeatureCollection");
expect(result.features).toEqual([]);
expect(result.features.length).toBe(0);
});
});
/**
* Test 6.2: Single zone export
* Tests export with one visible zone
* Validates: Requirements 1.1, 1.2, 2.1, 2.2, 2.3, 2.4, 2.5
*/
describe("6.2 Single zone export", () => {
it("should export single visible zone with correct GeoJSON structure and properties", () => {
// Setup: One visible zone with cells that have boundaries
const zones = [
{ i: 0, name: "Test Zone", type: "Territory", color: "#ff0000", cells: [0, 1], hidden: false },
];
const mockCells = {
v: [[0, 1, 2], [0, 1, 2]],
c: [[1, 2, 3], [0, 2, 3]], // Cell 0 has neighbors 1,2,3 where 2,3 are outside the zone
};
const mockVertices = {
p: [[0, 0], [10, 0], [5, 10]],
c: [[0, 1, 2], [0, 1, 3], [0, 1, 2]], // Vertices connected to cells including outside cells
v: [[1, 2], [0, 2], [0, 1]],
};
globalThis.pack = { zones, cells: mockCells, vertices: mockVertices };
// Execute
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 GeoJSON structure
expect(result.type).toBe("FeatureCollection");
expect(result.features).toBeDefined();
expect(result.features.length).toBe(1);
// Verify feature structure
const feature = result.features[0];
expect(feature.type).toBe("Feature");
expect(feature.geometry).toBeDefined();
expect(feature.geometry.type).toBe("Polygon");
expect(feature.geometry.coordinates).toBeDefined();
expect(Array.isArray(feature.geometry.coordinates)).toBe(true);
// Verify all properties are present
expect(feature.properties).toBeDefined();
expect(feature.properties.id).toBe(0);
expect(feature.properties.name).toBe("Test Zone");
expect(feature.properties.type).toBe("Territory");
expect(feature.properties.color).toBe("#ff0000");
expect(feature.properties.cells).toEqual([0, 1]);
});
});
/**
* Test 6.3: Multiple zones export
* Tests export with multiple visible zones
* Validates: Requirements 1.1, 1.3, 1.4
*/
describe("6.3 Multiple zones export", () => {
it("should export multiple visible zones with correct feature count", () => {
// Setup: Multiple visible zones with one hidden
const zones = [
{ i: 0, name: "Zone 1", type: "Territory", color: "#ff0000", cells: [0, 1], hidden: false },
{ i: 1, name: "Zone 2", type: "Climate", color: "#00ff00", cells: [2, 3], hidden: true },
{ i: 2, name: "Zone 3", type: "Unknown", color: "#0000ff", cells: [4, 5], hidden: false },
];
const mockCells = {
v: [[0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2]],
c: [[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2], [5, 2, 3], [4, 2, 3]],
};
const mockVertices = {
p: [[0, 0], [10, 0], [5, 10]],
c: [[0, 1, 2, 3, 4, 5], [0, 1, 2, 3, 4, 5], [0, 1, 2, 3, 4, 5]],
v: [[1, 2], [0, 2], [0, 1]],
};
globalThis.pack = { zones, cells: mockCells, vertices: mockVertices };
// Execute
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 feature count matches visible zones (2 out of 3)
expect(result.type).toBe("FeatureCollection");
expect(result.features.length).toBe(2);
// Verify each zone is correctly converted
const feature1 = result.features.find((f: any) => f.properties.id === 0);
const feature2 = result.features.find((f: any) => f.properties.id === 2);
expect(feature1).toBeDefined();
expect(feature1?.properties.name).toBe("Zone 1");
expect(feature1?.properties.type).toBe("Territory");
expect(feature1?.properties.color).toBe("#ff0000");
expect(feature1?.properties.cells).toEqual([0, 1]);
expect(feature2).toBeDefined();
expect(feature2?.properties.name).toBe("Zone 3");
expect(feature2?.properties.type).toBe("Unknown");
expect(feature2?.properties.color).toBe("#0000ff");
expect(feature2?.properties.cells).toEqual([4, 5]);
// Verify hidden zone is not exported
const hiddenFeature = result.features.find((f: any) => f.properties.id === 1);
expect(hiddenFeature).toBeUndefined();
});
});
});