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",
"@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",

View file

@ -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",

View file

@ -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");
}

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

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();
});
});
});