mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-25 00:27:24 +01:00
Adding zones export to GeoJSON
This commit is contained in:
parent
1100c7c53b
commit
31fef04ce3
7 changed files with 2413 additions and 6 deletions
45
package-lock.json
generated
45
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1567
src/modules/io/export.zones.property.test.ts
Normal file
1567
src/modules/io/export.zones.property.test.ts
Normal file
File diff suppressed because it is too large
Load diff
96
src/modules/io/export.zones.ui.test.ts
Normal file
96
src/modules/io/export.zones.ui.test.ts
Normal 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()"');
|
||||
});
|
||||
});
|
||||
597
src/modules/io/export.zones.unit.test.ts
Normal file
597
src/modules/io/export.zones.unit.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue