Release Geo Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 12:02:09 +02:00
parent 2acdb66399
commit 3538e4e12b
29 changed files with 1352 additions and 172 deletions
+257 -19
View File
@@ -1,9 +1,19 @@
import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
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 {
collectionPositions,
geometryPaths,
type GeoCollection,
type GeoParseResult,
type Position,
@@ -22,18 +32,22 @@ const example = `{
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(
collection: GeoCollection,
): { points: Position[]; closed: boolean }[] {
const paths: { points: Position[]; closed: boolean }[] = [];
for (const feature of collection.features) {
if (feature.geometry.type === "Point")
paths.push({ points: [feature.geometry.coordinates], closed: false });
else if (feature.geometry.type === "LineString")
paths.push({ points: feature.geometry.coordinates, closed: false });
else
for (const points of feature.geometry.coordinates)
paths.push({ points, closed: true });
paths.push(
...geometryPaths(feature.geometry).map(({ points, closed }) => ({
points,
closed,
})),
);
}
return paths;
}
@@ -99,6 +113,12 @@ export function Workbench() {
const [tolerance, setTolerance] = useState(10);
const [latitude, setLatitude] = useState(52.52);
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(
() => analyseCollection(parsed.collection),
[parsed],
@@ -107,6 +127,21 @@ export function Workbench() {
() => serializeGeo(parsed.collection, 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(() => {
try {
return [toDms(latitude, "latitude"), toDms(longitude, "longitude")];
@@ -117,7 +152,10 @@ export function Workbench() {
const parse = () => {
try {
setParsed(detectAndParse(source, sourceFormat));
const next = detectAndParse(source, sourceFormat);
setParsed(next);
setFeatureIndex(0);
setFeatureDraft(featureJson(next, 0));
setError("");
} catch (reason) {
setError(
@@ -146,7 +184,10 @@ export function Workbench() {
: "auto";
setSourceFormat(format);
try {
setParsed(detectAndParse(text, format));
const next = detectAndParse(text, format);
setParsed(next);
setFeatureIndex(0);
setFeatureDraft(featureJson(next, 0));
setError("");
} catch (reason) {
setError(
@@ -156,14 +197,16 @@ export function Workbench() {
};
const simplify = () => {
try {
setParsed((current) => ({
...current,
collection: simplifyCollection(current.collection, tolerance),
const next = {
...parsed,
collection: simplifyCollection(parsed.collection, tolerance),
warnings: [
...current.warnings,
...parsed.warnings,
`LineStrings simplified with a ${tolerance} m equirectangular DouglasPeucker tolerance.`,
],
}));
};
setParsed(next);
setFeatureDraft(featureJson(next, featureIndex));
setError("");
} catch (reason) {
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 (
<main className="workbench">
@@ -274,6 +344,171 @@ export function Workbench() {
))}
<LocalPlot collection={parsed.collection} />
</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">
<section className="panel workspace" aria-labelledby="convert-heading">
<div>
@@ -314,8 +549,9 @@ export function Workbench() {
<p className="eyebrow">Simplify</p>
<h2 id="simplify-heading">LineString vertices</h2>
<p className="muted">
DouglasPeucker on a local equirectangular approximation; Points
and Polygons remain unchanged.
DouglasPeucker on a local equirectangular approximation;
LineStrings inside multi-geometries are included. Points and
Polygons remain unchanged.
</p>
</div>
<label className="field">
@@ -371,9 +607,11 @@ export function Workbench() {
</div>
<section className="panel workspace">
<p className="notice">
v0.1 assumes WGS 84 longitude/latitude. It does not transform
coordinate reference systems, fetch maps, preserve every format
extension, or claim survey-grade geodesy.
GeoJSON Point, LineString, Polygon, all three Multi geometries and
bounded nested GeometryCollections are supported. The tool assumes WGS
84 longitude/latitude and does not transform coordinate reference
systems, fetch maps, preserve every format extension, or claim
survey-grade geodesy.
</p>
</section>
</main>
+37 -22
View File
@@ -1,5 +1,6 @@
import {
collectionPositions,
geometryPaths,
type GeoCollection,
type Position,
} from "./model";
@@ -33,14 +34,12 @@ export function analyseCollection(collection: GeoCollection): GeoStatistics {
let ascent = 0;
let descent = 0;
for (const feature of collection.features)
if (feature.geometry.type === "LineString")
for (
let index = 1;
index < feature.geometry.coordinates.length;
index += 1
) {
const previous = feature.geometry.coordinates[index - 1]!;
const current = feature.geometry.coordinates[index]!;
for (const path of geometryPaths(feature.geometry).filter(
(entry) => entry.line,
))
for (let index = 1; index < path.points.length; index += 1) {
const previous = path.points[index - 1]!;
const current = path.points[index]!;
distance += haversine(previous, current);
if (previous[2] !== undefined && current[2] !== undefined) {
const delta = current[2] - previous[2];
@@ -138,23 +137,39 @@ export function simplifyCollection(
): GeoCollection {
return {
...collection,
features: collection.features.map((feature) =>
feature.geometry.type === "LineString"
? {
...feature,
geometry: {
...feature.geometry,
coordinates: simplifyLine(
feature.geometry.coordinates,
toleranceMetres,
),
},
}
: feature,
),
features: collection.features.map((feature) => ({
...feature,
geometry: simplifyGeometry(feature.geometry, toleranceMetres),
})),
};
}
function simplifyGeometry(
geometry: GeoCollection["features"][number]["geometry"],
toleranceMetres: number,
): 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 {
const maximum = axis === "latitude" ? 90 : 180;
if (!Number.isFinite(value) || Math.abs(value) > maximum)
+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,
};
}
+183 -52
View File
@@ -1,5 +1,7 @@
import {
collectionPositions,
MAX_GEOMETRIES,
MAX_GEOMETRY_DEPTH,
MAX_COORDINATES,
validatePosition,
type GeoCollection,
@@ -74,10 +76,27 @@ function safeProperties(
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")
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")
return {
type: "Point",
@@ -96,8 +115,48 @@ function geoJsonGeometry(value: unknown): Geometry {
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(
`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)];
}
function directLocalElements(parent: Element, name?: string): Element[] {
return [...parent.children].filter(
(child) => name === undefined || child.localName === name,
);
}
function pointFromAttributes(element: Element): Position {
const latitude = Number(element.getAttribute("lat"));
const longitude = Number(element.getAttribute("lon"));
@@ -250,38 +315,16 @@ export function parseKml(source: string): GeoParseResult {
name:
localElements(placemark, "name")[0]?.textContent?.trim() ?? "Placemark",
};
const point = localElements(placemark, "Point")[0];
const line = localElements(placemark, "LineString")[0];
const polygon = localElements(placemark, "Polygon")[0];
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)
const geometryElement = directLocalElements(placemark).find((element) =>
["Point", "LineString", "Polygon", "MultiGeometry"].includes(
element.localName,
),
);
if (geometryElement) {
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 ?? ""),
);
features.push({
type: "Feature",
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[] {
const values: string[] = [];
let value = "";
@@ -424,12 +508,7 @@ export function serializeGeo(
if (format === "csv") {
const rows = ["feature,position,name,longitude,latitude,elevation"];
collection.features.forEach((feature, featureIndex) => {
const positions =
feature.geometry.type === "Point"
? [feature.geometry.coordinates]
: feature.geometry.type === "LineString"
? feature.geometry.coordinates
: feature.geometry.coordinates.flat();
const positions = positionsForGeometry(feature.geometry);
positions.forEach((position, positionIndex) =>
rows.push(
[
@@ -456,13 +535,15 @@ export function serializeGeo(
const body = collection.features
.map((entry) => {
const name = xml(entry.properties.name ?? "Feature");
if (entry.geometry.type === "Point")
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>`;
const positions =
entry.geometry.type === "LineString"
? entry.geometry.coordinates
: (entry.geometry.coordinates[0] ?? []);
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>`;
const points = pointGeometries(entry.geometry).map(
(position) =>
`<wpt lat="${position[1]}" lon="${position[0]}">${position[2] === undefined ? "" : `<ele>${position[2]}</ele>`}<name>${name}</name></wpt>`,
);
const paths = pathGeometries(entry.geometry).map(
(positions) =>
`<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("");
return {
@@ -470,19 +551,14 @@ export function serializeGeo(
mime: "application/gpx+xml",
extension: "gpx",
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
.map((entry) => {
const name = xml(entry.properties.name ?? "Feature");
const 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>`;
const geometry = serializeKmlGeometry(entry.geometry);
return `<Placemark><name>${name}</name>${geometry}</Placemark>`;
})
.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>`;
}
+76 -11
View File
@@ -6,7 +6,11 @@ export type Position = [
export type Geometry =
| { type: "Point"; 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 {
type: "Feature";
@@ -26,6 +30,8 @@ export interface GeoParseResult {
}
export const MAX_COORDINATES = 200_000;
export const MAX_GEOMETRIES = 20_000;
export const MAX_GEOMETRY_DEPTH = 16;
export function validatePosition(value: unknown): Position {
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[] {
const positions: Position[] = [];
let geometries = 0;
for (const feature of collection.features) {
if (feature.geometry.type === "Point")
positions.push(feature.geometry.coordinates);
else if (feature.geometry.type === "LineString")
positions.push(...feature.geometry.coordinates);
else
for (const ring of feature.geometry.coordinates) positions.push(...ring);
if (positions.length > MAX_COORDINATES)
throw new Error(
`Input exceeds the ${MAX_COORDINATES.toLocaleString()} coordinate limit.`,
);
const stack: Array<{ geometry: Geometry; depth: number }> = [
{ geometry: feature.geometry, depth: 0 },
];
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
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)
throw new Error(
`Input exceeds the ${MAX_COORDINATES.toLocaleString()} coordinate limit.`,
);
}
}
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,
"id": "de.add-ideas.geo-tools",
"name": "Geo Tools",
"version": "0.1.0",
"description": "Inspect, convert and analyse geospatial files locally in the browser.",
"version": "0.2.0",
"description": "Inspect, edit, measure and convert geospatial files locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["geography", "data", "files"],
"tags": ["geojson", "gpx", "kml", "track", "coordinates"],
"tags": [
"geojson",
"gpx",
"kml",
"track",
"geometry",
"measure",
"coordinates"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +29,53 @@
"crossOriginIsolated": 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": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";