+257
-19
@@ -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 Douglas–Peucker 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">
|
||||
Douglas–Peucker on a local equirectangular approximation; Points
|
||||
and Polygons remain unchanged.
|
||||
Douglas–Peucker 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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
Reference in New Issue
Block a user