1 Commits
Author SHA1 Message Date
zemion 3538e4e12b Release Geo Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 12:02:09 +02:00
29 changed files with 1352 additions and 172 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Added validated full-GeoJSON feature editing, append/delete, nested coordinate addressing, line-track reversal, and GPX-derived track inventories.
- Added a local great-circle distance/bearing/midpoint ruler and Toolbox format/capability metadata while retaining the tile-free, WGS 84-only boundary.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Added the initial local-first Geo Tools workbench. - Added the initial local-first Geo Tools workbench.
+10 -4
View File
@@ -1,18 +1,24 @@
# Geo Tools # Geo Tools
Inspect, convert and analyse geospatial files locally in the browser. Inspect, edit, measure and convert geospatial files locally in the browser.
Geo Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded. Geo Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope ## Version 0.1 scope
- Bounded GeoJSON, GPX, KML and coordinate-CSV parsing into a small common model - Bounded GeoJSON, GPX, KML and coordinate-CSV parsing into a common model with Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon and nested GeometryCollection support
- WGS 84 bounds, point counts, great-circle LineString distance and elevation gain/loss - WGS 84 bounds, point counts, great-circle LineString distance and elevation gain/loss
- Explicit lossy conversion among the four supported formats - Explicit lossy conversion among the four supported formats
- Iterative DouglasPeucker LineString simplification and decimal-degree/DMS conversion - Iterative DouglasPeucker LineString simplification and decimal-degree/DMS conversion
- A coordinate-only local sketch with no map tiles or projection service - A coordinate-only local sketch with no map tiles or projection service
- Full-model GeoJSON geometry replacement, append/delete, nested coordinate editing and line-direction reversal with validation after every edit
- GPX-derived track inventory plus a local two-point distance, initial-bearing and midpoint ruler
The common model intentionally supports only Point, LineString and Polygon and preserves only scalar properties. There is no CRS transformation, basemap request or claim of survey-grade geodesy. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md). Geometry collections are limited to 20,000 geometry nodes, 16 nesting levels
and 200,000 coordinates. GPX/CSV conversions flatten geometry grouping and
report that loss. Scalar properties are preserved; there is no CRS
transformation, basemap request or claim of survey-grade geodesy. See
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development ## Development
@@ -26,7 +32,7 @@ npm run test:browser
## Release ## Release
`npm run release:artifact` creates a deterministic `release/geo-tools-0.1.0.zip` and checksum sidecar. `npm run release:artifact` creates a deterministic `release/geo-tools-0.2.0.zip` and checksum sidecar.
## Licence ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Geo Tools 0.1.0 is available at: The corresponding source for Geo Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/geo-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/geo-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ Geo Tools 0.1.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+4 -2
View File
@@ -1,7 +1,9 @@
# Architecture # Architecture
Geo Tools is a static React/Vite application wrapped in the shared Toolbox shell. `geo/formats.ts` parses GeoJSON, GPX, KML or a narrow coordinate-CSV dialect into a project-owned `FeatureCollection` model containing only Point, LineString and Polygon geometries plus scalar properties. Conversion serialises that model and returns visible loss notes. Geo Tools is a static React/Vite application wrapped in the shared Toolbox shell. `geo/formats.ts` parses GeoJSON, GPX, KML or a narrow coordinate-CSV dialect into a project-owned `FeatureCollection` model covering Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon and recursively bounded GeometryCollection values plus scalar properties. Conversion serialises that model and returns visible loss notes where a target format must flatten structure.
`geo/model.ts` validates WGS 84 coordinate ranges and enforces the collection-wide coordinate limit. `geo/analysis.ts` computes bounds, haversine line distance and elevation changes, and performs iterative DouglasPeucker simplification. The local sketch projects coordinates only into its own SVG viewport; it loads no basemap, tiles or projection service. `geo/model.ts` validates WGS 84 coordinate ranges and enforces collection-wide coordinate, geometry-count and nesting-depth limits. `geo/analysis.ts` computes bounds, haversine line distance and elevation changes, and performs iterative DouglasPeucker simplification across relevant nested path members. The local sketch projects every supported geometry path only into its own SVG viewport; it loads no basemap, tiles or projection service.
`geo/editor.ts` assigns stable feature/geometry/coordinate paths across the complete geometry union, performs immutable point or feature replacement, and sends every result back through the common validator before returning it. Track summaries recurse through GeometryCollections, and direction reversal is limited to line geometries. The local ruler returns a spherical distance, initial bearing and coordinate midpoint; it does not imply a routing path or projected measurement.
XML input is lexically bounded before DOM construction and rejects DOCTYPE, entity and stylesheet declarations. Version 0.1 uses no worker, storage or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path. XML input is lexically bounded before DOM construction and rejects DOCTYPE, entity and stylesheet declarations. Version 0.1 uses no worker, storage or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
+4 -2
View File
@@ -2,6 +2,8 @@
Imported coordinates and generated exports stay in page memory. The app has no telemetry, analytics, account, persistence, map-tile request or other runtime network path. Names and properties are rendered as React text and escaped on XML export. Imported coordinates and generated exports stay in page memory. The app has no telemetry, analytics, account, persistence, map-tile request or other runtime network path. Names and properties are rendered as React text and escaped on XML export.
Text/file input is limited to 16 MiB and the common model to 200,000 coordinates. XML is rejected above 250,000 elements or 256 levels before DOM parsing; DOCTYPE, entity and XML stylesheet declarations are rejected. These controls bound supported input but are not a general XML sanitiser. Text/file input is limited to 16 MiB and the common model to 200,000 coordinates, 20,000 geometries and 16 GeometryCollection levels. XML is rejected above 250,000 elements or 256 levels before DOM parsing; DOCTYPE, entity and XML stylesheet declarations are rejected. These controls bound supported input but are not a general XML sanitiser.
Coordinates are interpreted as WGS 84 longitude/latitude. There is no CRS transformation and the local SVG sketch is not a map projection. Distance is a spherical approximation, elevation gain/loss uses the supplied samples without smoothing, and conversion intentionally drops unsupported styles, extensions, geometry structure or properties as stated beside each result. Do not treat results as survey-grade measurements. Coordinates are interpreted as WGS 84 longitude/latitude. There is no CRS transformation and the local SVG sketch is not a map projection. Distance is a spherical approximation, elevation gain/loss uses the supplied samples without smoothing, and conversion intentionally drops unsupported styles, extensions, properties, or flattens geometry structure where the target format cannot preserve it, as stated beside each result. Do not treat results as survey-grade measurements.
Geometry editing validates coordinate bounds, model size and recursion limits but does not repair topology, ring winding, self-intersections or semantic feature relationships. GPX import retains track geometry and available elevations in the common model; timestamps, extensions and vendor-specific track metadata are not round-tripped. The two-point ruler measures a great-circle segment only and never contacts a routing or map service.
+22 -23
View File
@@ -1,22 +1,22 @@
{ {
"name": "geo-tools", "name": "geo-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "geo-tools", "name": "geo-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"license": "Apache-2.0", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"engines": { "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"node": ">=20" "license": "Apache-2.0"
}
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"engines": { "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"node": ">=22" "license": "GPL-3.0-or-later"
}
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -67,17 +67,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "geo-tools", "name": "geo-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, convert and analyse geospatial files locally in the browser.", "description": "Inspect, convert and analyse geospatial files locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, {
{ name: "firefox", use: { ...devices["Desktop Firefox"] } }, name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
], ],
}); });
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Added validated full-GeoJSON feature editing, append/delete, nested coordinate addressing, line-track reversal, and GPX-derived track inventories.
- Added a local great-circle distance/bearing/midpoint ruler and Toolbox format/capability metadata while retaining the tile-free, WGS 84-only boundary.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Added the initial local-first Geo Tools workbench. - Added the initial local-first Geo Tools workbench.
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
============================================================================== ==============================================================================
@add-ideas/toolbox-helpers@0.1.0 @add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
============================================================================== ==============================================================================
@add-ideas/toolbox-shell-react@0.2.3 @add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+10 -4
View File
@@ -1,18 +1,24 @@
# Geo Tools # Geo Tools
Inspect, convert and analyse geospatial files locally in the browser. Inspect, edit, measure and convert geospatial files locally in the browser.
Geo Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded. Geo Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope ## Version 0.1 scope
- Bounded GeoJSON, GPX, KML and coordinate-CSV parsing into a small common model - Bounded GeoJSON, GPX, KML and coordinate-CSV parsing into a common model with Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon and nested GeometryCollection support
- WGS 84 bounds, point counts, great-circle LineString distance and elevation gain/loss - WGS 84 bounds, point counts, great-circle LineString distance and elevation gain/loss
- Explicit lossy conversion among the four supported formats - Explicit lossy conversion among the four supported formats
- Iterative DouglasPeucker LineString simplification and decimal-degree/DMS conversion - Iterative DouglasPeucker LineString simplification and decimal-degree/DMS conversion
- A coordinate-only local sketch with no map tiles or projection service - A coordinate-only local sketch with no map tiles or projection service
- Full-model GeoJSON geometry replacement, append/delete, nested coordinate editing and line-direction reversal with validation after every edit
- GPX-derived track inventory plus a local two-point distance, initial-bearing and midpoint ruler
The common model intentionally supports only Point, LineString and Polygon and preserves only scalar properties. There is no CRS transformation, basemap request or claim of survey-grade geodesy. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md). Geometry collections are limited to 20,000 geometry nodes, 16 nesting levels
and 200,000 coordinates. GPX/CSV conversions flatten geometry grouping and
report that loss. Scalar properties are preserved; there is no CRS
transformation, basemap request or claim of survey-grade geodesy. See
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development ## Development
@@ -26,7 +32,7 @@ npm run test:browser
## Release ## Release
`npm run release:artifact` creates a deterministic `release/geo-tools-0.1.0.zip` and checksum sidecar. `npm run release:artifact` creates a deterministic `release/geo-tools-0.2.0.zip` and checksum sidecar.
## Licence ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Geo Tools 0.1.0 is available at: The corresponding source for Geo Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/geo-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/geo-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ Geo Tools 0.1.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+4 -2
View File
@@ -1,7 +1,9 @@
# Architecture # Architecture
Geo Tools is a static React/Vite application wrapped in the shared Toolbox shell. `geo/formats.ts` parses GeoJSON, GPX, KML or a narrow coordinate-CSV dialect into a project-owned `FeatureCollection` model containing only Point, LineString and Polygon geometries plus scalar properties. Conversion serialises that model and returns visible loss notes. Geo Tools is a static React/Vite application wrapped in the shared Toolbox shell. `geo/formats.ts` parses GeoJSON, GPX, KML or a narrow coordinate-CSV dialect into a project-owned `FeatureCollection` model covering Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon and recursively bounded GeometryCollection values plus scalar properties. Conversion serialises that model and returns visible loss notes where a target format must flatten structure.
`geo/model.ts` validates WGS 84 coordinate ranges and enforces the collection-wide coordinate limit. `geo/analysis.ts` computes bounds, haversine line distance and elevation changes, and performs iterative DouglasPeucker simplification. The local sketch projects coordinates only into its own SVG viewport; it loads no basemap, tiles or projection service. `geo/model.ts` validates WGS 84 coordinate ranges and enforces collection-wide coordinate, geometry-count and nesting-depth limits. `geo/analysis.ts` computes bounds, haversine line distance and elevation changes, and performs iterative DouglasPeucker simplification across relevant nested path members. The local sketch projects every supported geometry path only into its own SVG viewport; it loads no basemap, tiles or projection service.
`geo/editor.ts` assigns stable feature/geometry/coordinate paths across the complete geometry union, performs immutable point or feature replacement, and sends every result back through the common validator before returning it. Track summaries recurse through GeometryCollections, and direction reversal is limited to line geometries. The local ruler returns a spherical distance, initial bearing and coordinate midpoint; it does not imply a routing path or projected measurement.
XML input is lexically bounded before DOM construction and rejects DOCTYPE, entity and stylesheet declarations. Version 0.1 uses no worker, storage or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path. XML input is lexically bounded before DOM construction and rejects DOCTYPE, entity and stylesheet declarations. Version 0.1 uses no worker, storage or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
+4 -2
View File
@@ -2,6 +2,8 @@
Imported coordinates and generated exports stay in page memory. The app has no telemetry, analytics, account, persistence, map-tile request or other runtime network path. Names and properties are rendered as React text and escaped on XML export. Imported coordinates and generated exports stay in page memory. The app has no telemetry, analytics, account, persistence, map-tile request or other runtime network path. Names and properties are rendered as React text and escaped on XML export.
Text/file input is limited to 16 MiB and the common model to 200,000 coordinates. XML is rejected above 250,000 elements or 256 levels before DOM parsing; DOCTYPE, entity and XML stylesheet declarations are rejected. These controls bound supported input but are not a general XML sanitiser. Text/file input is limited to 16 MiB and the common model to 200,000 coordinates, 20,000 geometries and 16 GeometryCollection levels. XML is rejected above 250,000 elements or 256 levels before DOM parsing; DOCTYPE, entity and XML stylesheet declarations are rejected. These controls bound supported input but are not a general XML sanitiser.
Coordinates are interpreted as WGS 84 longitude/latitude. There is no CRS transformation and the local SVG sketch is not a map projection. Distance is a spherical approximation, elevation gain/loss uses the supplied samples without smoothing, and conversion intentionally drops unsupported styles, extensions, geometry structure or properties as stated beside each result. Do not treat results as survey-grade measurements. Coordinates are interpreted as WGS 84 longitude/latitude. There is no CRS transformation and the local SVG sketch is not a map projection. Distance is a spherical approximation, elevation gain/loss uses the supplied samples without smoothing, and conversion intentionally drops unsupported styles, extensions, properties, or flattens geometry structure where the target format cannot preserve it, as stated beside each result. Do not treat results as survey-grade measurements.
Geometry editing validates coordinate bounds, model size and recursion limits but does not repair topology, ring winding, self-intersections or semantic feature relationships. GPX import retains track geometry and available elevations in the common model; timestamps, extensions and vendor-specific track metadata are not round-tripped. The two-point ruler measures a great-circle segment only and never contacts a routing or map service.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "geo-tools-shell-"; const CACHE_PREFIX = "geo-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0"; const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+58 -3
View File
@@ -3,12 +3,20 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.geo-tools", "id": "de.add-ideas.geo-tools",
"name": "Geo Tools", "name": "Geo Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, convert and analyse geospatial files locally in the browser.", "description": "Inspect, edit, measure and convert geospatial files locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["geography", "data", "files"], "categories": ["geography", "data", "files"],
"tags": ["geojson", "gpx", "kml", "track", "coordinates"], "tags": [
"geojson",
"gpx",
"kml",
"track",
"geometry",
"measure",
"coordinates"
],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +29,53 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "application/geo+json",
"extensions": [".geojson", ".json"],
"label": "GeoJSON"
},
{
"mediaType": "application/gpx+xml",
"extensions": [".gpx"],
"label": "GPX"
},
{
"mediaType": "application/vnd.google-earth.kml+xml",
"extensions": [".kml"],
"label": "KML"
},
{
"mediaType": "text/csv",
"extensions": [".csv"],
"label": "Coordinate CSV"
}
],
"produces": [
{
"mediaType": "application/geo+json",
"extensions": [".geojson", ".json"],
"label": "GeoJSON"
},
{
"mediaType": "application/gpx+xml",
"extensions": [".gpx"],
"label": "GPX"
},
{
"mediaType": "application/vnd.google-earth.kml+xml",
"extensions": [".kml"],
"label": "KML"
},
{
"mediaType": "text/csv",
"extensions": [".csv"],
"label": "Coordinate CSV"
}
]
},
"capabilities": { "required": [], "optional": ["clipboard-write"] },
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+257 -19
View File
@@ -1,9 +1,19 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers"; import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { analyseCollection, simplifyCollection, toDms } from "../geo/analysis"; import { analyseCollection, simplifyCollection, toDms } from "../geo/analysis";
import {
appendFeature,
listPositionReferences,
measureSegment,
removeFeature,
replaceFeature,
reverseFeatureTracks,
summarizeTracks,
} from "../geo/editor";
import { detectAndParse, serializeGeo } from "../geo/formats"; import { detectAndParse, serializeGeo } from "../geo/formats";
import { import {
collectionPositions, collectionPositions,
geometryPaths,
type GeoCollection, type GeoCollection,
type GeoParseResult, type GeoParseResult,
type Position, type Position,
@@ -22,18 +32,22 @@ const example = `{
type Format = GeoParseResult["sourceFormat"]; type Format = GeoParseResult["sourceFormat"];
function featureJson(result: GeoParseResult, index: number): string {
const feature = result.collection.features[index];
return feature ? JSON.stringify(feature, null, 2) : "";
}
function pointsForGeometry( function pointsForGeometry(
collection: GeoCollection, collection: GeoCollection,
): { points: Position[]; closed: boolean }[] { ): { points: Position[]; closed: boolean }[] {
const paths: { points: Position[]; closed: boolean }[] = []; const paths: { points: Position[]; closed: boolean }[] = [];
for (const feature of collection.features) { for (const feature of collection.features) {
if (feature.geometry.type === "Point") paths.push(
paths.push({ points: [feature.geometry.coordinates], closed: false }); ...geometryPaths(feature.geometry).map(({ points, closed }) => ({
else if (feature.geometry.type === "LineString") points,
paths.push({ points: feature.geometry.coordinates, closed: false }); closed,
else })),
for (const points of feature.geometry.coordinates) );
paths.push({ points, closed: true });
} }
return paths; return paths;
} }
@@ -99,6 +113,12 @@ export function Workbench() {
const [tolerance, setTolerance] = useState(10); const [tolerance, setTolerance] = useState(10);
const [latitude, setLatitude] = useState(52.52); const [latitude, setLatitude] = useState(52.52);
const [longitude, setLongitude] = useState(13.405); const [longitude, setLongitude] = useState(13.405);
const [featureIndex, setFeatureIndex] = useState(0);
const [featureDraft, setFeatureDraft] = useState(() =>
featureJson(detectAndParse(example, "auto"), 0),
);
const [measureStart, setMeasureStart] = useState("13.3777,52.5163");
const [measureEnd, setMeasureEnd] = useState("13.4050,52.5200");
const statistics = useMemo( const statistics = useMemo(
() => analyseCollection(parsed.collection), () => analyseCollection(parsed.collection),
[parsed], [parsed],
@@ -107,6 +127,21 @@ export function Workbench() {
() => serializeGeo(parsed.collection, target), () => serializeGeo(parsed.collection, target),
[parsed, target], [parsed, target],
); );
const tracks = useMemo(() => summarizeTracks(parsed.collection), [parsed]);
const positionCount = useMemo(
() => listPositionReferences(parsed.collection).length,
[parsed],
);
const measurement = useMemo(() => {
try {
return measureSegment(
measureStart.split(",").map(Number),
measureEnd.split(",").map(Number),
);
} catch {
return undefined;
}
}, [measureEnd, measureStart]);
const coordinateOutput = useMemo(() => { const coordinateOutput = useMemo(() => {
try { try {
return [toDms(latitude, "latitude"), toDms(longitude, "longitude")]; return [toDms(latitude, "latitude"), toDms(longitude, "longitude")];
@@ -117,7 +152,10 @@ export function Workbench() {
const parse = () => { const parse = () => {
try { try {
setParsed(detectAndParse(source, sourceFormat)); const next = detectAndParse(source, sourceFormat);
setParsed(next);
setFeatureIndex(0);
setFeatureDraft(featureJson(next, 0));
setError(""); setError("");
} catch (reason) { } catch (reason) {
setError( setError(
@@ -146,7 +184,10 @@ export function Workbench() {
: "auto"; : "auto";
setSourceFormat(format); setSourceFormat(format);
try { try {
setParsed(detectAndParse(text, format)); const next = detectAndParse(text, format);
setParsed(next);
setFeatureIndex(0);
setFeatureDraft(featureJson(next, 0));
setError(""); setError("");
} catch (reason) { } catch (reason) {
setError( setError(
@@ -156,14 +197,16 @@ export function Workbench() {
}; };
const simplify = () => { const simplify = () => {
try { try {
setParsed((current) => ({ const next = {
...current, ...parsed,
collection: simplifyCollection(current.collection, tolerance), collection: simplifyCollection(parsed.collection, tolerance),
warnings: [ warnings: [
...current.warnings, ...parsed.warnings,
`LineStrings simplified with a ${tolerance} m equirectangular DouglasPeucker tolerance.`, `LineStrings simplified with a ${tolerance} m equirectangular DouglasPeucker tolerance.`,
], ],
})); };
setParsed(next);
setFeatureDraft(featureJson(next, featureIndex));
setError(""); setError("");
} catch (reason) { } catch (reason) {
setError( setError(
@@ -171,6 +214,33 @@ export function Workbench() {
); );
} }
}; };
const editFeature = (action: "replace" | "append") => {
try {
const value = JSON.parse(featureDraft) as unknown;
const collection =
action === "replace"
? replaceFeature(parsed.collection, featureIndex, value)
: appendFeature(parsed.collection, value);
const next = {
...parsed,
collection: collection,
warnings: [
...parsed.warnings,
`Feature ${action === "replace" ? "replaced" : "appended"} in the local editable model.`,
],
};
const nextIndex =
action === "append" ? collection.features.length - 1 : featureIndex;
setParsed(next);
setFeatureIndex(nextIndex);
setFeatureDraft(featureJson(next, nextIndex));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Feature edit failed.",
);
}
};
return ( return (
<main className="workbench"> <main className="workbench">
@@ -274,6 +344,171 @@ export function Workbench() {
))} ))}
<LocalPlot collection={parsed.collection} /> <LocalPlot collection={parsed.collection} />
</section> </section>
<div className="grid">
<section className="panel workspace" aria-labelledby="edit-heading">
<div>
<p className="eyebrow">Geometry editor</p>
<h2 id="edit-heading">Edit any GeoJSON feature</h2>
<p className="muted">
The draft accepts the complete Point, MultiPoint, LineString,
MultiLineString, Polygon, MultiPolygon or GeometryCollection
model. Every replacement is re-parsed and bounded before it
becomes current.
</p>
</div>
<label className="field">
<span>Feature</span>
<select
value={featureIndex}
disabled={!parsed.collection.features.length}
onChange={(event) => {
const nextIndex = Number(event.target.value);
setFeatureIndex(nextIndex);
setFeatureDraft(featureJson(parsed, nextIndex));
}}
>
{parsed.collection.features.map((feature, index) => (
<option value={index} key={index}>
{index + 1}:{" "}
{String(feature.properties.name ?? feature.geometry.type)}
</option>
))}
</select>
</label>
<textarea
value={featureDraft}
onChange={(event) => setFeatureDraft(event.target.value)}
aria-label="Editable GeoJSON feature"
spellCheck={false}
/>
<div className="actions">
<button type="button" onClick={() => editFeature("replace")}>
Apply feature edit
</button>
<button type="button" onClick={() => editFeature("append")}>
Append as new feature
</button>
<button
type="button"
disabled={!parsed.collection.features.length}
onClick={() => {
try {
const next = {
...parsed,
collection: removeFeature(parsed.collection, featureIndex),
};
const nextIndex = Math.min(
featureIndex,
Math.max(0, next.collection.features.length - 1),
);
setParsed(next);
setFeatureIndex(nextIndex);
setFeatureDraft(featureJson(next, nextIndex));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Delete failed.",
);
}
}}
>
Delete feature
</button>
<button
type="button"
disabled={
!tracks.some((track) => track.featureIndex === featureIndex)
}
onClick={() => {
const next = {
...parsed,
collection: reverseFeatureTracks(
parsed.collection,
featureIndex,
),
warnings: [
...parsed.warnings,
"Selected linear track direction reversed.",
],
};
setParsed(next);
setFeatureDraft(featureJson(next, featureIndex));
}}
>
Reverse track direction
</button>
</div>
<p className="muted">
{positionCount.toLocaleString()} editable coordinate references in
the current model. Polygon ring validity and self-intersection are
not topologically repaired.
</p>
</section>
<section className="panel workspace" aria-labelledby="tracks-heading">
<div>
<p className="eyebrow">GPX / linear tracks</p>
<h2 id="tracks-heading">Track inventory</h2>
</div>
{tracks.length ? (
<ul className="track-list">
{tracks.map((track) => (
<li key={track.featureIndex}>
<strong>{track.name}</strong>
<span>
{track.pathCount} path(s) · {track.positions} positions ·{" "}
{(track.distanceMetres / 1_000).toFixed(3)} km · elevation{" "}
{track.hasElevation ? "present" : "absent"}
</span>
</li>
))}
</ul>
) : (
<p>No linear tracks in the current model.</p>
)}
<p className="muted">
GPX track segments remain separate paths in the common model;
timestamps and GPX extensions are still not preserved.
</p>
</section>
<section className="panel workspace" aria-labelledby="ruler-heading">
<div>
<p className="eyebrow">Local ruler</p>
<h2 id="ruler-heading">Measure two WGS 84 points</h2>
</div>
<label className="field">
<span>Start longitude,latitude[,elevation]</span>
<input
value={measureStart}
onChange={(event) => setMeasureStart(event.target.value)}
/>
</label>
<label className="field">
<span>End longitude,latitude[,elevation]</span>
<input
value={measureEnd}
onChange={(event) => setMeasureEnd(event.target.value)}
/>
</label>
{measurement ? (
<dl className="facts">
<div>
<dt>Great-circle distance</dt>
<dd>{measurement.distanceMetres.toFixed(2)} m</dd>
</div>
<div>
<dt>Initial bearing</dt>
<dd>{measurement.initialBearingDegrees.toFixed(2)}°</dd>
</div>
<div>
<dt>Coordinate midpoint</dt>
<dd>{measurement.midpoint.join(", ")}</dd>
</div>
</dl>
) : (
<p className="error">Enter two valid longitude,latitude pairs.</p>
)}
</section>
</div>
<div className="grid"> <div className="grid">
<section className="panel workspace" aria-labelledby="convert-heading"> <section className="panel workspace" aria-labelledby="convert-heading">
<div> <div>
@@ -314,8 +549,9 @@ export function Workbench() {
<p className="eyebrow">Simplify</p> <p className="eyebrow">Simplify</p>
<h2 id="simplify-heading">LineString vertices</h2> <h2 id="simplify-heading">LineString vertices</h2>
<p className="muted"> <p className="muted">
DouglasPeucker on a local equirectangular approximation; Points DouglasPeucker on a local equirectangular approximation;
and Polygons remain unchanged. LineStrings inside multi-geometries are included. Points and
Polygons remain unchanged.
</p> </p>
</div> </div>
<label className="field"> <label className="field">
@@ -371,9 +607,11 @@ export function Workbench() {
</div> </div>
<section className="panel workspace"> <section className="panel workspace">
<p className="notice"> <p className="notice">
v0.1 assumes WGS 84 longitude/latitude. It does not transform GeoJSON Point, LineString, Polygon, all three Multi geometries and
coordinate reference systems, fetch maps, preserve every format bounded nested GeometryCollections are supported. The tool assumes WGS
extension, or claim survey-grade geodesy. 84 longitude/latitude and does not transform coordinate reference
systems, fetch maps, preserve every format extension, or claim
survey-grade geodesy.
</p> </p>
</section> </section>
</main> </main>
+35 -20
View File
@@ -1,5 +1,6 @@
import { import {
collectionPositions, collectionPositions,
geometryPaths,
type GeoCollection, type GeoCollection,
type Position, type Position,
} from "./model"; } from "./model";
@@ -33,14 +34,12 @@ export function analyseCollection(collection: GeoCollection): GeoStatistics {
let ascent = 0; let ascent = 0;
let descent = 0; let descent = 0;
for (const feature of collection.features) for (const feature of collection.features)
if (feature.geometry.type === "LineString") for (const path of geometryPaths(feature.geometry).filter(
for ( (entry) => entry.line,
let index = 1; ))
index < feature.geometry.coordinates.length; for (let index = 1; index < path.points.length; index += 1) {
index += 1 const previous = path.points[index - 1]!;
) { const current = path.points[index]!;
const previous = feature.geometry.coordinates[index - 1]!;
const current = feature.geometry.coordinates[index]!;
distance += haversine(previous, current); distance += haversine(previous, current);
if (previous[2] !== undefined && current[2] !== undefined) { if (previous[2] !== undefined && current[2] !== undefined) {
const delta = current[2] - previous[2]; const delta = current[2] - previous[2];
@@ -138,21 +137,37 @@ export function simplifyCollection(
): GeoCollection { ): GeoCollection {
return { return {
...collection, ...collection,
features: collection.features.map((feature) => features: collection.features.map((feature) => ({
feature.geometry.type === "LineString"
? {
...feature, ...feature,
geometry: { geometry: simplifyGeometry(feature.geometry, toleranceMetres),
...feature.geometry, })),
coordinates: simplifyLine( };
feature.geometry.coordinates, }
toleranceMetres,
), function simplifyGeometry(
}, geometry: GeoCollection["features"][number]["geometry"],
} toleranceMetres: number,
: feature, ): GeoCollection["features"][number]["geometry"] {
if (geometry.type === "LineString")
return {
...geometry,
coordinates: simplifyLine(geometry.coordinates, toleranceMetres),
};
if (geometry.type === "MultiLineString")
return {
...geometry,
coordinates: geometry.coordinates.map((line) =>
simplifyLine(line, toleranceMetres),
), ),
}; };
if (geometry.type === "GeometryCollection")
return {
...geometry,
geometries: geometry.geometries.map((entry) =>
simplifyGeometry(entry, toleranceMetres),
),
};
return geometry;
} }
export function toDms(value: number, axis: "latitude" | "longitude"): string { export function toDms(value: number, axis: "latitude" | "longitude"): string {
+330
View File
@@ -0,0 +1,330 @@
import { haversine } from "./analysis";
import { parseGeoJson } from "./formats";
import {
geometryPaths,
validatePosition,
type GeoCollection,
type Geometry,
type Position,
} from "./model";
export interface PositionReference {
readonly featureIndex: number;
readonly geometryPath: readonly number[];
readonly coordinatePath: readonly number[];
readonly label: string;
readonly position: Position;
}
export interface TrackSummary {
readonly featureIndex: number;
readonly name: string;
readonly pathCount: number;
readonly positions: number;
readonly distanceMetres: number;
readonly hasElevation: boolean;
}
export interface SegmentMeasurement {
readonly start: Position;
readonly end: Position;
readonly distanceMetres: number;
readonly initialBearingDegrees: number;
readonly midpoint: Position;
}
function coordinateReferences(
geometry: Geometry,
featureIndex: number,
geometryPath: readonly number[],
): PositionReference[] {
const make = (position: Position, coordinatePath: readonly number[]) => ({
featureIndex,
geometryPath,
coordinatePath,
label: `feature ${featureIndex + 1} / geometry ${geometryPath.length ? geometryPath.join(".") : "root"} / coordinate ${coordinatePath.length ? coordinatePath.join(".") : "point"}`,
position,
});
if (geometry.type === "Point") return [make(geometry.coordinates, [])];
if (geometry.type === "LineString" || geometry.type === "MultiPoint")
return geometry.coordinates.map((position, index) =>
make(position, [index]),
);
if (geometry.type === "Polygon" || geometry.type === "MultiLineString")
return geometry.coordinates.flatMap((line, lineIndex) =>
line.map((position, pointIndex) =>
make(position, [lineIndex, pointIndex]),
),
);
if (geometry.type === "MultiPolygon")
return geometry.coordinates.flatMap((polygon, polygonIndex) =>
polygon.flatMap((ring, ringIndex) =>
ring.map((position, pointIndex) =>
make(position, [polygonIndex, ringIndex, pointIndex]),
),
),
);
return geometry.geometries.flatMap((child, index) =>
coordinateReferences(child, featureIndex, [...geometryPath, index]),
);
}
export function listPositionReferences(
collection: GeoCollection,
): readonly PositionReference[] {
const output = collection.features.flatMap((feature, featureIndex) =>
coordinateReferences(feature.geometry, featureIndex, []),
);
if (output.length > 200_000)
throw new RangeError(
"Coordinate editor exceeds the 200,000-position limit.",
);
return Object.freeze(output);
}
function replaceCoordinate(
geometry: Geometry,
path: readonly number[],
position: Position,
): Geometry {
const get = (index: number) => {
const value = path[index];
if (!Number.isSafeInteger(value) || value! < 0)
throw new RangeError("Coordinate reference is invalid.");
return value!;
};
if (geometry.type === "Point") {
if (path.length) throw new RangeError("Point coordinate path is invalid.");
return { ...geometry, coordinates: position };
}
if (geometry.type === "LineString" || geometry.type === "MultiPoint") {
if (path.length !== 1 || !geometry.coordinates[get(0)])
throw new RangeError("Linear coordinate path is invalid.");
const coordinates = [...geometry.coordinates];
coordinates[get(0)] = position;
return { ...geometry, coordinates };
}
if (geometry.type === "Polygon" || geometry.type === "MultiLineString") {
if (path.length !== 2 || !geometry.coordinates[get(0)]?.[get(1)])
throw new RangeError("Nested coordinate path is invalid.");
const coordinates = geometry.coordinates.map((line) => [...line]);
coordinates[get(0)]![get(1)] = position;
return { ...geometry, coordinates } as Geometry;
}
if (geometry.type === "MultiPolygon") {
if (path.length !== 3 || !geometry.coordinates[get(0)]?.[get(1)]?.[get(2)])
throw new RangeError("MultiPolygon coordinate path is invalid.");
const coordinates = geometry.coordinates.map((polygon) =>
polygon.map((ring) => [...ring]),
);
coordinates[get(0)]![get(1)]![get(2)] = position;
return { ...geometry, coordinates };
}
throw new RangeError("GeometryCollection requires a geometry path.");
}
function replaceInGeometry(
geometry: Geometry,
geometryPath: readonly number[],
coordinatePath: readonly number[],
position: Position,
): Geometry {
if (!geometryPath.length)
return replaceCoordinate(geometry, coordinatePath, position);
if (geometry.type !== "GeometryCollection")
throw new RangeError("Geometry reference does not match the model.");
const [head, ...tail] = geometryPath;
if (!Number.isSafeInteger(head) || head! < 0 || !geometry.geometries[head!])
throw new RangeError("Geometry reference is invalid.");
const geometries = [...geometry.geometries];
geometries[head!] = replaceInGeometry(
geometries[head!]!,
tail,
coordinatePath,
position,
);
return { ...geometry, geometries };
}
export function updatePosition(
collection: GeoCollection,
reference: Omit<PositionReference, "label" | "position">,
value: unknown,
): GeoCollection {
const feature = collection.features[reference.featureIndex];
if (!feature) throw new RangeError("Feature reference is invalid.");
const position = validatePosition(value);
const features = [...collection.features];
features[reference.featureIndex] = {
...feature,
geometry: replaceInGeometry(
feature.geometry,
reference.geometryPath,
reference.coordinatePath,
position,
),
};
return parseGeoJson(JSON.stringify({ ...collection, features })).collection;
}
export function replaceFeature(
collection: GeoCollection,
featureIndex: number,
value: unknown,
): GeoCollection {
const parsed = parseGeoJson(
JSON.stringify({ type: "FeatureCollection", features: [value] }),
).collection.features[0];
if (!parsed || !collection.features[featureIndex])
throw new RangeError("Feature reference is invalid.");
const features = [...collection.features];
features[featureIndex] = parsed;
return parseGeoJson(JSON.stringify({ type: "FeatureCollection", features }))
.collection;
}
export function appendFeature(
collection: GeoCollection,
value: unknown,
): GeoCollection {
if (collection.features.length >= 20_000)
throw new RangeError("Feature count exceeds the 20,000-feature limit.");
const parsed = parseGeoJson(
JSON.stringify({ type: "FeatureCollection", features: [value] }),
).collection.features[0];
if (!parsed) throw new TypeError("Feature is invalid.");
const next = {
type: "FeatureCollection" as const,
features: [...collection.features, parsed],
};
return parseGeoJson(JSON.stringify(next)).collection;
}
export function removeFeature(
collection: GeoCollection,
featureIndex: number,
): GeoCollection {
if (!collection.features[featureIndex])
throw new RangeError("Feature reference is invalid.");
return {
type: "FeatureCollection",
features: collection.features.filter(
(_feature, index) => index !== featureIndex,
),
};
}
function reverseGeometryTracks(geometry: Geometry): Geometry {
if (geometry.type === "LineString")
return { ...geometry, coordinates: [...geometry.coordinates].reverse() };
if (geometry.type === "MultiLineString")
return {
...geometry,
coordinates: geometry.coordinates.map((line) => [...line].reverse()),
};
if (geometry.type === "GeometryCollection")
return {
...geometry,
geometries: geometry.geometries.map(reverseGeometryTracks),
};
return geometry;
}
export function reverseFeatureTracks(
collection: GeoCollection,
featureIndex: number,
): GeoCollection {
const feature = collection.features[featureIndex];
if (!feature) throw new RangeError("Feature reference is invalid.");
const features = [...collection.features];
features[featureIndex] = {
...feature,
geometry: reverseGeometryTracks(feature.geometry),
};
return { type: "FeatureCollection", features };
}
export function summarizeTracks(
collection: GeoCollection,
): readonly TrackSummary[] {
return Object.freeze(
collection.features.flatMap((feature, featureIndex) => {
const paths = geometryPaths(feature.geometry).filter((path) => path.line);
if (!paths.length) return [];
let distanceMetres = 0;
for (const path of paths)
for (let index = 1; index < path.points.length; index += 1)
distanceMetres += haversine(
path.points[index - 1]!,
path.points[index]!,
);
return [
Object.freeze({
featureIndex,
name: String(
feature.properties.name ?? `Feature ${featureIndex + 1}`,
),
pathCount: paths.length,
positions: paths.reduce(
(total, path) => total + path.points.length,
0,
),
distanceMetres,
hasElevation: paths.some((path) =>
path.points.some((position) => position[2] !== undefined),
),
}),
];
}),
);
}
const radians = (degrees: number) => (degrees * Math.PI) / 180;
const degrees = (value: number) => (value * 180) / Math.PI;
export function measureSegment(
startInput: unknown,
endInput: unknown,
): SegmentMeasurement {
const start = validatePosition(startInput);
const end = validatePosition(endInput);
const deltaLongitude = radians(end[0] - start[0]);
const firstLatitude = radians(start[1]);
const secondLatitude = radians(end[1]);
const y = Math.sin(deltaLongitude) * Math.cos(secondLatitude);
const x =
Math.cos(firstLatitude) * Math.sin(secondLatitude) -
Math.sin(firstLatitude) *
Math.cos(secondLatitude) *
Math.cos(deltaLongitude);
const bearing = (degrees(Math.atan2(y, x)) + 360) % 360;
const longitudeOne = radians(start[0]);
const bx = Math.cos(secondLatitude) * Math.cos(deltaLongitude);
const by = Math.cos(secondLatitude) * Math.sin(deltaLongitude);
const denominatorX = Math.cos(firstLatitude) + bx;
const denominatorY = by;
if (Math.hypot(denominatorX, denominatorY) < 1e-12)
throw new RangeError(
"A unique great-circle midpoint is undefined for antipodal points.",
);
const midpointLatitude = Math.atan2(
Math.sin(firstLatitude) + Math.sin(secondLatitude),
Math.hypot(denominatorX, denominatorY),
);
const midpointLongitude = longitudeOne + Math.atan2(by, denominatorX);
const midpoint: Position = [
((degrees(midpointLongitude) + 540) % 360) - 180,
degrees(midpointLatitude),
start[2] === undefined || end[2] === undefined
? undefined
: (start[2] + end[2]) / 2,
];
if (midpoint[2] === undefined) midpoint.pop();
return {
start,
end,
distanceMetres: haversine(start, end),
initialBearingDegrees: bearing,
midpoint,
};
}
+181 -50
View File
@@ -1,5 +1,7 @@
import { import {
collectionPositions, collectionPositions,
MAX_GEOMETRIES,
MAX_GEOMETRY_DEPTH,
MAX_COORDINATES, MAX_COORDINATES,
validatePosition, validatePosition,
type GeoCollection, type GeoCollection,
@@ -74,10 +76,27 @@ function safeProperties(
return output; return output;
} }
function geoJsonGeometry(value: unknown): Geometry { function geoJsonGeometry(
value: unknown,
depth = 0,
count = { value: 0 },
): Geometry {
count.value += 1;
if (count.value > MAX_GEOMETRIES)
throw new Error(
`GeoJSON exceeds the ${MAX_GEOMETRIES.toLocaleString()} geometry limit.`,
);
if (depth > MAX_GEOMETRY_DEPTH)
throw new Error(
`GeometryCollection exceeds the ${MAX_GEOMETRY_DEPTH}-level nesting limit.`,
);
if (!value || typeof value !== "object") if (!value || typeof value !== "object")
throw new Error("Feature geometry is missing."); throw new Error("Feature geometry is missing.");
const geometry = value as { type?: unknown; coordinates?: unknown }; const geometry = value as {
type?: unknown;
coordinates?: unknown;
geometries?: unknown;
};
if (geometry.type === "Point") if (geometry.type === "Point")
return { return {
type: "Point", type: "Point",
@@ -96,8 +115,48 @@ function geoJsonGeometry(value: unknown): Geometry {
return ring.map(validatePosition); return ring.map(validatePosition);
}), }),
}; };
if (geometry.type === "MultiPoint" && Array.isArray(geometry.coordinates))
return {
type: "MultiPoint",
coordinates: geometry.coordinates.map(validatePosition),
};
if (
geometry.type === "MultiLineString" &&
Array.isArray(geometry.coordinates)
)
return {
type: "MultiLineString",
coordinates: geometry.coordinates.map((line) => {
if (!Array.isArray(line))
throw new Error("MultiLineString line is invalid.");
return line.map(validatePosition);
}),
};
if (geometry.type === "MultiPolygon" && Array.isArray(geometry.coordinates))
return {
type: "MultiPolygon",
coordinates: geometry.coordinates.map((polygon) => {
if (!Array.isArray(polygon))
throw new Error("MultiPolygon polygon is invalid.");
return polygon.map((ring) => {
if (!Array.isArray(ring))
throw new Error("MultiPolygon ring is invalid.");
return ring.map(validatePosition);
});
}),
};
if (
geometry.type === "GeometryCollection" &&
Array.isArray(geometry.geometries)
)
return {
type: "GeometryCollection",
geometries: geometry.geometries.map((entry) =>
geoJsonGeometry(entry, depth + 1, count),
),
};
throw new Error( throw new Error(
`Geometry ${String(geometry.type)} is unsupported in v0.1; use Point, LineString, or Polygon.`, `Geometry ${String(geometry.type)} is not a supported GeoJSON geometry.`,
); );
} }
@@ -165,6 +224,12 @@ function localElements(parent: ParentNode, name: string): Element[] {
return [...(parent as Document | Element).getElementsByTagNameNS("*", name)]; return [...(parent as Document | Element).getElementsByTagNameNS("*", name)];
} }
function directLocalElements(parent: Element, name?: string): Element[] {
return [...parent.children].filter(
(child) => name === undefined || child.localName === name,
);
}
function pointFromAttributes(element: Element): Position { function pointFromAttributes(element: Element): Position {
const latitude = Number(element.getAttribute("lat")); const latitude = Number(element.getAttribute("lat"));
const longitude = Number(element.getAttribute("lon")); const longitude = Number(element.getAttribute("lon"));
@@ -250,38 +315,16 @@ export function parseKml(source: string): GeoParseResult {
name: name:
localElements(placemark, "name")[0]?.textContent?.trim() ?? "Placemark", localElements(placemark, "name")[0]?.textContent?.trim() ?? "Placemark",
}; };
const point = localElements(placemark, "Point")[0]; const geometryElement = directLocalElements(placemark).find((element) =>
const line = localElements(placemark, "LineString")[0]; ["Point", "LineString", "Polygon", "MultiGeometry"].includes(
const polygon = localElements(placemark, "Polygon")[0]; element.localName,
if (point) {
const coordinate = kmlPositions(
localElements(point, "coordinates")[0]?.textContent ?? "",
)[0];
if (coordinate)
features.push({
type: "Feature",
properties,
geometry: { type: "Point", coordinates: coordinate },
});
} else if (line)
features.push({
type: "Feature",
properties,
geometry: {
type: "LineString",
coordinates: kmlPositions(
localElements(line, "coordinates")[0]?.textContent ?? "",
), ),
},
});
else if (polygon) {
const rings = localElements(polygon, "LinearRing").map((ring) =>
kmlPositions(localElements(ring, "coordinates")[0]?.textContent ?? ""),
); );
if (geometryElement) {
features.push({ features.push({
type: "Feature", type: "Feature",
properties, properties,
geometry: { type: "Polygon", coordinates: rings }, geometry: parseKmlGeometry(geometryElement),
}); });
} }
} }
@@ -300,6 +343,47 @@ export function parseKml(source: string): GeoParseResult {
}; };
} }
function parseKmlGeometry(element: Element, depth = 0): Geometry {
if (depth > MAX_GEOMETRY_DEPTH)
throw new Error(
`KML MultiGeometry exceeds the ${MAX_GEOMETRY_DEPTH}-level nesting limit.`,
);
if (element.localName === "Point") {
const coordinate = kmlPositions(
localElements(element, "coordinates")[0]?.textContent ?? "",
)[0];
if (!coordinate) throw new Error("KML Point has no coordinate.");
return { type: "Point", coordinates: coordinate };
}
if (element.localName === "LineString")
return {
type: "LineString",
coordinates: kmlPositions(
localElements(element, "coordinates")[0]?.textContent ?? "",
),
};
if (element.localName === "Polygon")
return {
type: "Polygon",
coordinates: localElements(element, "LinearRing").map((ring) =>
kmlPositions(localElements(ring, "coordinates")[0]?.textContent ?? ""),
),
};
const children = directLocalElements(element).filter((child) =>
["Point", "LineString", "Polygon", "MultiGeometry"].includes(
child.localName,
),
);
if (children.length > MAX_GEOMETRIES)
throw new Error(
`KML MultiGeometry exceeds the ${MAX_GEOMETRIES.toLocaleString()} geometry limit.`,
);
return {
type: "GeometryCollection",
geometries: children.map((child) => parseKmlGeometry(child, depth + 1)),
};
}
function csvRow(line: string): string[] { function csvRow(line: string): string[] {
const values: string[] = []; const values: string[] = [];
let value = ""; let value = "";
@@ -424,12 +508,7 @@ export function serializeGeo(
if (format === "csv") { if (format === "csv") {
const rows = ["feature,position,name,longitude,latitude,elevation"]; const rows = ["feature,position,name,longitude,latitude,elevation"];
collection.features.forEach((feature, featureIndex) => { collection.features.forEach((feature, featureIndex) => {
const positions = const positions = positionsForGeometry(feature.geometry);
feature.geometry.type === "Point"
? [feature.geometry.coordinates]
: feature.geometry.type === "LineString"
? feature.geometry.coordinates
: feature.geometry.coordinates.flat();
positions.forEach((position, positionIndex) => positions.forEach((position, positionIndex) =>
rows.push( rows.push(
[ [
@@ -456,13 +535,15 @@ export function serializeGeo(
const body = collection.features const body = collection.features
.map((entry) => { .map((entry) => {
const name = xml(entry.properties.name ?? "Feature"); const name = xml(entry.properties.name ?? "Feature");
if (entry.geometry.type === "Point") const points = pointGeometries(entry.geometry).map(
return `<wpt lat="${entry.geometry.coordinates[1]}" lon="${entry.geometry.coordinates[0]}">${entry.geometry.coordinates[2] === undefined ? "" : `<ele>${entry.geometry.coordinates[2]}</ele>`}<name>${name}</name></wpt>`; (position) =>
const positions = `<wpt lat="${position[1]}" lon="${position[0]}">${position[2] === undefined ? "" : `<ele>${position[2]}</ele>`}<name>${name}</name></wpt>`,
entry.geometry.type === "LineString" );
? entry.geometry.coordinates const paths = pathGeometries(entry.geometry).map(
: (entry.geometry.coordinates[0] ?? []); (positions) =>
return `<trk><name>${name}</name><trkseg>${positions.map((position) => `<trkpt lat="${position[1]}" lon="${position[0]}">${position[2] === undefined ? "" : `<ele>${position[2]}</ele>`}</trkpt>`).join("")}</trkseg></trk>`; `<trkseg>${positions.map((position) => `<trkpt lat="${position[1]}" lon="${position[0]}">${position[2] === undefined ? "" : `<ele>${position[2]}</ele>`}</trkpt>`).join("")}</trkseg>`,
);
return `${points.join("")}${paths.length ? `<trk><name>${name}</name>${paths.join("")}</trk>` : ""}`;
}) })
.join(""); .join("");
return { return {
@@ -470,19 +551,14 @@ export function serializeGeo(
mime: "application/gpx+xml", mime: "application/gpx+xml",
extension: "gpx", extension: "gpx",
losses: [ losses: [
"Only names, coordinates, and elevation are exported; Polygon rings become tracks.", "Only names, coordinates, and elevation are exported; polygon rings and multi-line members become track segments, and geometry grouping is lossy.",
], ],
}; };
} }
const body = collection.features const body = collection.features
.map((entry) => { .map((entry) => {
const name = xml(entry.properties.name ?? "Feature"); const name = xml(entry.properties.name ?? "Feature");
const geometry = const geometry = serializeKmlGeometry(entry.geometry);
entry.geometry.type === "Point"
? `<Point><coordinates>${tuple(entry.geometry.coordinates)}</coordinates></Point>`
: entry.geometry.type === "LineString"
? `<LineString><coordinates>${entry.geometry.coordinates.map(tuple).join(" ")}</coordinates></LineString>`
: `<Polygon>${entry.geometry.coordinates.map((ring, index) => `<${index ? "innerBoundaryIs" : "outerBoundaryIs"}><LinearRing><coordinates>${ring.map(tuple).join(" ")}</coordinates></LinearRing></${index ? "innerBoundaryIs" : "outerBoundaryIs"}>`).join("")}</Polygon>`;
return `<Placemark><name>${name}</name>${geometry}</Placemark>`; return `<Placemark><name>${name}</name>${geometry}</Placemark>`;
}) })
.join(""); .join("");
@@ -495,3 +571,58 @@ export function serializeGeo(
], ],
}; };
} }
function positionsForGeometry(geometry: Geometry): Position[] {
if (geometry.type === "Point") return [geometry.coordinates];
if (geometry.type === "LineString" || geometry.type === "MultiPoint")
return geometry.coordinates;
if (geometry.type === "Polygon" || geometry.type === "MultiLineString")
return geometry.coordinates.flat();
if (geometry.type === "MultiPolygon") return geometry.coordinates.flat(2);
return geometry.geometries.flatMap(positionsForGeometry);
}
function pointGeometries(geometry: Geometry): Position[] {
if (geometry.type === "Point") return [geometry.coordinates];
if (geometry.type === "MultiPoint") return geometry.coordinates;
if (geometry.type === "GeometryCollection")
return geometry.geometries.flatMap(pointGeometries);
return [];
}
function pathGeometries(geometry: Geometry): Position[][] {
if (geometry.type === "LineString") return [geometry.coordinates];
if (geometry.type === "MultiLineString" || geometry.type === "Polygon")
return geometry.coordinates;
if (geometry.type === "MultiPolygon") return geometry.coordinates.flat();
if (geometry.type === "GeometryCollection")
return geometry.geometries.flatMap(pathGeometries);
return [];
}
function serializeKmlGeometry(geometry: Geometry): string {
if (geometry.type === "Point")
return `<Point><coordinates>${tuple(geometry.coordinates)}</coordinates></Point>`;
if (geometry.type === "LineString")
return `<LineString><coordinates>${geometry.coordinates.map(tuple).join(" ")}</coordinates></LineString>`;
if (geometry.type === "Polygon")
return `<Polygon>${geometry.coordinates.map((ring, index) => `<${index ? "innerBoundaryIs" : "outerBoundaryIs"}><LinearRing><coordinates>${ring.map(tuple).join(" ")}</coordinates></LinearRing></${index ? "innerBoundaryIs" : "outerBoundaryIs"}>`).join("")}</Polygon>`;
const children: Geometry[] =
geometry.type === "MultiPoint"
? geometry.coordinates.map((coordinates) => ({
type: "Point",
coordinates,
}))
: geometry.type === "MultiLineString"
? geometry.coordinates.map((coordinates) => ({
type: "LineString",
coordinates,
}))
: geometry.type === "MultiPolygon"
? geometry.coordinates.map((coordinates) => ({
type: "Polygon",
coordinates,
}))
: geometry.geometries;
return `<MultiGeometry>${children.map(serializeKmlGeometry).join("")}</MultiGeometry>`;
}
+71 -6
View File
@@ -6,7 +6,11 @@ export type Position = [
export type Geometry = export type Geometry =
| { type: "Point"; coordinates: Position } | { type: "Point"; coordinates: Position }
| { type: "LineString"; coordinates: Position[] } | { type: "LineString"; coordinates: Position[] }
| { type: "Polygon"; coordinates: Position[][] }; | { type: "Polygon"; coordinates: Position[][] }
| { type: "MultiPoint"; coordinates: Position[] }
| { type: "MultiLineString"; coordinates: Position[][] }
| { type: "MultiPolygon"; coordinates: Position[][][] }
| { type: "GeometryCollection"; geometries: Geometry[] };
export interface GeoFeature { export interface GeoFeature {
type: "Feature"; type: "Feature";
@@ -26,6 +30,8 @@ export interface GeoParseResult {
} }
export const MAX_COORDINATES = 200_000; export const MAX_COORDINATES = 200_000;
export const MAX_GEOMETRIES = 20_000;
export const MAX_GEOMETRY_DEPTH = 16;
export function validatePosition(value: unknown): Position { export function validatePosition(value: unknown): Position {
if (!Array.isArray(value) || value.length < 2 || value.length > 3) if (!Array.isArray(value) || value.length < 2 || value.length > 3)
@@ -49,17 +55,76 @@ export function validatePosition(value: unknown): Position {
export function collectionPositions(collection: GeoCollection): Position[] { export function collectionPositions(collection: GeoCollection): Position[] {
const positions: Position[] = []; const positions: Position[] = [];
let geometries = 0;
for (const feature of collection.features) { for (const feature of collection.features) {
if (feature.geometry.type === "Point") const stack: Array<{ geometry: Geometry; depth: number }> = [
positions.push(feature.geometry.coordinates); { geometry: feature.geometry, depth: 0 },
else if (feature.geometry.type === "LineString") ];
positions.push(...feature.geometry.coordinates); while (stack.length) {
const { geometry, depth } = stack.pop()!;
geometries += 1;
if (geometries > MAX_GEOMETRIES)
throw new Error(
`Input exceeds the ${MAX_GEOMETRIES.toLocaleString()} geometry limit.`,
);
if (depth > MAX_GEOMETRY_DEPTH)
throw new Error(
`GeometryCollection exceeds the ${MAX_GEOMETRY_DEPTH}-level nesting limit.`,
);
if (geometry.type === "Point") positions.push(geometry.coordinates);
else if (geometry.type === "LineString" || geometry.type === "MultiPoint")
positions.push(...geometry.coordinates);
else if (
geometry.type === "Polygon" ||
geometry.type === "MultiLineString"
)
for (const path of geometry.coordinates) positions.push(...path);
else if (geometry.type === "MultiPolygon")
for (const polygon of geometry.coordinates)
for (const ring of polygon) positions.push(...ring);
else else
for (const ring of feature.geometry.coordinates) positions.push(...ring); for (let index = geometry.geometries.length - 1; index >= 0; index -= 1)
stack.push({
geometry: geometry.geometries[index]!,
depth: depth + 1,
});
if (positions.length > MAX_COORDINATES) if (positions.length > MAX_COORDINATES)
throw new Error( throw new Error(
`Input exceeds the ${MAX_COORDINATES.toLocaleString()} coordinate limit.`, `Input exceeds the ${MAX_COORDINATES.toLocaleString()} coordinate limit.`,
); );
} }
}
return positions; return positions;
} }
export function geometryPaths(
geometry: Geometry,
): Array<{ points: Position[]; closed: boolean; line: boolean }> {
if (geometry.type === "Point")
return [{ points: [geometry.coordinates], closed: false, line: false }];
if (geometry.type === "MultiPoint")
return geometry.coordinates.map((point) => ({
points: [point],
closed: false,
line: false,
}));
if (geometry.type === "LineString")
return [{ points: geometry.coordinates, closed: false, line: true }];
if (geometry.type === "MultiLineString")
return geometry.coordinates.map((points) => ({
points,
closed: false,
line: true,
}));
if (geometry.type === "Polygon")
return geometry.coordinates.map((points) => ({
points,
closed: true,
line: false,
}));
if (geometry.type === "MultiPolygon")
return geometry.coordinates.flatMap((polygon) =>
polygon.map((points) => ({ points, closed: true, line: false })),
);
return geometry.geometries.flatMap(geometryPaths);
}
+58 -3
View File
@@ -3,12 +3,20 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.geo-tools", "id": "de.add-ideas.geo-tools",
"name": "Geo Tools", "name": "Geo Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, convert and analyse geospatial files locally in the browser.", "description": "Inspect, edit, measure and convert geospatial files locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["geography", "data", "files"], "categories": ["geography", "data", "files"],
"tags": ["geojson", "gpx", "kml", "track", "coordinates"], "tags": [
"geojson",
"gpx",
"kml",
"track",
"geometry",
"measure",
"coordinates"
],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +29,53 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "application/geo+json",
"extensions": [".geojson", ".json"],
"label": "GeoJSON"
},
{
"mediaType": "application/gpx+xml",
"extensions": [".gpx"],
"label": "GPX"
},
{
"mediaType": "application/vnd.google-earth.kml+xml",
"extensions": [".kml"],
"label": "KML"
},
{
"mediaType": "text/csv",
"extensions": [".csv"],
"label": "Coordinate CSV"
}
],
"produces": [
{
"mediaType": "application/geo+json",
"extensions": [".geojson", ".json"],
"label": "GeoJSON"
},
{
"mediaType": "application/gpx+xml",
"extensions": [".gpx"],
"label": "GPX"
},
{
"mediaType": "application/vnd.google-earth.kml+xml",
"extensions": [".kml"],
"label": "KML"
},
{
"mediaType": "text/csv",
"extensions": [".csv"],
"label": "Coordinate CSV"
}
]
},
"capabilities": { "required": [], "optional": ["clipboard-write"] },
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+33 -1
View File
@@ -44,6 +44,38 @@ test("parses coordinate CSV and updates the local analysis", async ({
expect(external).toEqual([]); expect(external).toEqual([]);
}); });
test("edits full-feature geometry, inventories tracks and measures locally", async ({
page,
}) => {
await page.goto("/deep/nested/geo/");
await expect(
page.getByRole("heading", { name: "Track inventory" }),
).toBeVisible();
await expect(
page
.getByRole("region", { name: "Track inventory" })
.getByText("Berlin walk", { exact: true }),
).toBeVisible();
await page.getByLabel("Editable GeoJSON feature").fill(
JSON.stringify({
type: "Feature",
properties: { name: "Edited point" },
geometry: { type: "Point", coordinates: [7.1, 50.7] },
}),
);
await page.getByRole("button", { name: "Apply feature edit" }).click();
const featurePicker = page
.getByRole("region", { name: "Edit any GeoJSON feature" })
.getByRole("combobox");
await expect(featurePicker.locator("option:checked")).toHaveText(
"1: Edited point",
);
await page.getByLabel("Start longitude,latitude[,elevation]").fill("0,0");
await page.getByLabel("End longitude,latitude[,elevation]").fill("1,0");
await expect(page.getByText(/111195/)).toBeVisible();
await expect(page.getByText("90.00°")).toBeVisible();
});
test("serves the release identity and hardened headers", async ({ test("serves the release identity and hardened headers", async ({
request, request,
}) => { }) => {
@@ -56,7 +88,7 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/geo/toolbox-app.json"); const manifest = await request.get("/deep/nested/geo/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.geo-tools", id: "de.add-ideas.geo-tools",
version: "0.1.0", version: "0.2.0",
entry: "./", entry: "./",
}); });
}); });
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/geo/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+155
View File
@@ -5,6 +5,15 @@ import {
simplifyLine, simplifyLine,
toDms, toDms,
} from "../../src/geo/analysis"; } from "../../src/geo/analysis";
import {
appendFeature,
listPositionReferences,
measureSegment,
replaceFeature,
reverseFeatureTracks,
summarizeTracks,
updatePosition,
} from "../../src/geo/editor";
import { import {
parseCoordinateCsv, parseCoordinateCsv,
parseGeoJson, parseGeoJson,
@@ -33,6 +42,70 @@ describe("geospatial formats", () => {
parseGeoJson(serializeGeo(result.collection, "geojson").text).collection, parseGeoJson(serializeGeo(result.collection, "geojson").text).collection,
).toEqual(result.collection); ).toEqual(result.collection);
}); });
it("preserves every GeoJSON multi geometry and bounded collections", () => {
const result = parseGeoJson(
JSON.stringify({
type: "Feature",
properties: { name: "Mixed" },
geometry: {
type: "GeometryCollection",
geometries: [
{
type: "MultiPoint",
coordinates: [
[1, 2],
[3, 4],
],
},
{
type: "MultiLineString",
coordinates: [
[
[0, 0],
[1, 0],
],
[
[2, 0],
[3, 0],
],
],
},
{
type: "MultiPolygon",
coordinates: [
[
[
[0, 0],
[1, 0],
[0, 0],
],
],
],
},
],
},
}),
);
expect(
parseGeoJson(serializeGeo(result.collection, "geojson").text).collection,
).toEqual(result.collection);
expect(analyseCollection(result.collection)).toMatchObject({
points: 9,
features: 1,
});
expect(serializeGeo(result.collection, "kml").text).toContain(
"<MultiGeometry>",
);
expect(serializeGeo(result.collection, "gpx").text).toContain("<trkseg>");
});
it("bounds nested GeometryCollections", () => {
let geometry: unknown = { type: "Point", coordinates: [0, 0] };
for (let index = 0; index < 18; index += 1)
geometry = { type: "GeometryCollection", geometries: [geometry] };
expect(() => parseGeoJson(JSON.stringify(geometry))).toThrow(
/nesting limit/iu,
);
});
}); });
describe("geospatial analysis", () => { describe("geospatial analysis", () => {
@@ -74,3 +147,85 @@ describe("geospatial analysis", () => {
it("formats directional DMS", () => it("formats directional DMS", () =>
expect(toDms(-13.5, "longitude")).toBe("13° 30 0.000″ W")); expect(toDms(-13.5, "longitude")).toBe("13° 30 0.000″ W"));
}); });
describe("bounded geometry editing and local measurement", () => {
const mixed = parseGeoJson(
JSON.stringify({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { name: "Track" },
geometry: {
type: "GeometryCollection",
geometries: [
{ type: "Point", coordinates: [13, 52] },
{
type: "MultiLineString",
coordinates: [
[
[13, 52, 10],
[13.01, 52, 20],
],
],
},
],
},
},
],
}),
).collection;
it("addresses and updates coordinates inside GeometryCollections", () => {
const references = listPositionReferences(mixed);
expect(references).toHaveLength(3);
const updated = updatePosition(mixed, references[2]!, [14, 53, 25]);
expect(listPositionReferences(updated)[2]?.position).toEqual([14, 53, 25]);
expect(listPositionReferences(mixed)[2]?.position).toEqual([13.01, 52, 20]);
});
it("replaces/appends full geometry features and reverses only tracks", () => {
const reversed = reverseFeatureTracks(mixed, 0);
expect(
listPositionReferences(reversed).map((item) => item.position),
).toEqual([
[13, 52],
[13.01, 52, 20],
[13, 52, 10],
]);
expect(summarizeTracks(reversed)[0]).toMatchObject({
pathCount: 1,
positions: 2,
hasElevation: true,
});
const replacement = {
type: "Feature",
properties: { name: "Area" },
geometry: {
type: "MultiPolygon",
coordinates: [
[
[
[0, 0],
[1, 0],
[0, 0],
],
],
],
},
};
expect(
replaceFeature(mixed, 0, replacement).features[0]?.geometry.type,
).toBe("MultiPolygon");
expect(appendFeature(mixed, replacement).features).toHaveLength(2);
});
it("measures a bounded great-circle segment and bearing", () => {
const result = measureSegment([0, 0], [1, 0]);
expect(result.distanceMetres).toBeCloseTo(111_195, -1);
expect(result.initialBearingDegrees).toBeCloseTo(90);
expect(result.midpoint).toEqual([0.5, 0]);
expect(measureSegment([179, 0], [-179, 0]).midpoint[0]).toBeCloseTo(-180);
expect(() => measureSegment([0, 0], [180, 0])).toThrow(/antipodal/u);
});
});