Initial release of Geo Tools 0.1.0

This commit is contained in:
2026-09-01 02:55:06 +02:00
commit 2acdb66399
59 changed files with 8637 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<Suspense
fallback={
<p className="loading" role="status">
Preparing Geo Tools
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Application failure", error, info);
}
render() {
if (this.state.error)
return (
<main className="fatal">
<h1>Geo Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const node = dialog.current;
if (!node) return;
if (open && !node.open) node.showModal();
if (!open && node.open) node.close();
}, [open]);
return (
<dialog
ref={dialog}
className="help-dialog"
onClose={onClose}
onCancel={onClose}
aria-labelledby="help-title"
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first help</p>
<h2 id="help-title">About Geo Tools</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>
Inspect, convert and analyse geospatial files locally in the browser.
</p>
<p>
All processing is performed in this browser. Imported data is treated as
untrusted and bounded before parsing.
</p>
</dialog>
);
}
+381
View File
@@ -0,0 +1,381 @@
import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { analyseCollection, simplifyCollection, toDms } from "../geo/analysis";
import { detectAndParse, serializeGeo } from "../geo/formats";
import {
collectionPositions,
type GeoCollection,
type GeoParseResult,
type Position,
} from "../geo/model";
const example = `{
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"properties": { "name": "Berlin walk" },
"geometry": { "type": "LineString", "coordinates": [
[13.3777, 52.5163, 34], [13.3904, 52.5208, 38], [13.4050, 52.5200, 35]
] }
}]
}`;
type Format = GeoParseResult["sourceFormat"];
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 });
}
return paths;
}
function LocalPlot({ collection }: { collection: GeoCollection }) {
const positions = collectionPositions(collection);
if (!positions.length) return null;
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const point of positions) {
minX = Math.min(minX, point[0]);
maxX = Math.max(maxX, point[0]);
minY = Math.min(minY, point[1]);
maxY = Math.max(maxY, point[1]);
}
const width = Math.max(maxX - minX, 0.000_001);
const height = Math.max(maxY - minY, 0.000_001);
const project = (point: Position) =>
`${30 + ((point[0] - minX) / width) * 740},${30 + ((maxY - point[1]) / height) * 340}`;
return (
<figure className="plot">
<svg
viewBox="0 0 800 400"
role="img"
aria-label="Local coordinate plot without a basemap"
>
<rect width="800" height="400" rx="16" className="plot-background" />
{pointsForGeometry(collection).map((path, index) =>
path.points.length === 1 ? (
<circle
key={index}
cx={Number(project(path.points[0]!).split(",")[0])}
cy={Number(project(path.points[0]!).split(",")[1])}
r="6"
/>
) : (
<polyline
key={index}
points={path.points.map(project).join(" ")}
className={path.closed ? "closed" : ""}
/>
),
)}
</svg>
<figcaption>
Coordinate-only equirectangular sketch no external tiles or geodetic
projection. Tracks crossing the antimeridian can appear stretched.
</figcaption>
</figure>
);
}
export function Workbench() {
const [source, setSource] = useState(example);
const [sourceFormat, setSourceFormat] = useState<"auto" | Format>("auto");
const [parsed, setParsed] = useState<GeoParseResult>(() =>
detectAndParse(example, "auto"),
);
const [error, setError] = useState("");
const [target, setTarget] = useState<Format>("geojson");
const [tolerance, setTolerance] = useState(10);
const [latitude, setLatitude] = useState(52.52);
const [longitude, setLongitude] = useState(13.405);
const statistics = useMemo(
() => analyseCollection(parsed.collection),
[parsed],
);
const converted = useMemo(
() => serializeGeo(parsed.collection, target),
[parsed, target],
);
const coordinateOutput = useMemo(() => {
try {
return [toDms(latitude, "latitude"), toDms(longitude, "longitude")];
} catch {
return ["Enter coordinates within the valid ranges."];
}
}, [latitude, longitude]);
const parse = () => {
try {
setParsed(detectAndParse(source, sourceFormat));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Input could not be parsed.",
);
}
};
const open = async (file: File | undefined) => {
if (!file) return;
if (file.size > 16 * 1024 * 1024) {
setError("File exceeds the 16 MiB limit.");
return;
}
const text = await file.text();
setSource(text);
const extension = file.name.split(".").at(-1)?.toLowerCase();
const format: "auto" | Format =
extension === "geojson" || extension === "json"
? "geojson"
: extension === "gpx"
? "gpx"
: extension === "kml"
? "kml"
: extension === "csv"
? "csv"
: "auto";
setSourceFormat(format);
try {
setParsed(detectAndParse(text, format));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "File could not be parsed.",
);
}
};
const simplify = () => {
try {
setParsed((current) => ({
...current,
collection: simplifyCollection(current.collection, tolerance),
warnings: [
...current.warnings,
`LineStrings simplified with a ${tolerance} m equirectangular DouglasPeucker tolerance.`,
],
}));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Simplification failed.",
);
}
};
return (
<main className="workbench">
<header className="hero">
<div>
<p className="eyebrow">Local geospatial workbench</p>
<h1>Geo Tools</h1>
<p>
Inspect, convert, simplify, and analyse GeoJSON, GPX, KML, and
coordinate CSV locally.
</p>
</div>
<span className="privacy-pill">No map requests</span>
</header>
<section className="panel workspace" aria-labelledby="source-heading">
<div className="panel-heading">
<div>
<p className="eyebrow">Source</p>
<h2 id="source-heading">Open coordinates</h2>
</div>
<label className="button file-button">
Open file
<input
type="file"
accept=".geojson,.json,.gpx,.kml,.csv,application/geo+json,application/gpx+xml,application/vnd.google-earth.kml+xml,text/csv"
onChange={(event) => void open(event.target.files?.[0])}
/>
</label>
</div>
<div className="format-row">
<label className="field">
<span>Input format</span>
<select
value={sourceFormat}
onChange={(event) =>
setSourceFormat(event.target.value as "auto" | Format)
}
>
<option value="auto">Detect conservatively</option>
<option value="geojson">GeoJSON</option>
<option value="gpx">GPX</option>
<option value="kml">KML</option>
<option value="csv">Coordinate CSV</option>
</select>
</label>
<button className="primary" type="button" onClick={parse}>
Parse locally
</button>
</div>
<textarea
value={source}
onChange={(event) => setSource(event.target.value)}
spellCheck={false}
aria-label="Geospatial source"
/>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
</section>
<section className="panel workspace" aria-labelledby="analysis-heading">
<div>
<p className="eyebrow">
Last successful model · {parsed.sourceFormat.toUpperCase()}
</p>
<h2 id="analysis-heading">Track and geometry analysis</h2>
</div>
<dl className="facts">
<div>
<dt>Features</dt>
<dd>{statistics.features.toLocaleString()}</dd>
</div>
<div>
<dt>Coordinates</dt>
<dd>{statistics.points.toLocaleString()}</dd>
</div>
<div>
<dt>Line distance</dt>
<dd>{(statistics.distance / 1_000).toFixed(3)} km</dd>
</div>
<div>
<dt>Elevation gain / loss</dt>
<dd>
+{statistics.ascent.toFixed(1)} / {statistics.descent.toFixed(1)}{" "}
m
</dd>
</div>
<div>
<dt>Bounds</dt>
<dd>
{statistics.bounds?.map((value) => value.toFixed(5)).join(", ") ??
"—"}
</dd>
</div>
</dl>
{parsed.warnings.map((warning) => (
<p className="warning" key={warning}>
{warning}
</p>
))}
<LocalPlot collection={parsed.collection} />
</section>
<div className="grid">
<section className="panel workspace" aria-labelledby="convert-heading">
<div>
<p className="eyebrow">Convert</p>
<h2 id="convert-heading">Explicit target</h2>
</div>
<label className="field">
<span>Output format</span>
<select
value={target}
onChange={(event) => setTarget(event.target.value as Format)}
>
<option value="geojson">GeoJSON</option>
<option value="gpx">GPX 1.1</option>
<option value="kml">KML 2.2</option>
<option value="csv">Coordinate CSV</option>
</select>
</label>
{converted.losses.map((loss) => (
<p className="warning" key={loss}>
{loss}
</p>
))}
<button
type="button"
onClick={() =>
triggerBlobDownload(
new Blob([converted.text], { type: converted.mime }),
`converted.${converted.extension}`,
)
}
>
Download {target.toUpperCase()}
</button>
</section>
<section className="panel workspace" aria-labelledby="simplify-heading">
<div>
<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.
</p>
</div>
<label className="field">
<span>Tolerance in metres</span>
<input
type="number"
min="0"
max="100000"
value={tolerance}
onChange={(event) => setTolerance(event.target.valueAsNumber)}
/>
</label>
<button type="button" onClick={simplify}>
Simplify current model
</button>
</section>
<section
className="panel workspace"
aria-labelledby="coordinate-heading"
>
<div>
<p className="eyebrow">Coordinate helper</p>
<h2 id="coordinate-heading">Decimal degrees to DMS</h2>
</div>
<label className="field">
<span>Latitude</span>
<input
type="number"
min="-90"
max="90"
step="any"
value={latitude}
onChange={(event) => setLatitude(event.target.valueAsNumber)}
/>
</label>
<label className="field">
<span>Longitude</span>
<input
type="number"
min="-180"
max="180"
step="any"
value={longitude}
onChange={(event) => setLongitude(event.target.valueAsNumber)}
/>
</label>
<output className="coordinate-output">
{coordinateOutput.map((line) => (
<span key={line}>{line}</span>
))}
</output>
</section>
</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.
</p>
</section>
</main>
);
}
+170
View File
@@ -0,0 +1,170 @@
import {
collectionPositions,
type GeoCollection,
type Position,
} from "./model";
const EARTH_RADIUS = 6_371_008.8;
const radians = (degrees: number) => (degrees * Math.PI) / 180;
export function haversine(left: Position, right: Position): number {
const dLat = radians(right[1] - left[1]);
const dLon = radians(right[0] - left[0]);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(radians(left[1])) *
Math.cos(radians(right[1])) *
Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.min(1, Math.sqrt(a)));
}
export interface GeoStatistics {
points: number;
features: number;
distance: number;
ascent: number;
descent: number;
bounds?: [number, number, number, number];
}
export function analyseCollection(collection: GeoCollection): GeoStatistics {
const positions = collectionPositions(collection);
let distance = 0;
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]!;
distance += haversine(previous, current);
if (previous[2] !== undefined && current[2] !== undefined) {
const delta = current[2] - previous[2];
if (delta > 0) ascent += delta;
else descent -= delta;
}
}
const bounds = positions.length
? positions.reduce<[number, number, number, number]>(
(box, position) => [
Math.min(box[0], position[0]),
Math.min(box[1], position[1]),
Math.max(box[2], position[0]),
Math.max(box[3], position[1]),
],
[Infinity, Infinity, -Infinity, -Infinity],
)
: undefined;
return {
points: positions.length,
features: collection.features.length,
distance,
ascent,
descent,
bounds,
};
}
function projectedDistance(
point: Position,
start: Position,
end: Position,
): number {
const latitude = radians((start[1] + end[1]) / 2);
const scaleX = Math.cos(latitude) * 111_320;
const scaleY = 110_574;
const px = point[0] * scaleX;
const py = point[1] * scaleY;
const ax = start[0] * scaleX;
const ay = start[1] * scaleY;
const bx = end[0] * scaleX;
const by = end[1] * scaleY;
const length = (bx - ax) ** 2 + (by - ay) ** 2;
const t =
length === 0
? 0
: Math.max(
0,
Math.min(1, ((px - ax) * (bx - ax) + (py - ay) * (by - ay)) / length),
);
return Math.hypot(px - (ax + t * (bx - ax)), py - (ay + t * (by - ay)));
}
export function simplifyLine(
points: Position[],
toleranceMetres: number,
): Position[] {
if (
!Number.isFinite(toleranceMetres) ||
toleranceMetres < 0 ||
toleranceMetres > 100_000
)
throw new Error("Tolerance must be from 0 to 100,000 metres.");
if (points.length <= 2 || toleranceMetres === 0) return [...points];
const keep = new Uint8Array(points.length);
keep[0] = 1;
keep[points.length - 1] = 1;
const stack: Array<[number, number]> = [[0, points.length - 1]];
while (stack.length) {
const [startIndex, endIndex] = stack.pop()!;
let maximum = 0;
let selected = -1;
for (let index = startIndex + 1; index < endIndex; index += 1) {
const distance = projectedDistance(
points[index]!,
points[startIndex]!,
points[endIndex]!,
);
if (distance > maximum) {
maximum = distance;
selected = index;
}
}
if (selected >= 0 && maximum > toleranceMetres) {
keep[selected] = 1;
stack.push([startIndex, selected], [selected, endIndex]);
}
}
return points.filter((_point, index) => keep[index] === 1);
}
export function simplifyCollection(
collection: GeoCollection,
toleranceMetres: number,
): GeoCollection {
return {
...collection,
features: collection.features.map((feature) =>
feature.geometry.type === "LineString"
? {
...feature,
geometry: {
...feature.geometry,
coordinates: simplifyLine(
feature.geometry.coordinates,
toleranceMetres,
),
},
}
: feature,
),
};
}
export function toDms(value: number, axis: "latitude" | "longitude"): string {
const maximum = axis === "latitude" ? 90 : 180;
if (!Number.isFinite(value) || Math.abs(value) > maximum)
throw new Error(`${axis} is outside its valid range.`);
const absolute = Math.abs(value);
const degrees = Math.floor(absolute);
const minutesFloat = (absolute - degrees) * 60;
const minutes = Math.floor(minutesFloat);
const seconds = (minutesFloat - minutes) * 60;
const direction =
axis === "latitude" ? (value < 0 ? "S" : "N") : value < 0 ? "W" : "E";
return `${degrees}° ${minutes} ${seconds.toFixed(3)}${direction}`;
}
+497
View File
@@ -0,0 +1,497 @@
import {
collectionPositions,
MAX_COORDINATES,
validatePosition,
type GeoCollection,
type GeoFeature,
type GeoParseResult,
type Geometry,
type Position,
} from "./model";
const MAX_TEXT = 16 * 1024 * 1024;
function preflightXml(source: string): void {
let cursor = 0;
let elements = 0;
let depth = 0;
while (cursor < source.length) {
const opening = source.indexOf("<", cursor);
if (opening < 0) break;
if (source.startsWith("<!--", opening)) {
const end = source.indexOf("-->", opening + 4);
cursor = end < 0 ? source.length : end + 3;
continue;
}
if (source.startsWith("<![CDATA[", opening)) {
const end = source.indexOf("]]>", opening + 9);
cursor = end < 0 ? source.length : end + 3;
continue;
}
let end = opening + 1;
let quote = "";
for (; end < source.length; end += 1) {
const character = source[end]!;
if (quote) {
if (character === quote) quote = "";
} else if (character === '"' || character === "'") quote = character;
else if (character === ">") break;
}
const markup = source.slice(opening, Math.min(source.length, end + 1));
if (/^<\s*\//u.test(markup)) depth = Math.max(0, depth - 1);
else if (!/^<\s*[!?]/u.test(markup)) {
elements += 1;
if (elements > 250_000)
throw new Error("XML exceeds the 250,000-element limit.");
if (!/\/\s*>$/u.test(markup)) {
depth += 1;
if (depth > 256)
throw new Error("XML exceeds the 256-level nesting limit.");
}
}
cursor = end < source.length ? end + 1 : source.length;
}
}
function bounded(source: string) {
if (new TextEncoder().encode(source).byteLength > MAX_TEXT)
throw new Error("Input exceeds the 16 MiB text limit.");
}
function safeProperties(
value: unknown,
): Record<string, string | number | boolean | null> {
const output: Record<string, string | number | boolean | null> =
Object.create(null) as Record<string, string | number | boolean | null>;
if (!value || typeof value !== "object" || Array.isArray(value))
return output;
for (const [key, entry] of Object.entries(value))
if (
entry === null ||
["string", "number", "boolean"].includes(typeof entry)
)
output[key] = entry as string | number | boolean | null;
return output;
}
function geoJsonGeometry(value: unknown): Geometry {
if (!value || typeof value !== "object")
throw new Error("Feature geometry is missing.");
const geometry = value as { type?: unknown; coordinates?: unknown };
if (geometry.type === "Point")
return {
type: "Point",
coordinates: validatePosition(geometry.coordinates),
};
if (geometry.type === "LineString" && Array.isArray(geometry.coordinates))
return {
type: "LineString",
coordinates: geometry.coordinates.map(validatePosition),
};
if (geometry.type === "Polygon" && Array.isArray(geometry.coordinates))
return {
type: "Polygon",
coordinates: geometry.coordinates.map((ring) => {
if (!Array.isArray(ring)) throw new Error("Polygon ring is invalid.");
return ring.map(validatePosition);
}),
};
throw new Error(
`Geometry ${String(geometry.type)} is unsupported in v0.1; use Point, LineString, or Polygon.`,
);
}
function feature(value: unknown): GeoFeature {
if (
!value ||
typeof value !== "object" ||
(value as { type?: unknown }).type !== "Feature"
)
throw new Error("Expected a GeoJSON Feature.");
const source = value as { geometry?: unknown; properties?: unknown };
return {
type: "Feature",
properties: safeProperties(source.properties),
geometry: geoJsonGeometry(source.geometry),
};
}
export function parseGeoJson(source: string): GeoParseResult {
bounded(source);
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
throw new Error("GeoJSON is not valid JSON.");
}
if (!parsed || typeof parsed !== "object")
throw new Error("GeoJSON root is invalid.");
const root = parsed as { type?: unknown; features?: unknown[] };
let features: GeoFeature[];
if (root.type === "FeatureCollection" && Array.isArray(root.features))
features = root.features.map(feature);
else if (root.type === "Feature") features = [feature(root)];
else
features = [
{ type: "Feature", properties: {}, geometry: geoJsonGeometry(root) },
];
const collection: GeoCollection = { type: "FeatureCollection", features };
collectionPositions(collection);
return {
collection,
sourceFormat: "geojson",
warnings: [
"Coordinates are interpreted as WGS 84 longitude/latitude (EPSG:4326), as required by GeoJSON.",
],
};
}
function parseXml(source: string): XMLDocument {
bounded(source);
if (/<!DOCTYPE|<!ENTITY|<\?xml-stylesheet/iu.test(source))
throw new Error(
"DOCTYPE, entity, and XML stylesheet declarations are rejected.",
);
preflightXml(source);
const document = new DOMParser().parseFromString(source, "application/xml");
if (document.getElementsByTagName("parsererror").length)
throw new Error("XML is not well formed.");
if (document.getElementsByTagName("*").length > 250_000)
throw new Error("XML exceeds the 250,000-element limit.");
return document;
}
function localElements(parent: ParentNode, name: string): Element[] {
return [...(parent as Document | Element).getElementsByTagNameNS("*", name)];
}
function pointFromAttributes(element: Element): Position {
const latitude = Number(element.getAttribute("lat"));
const longitude = Number(element.getAttribute("lon"));
const elevationText = localElements(element, "ele")[0]?.textContent?.trim();
return validatePosition(
elevationText
? [longitude, latitude, Number(elevationText)]
: [longitude, latitude],
);
}
export function parseGpx(source: string): GeoParseResult {
const document = parseXml(source);
if (document.documentElement.localName.toLowerCase() !== "gpx")
throw new Error("The XML root is not GPX.");
const features: GeoFeature[] = [];
for (const segment of localElements(document, "trkseg")) {
const coordinates = localElements(segment, "trkpt").map(
pointFromAttributes,
);
if (coordinates.length)
features.push({
type: "Feature",
properties: {
name: segment.parentElement
? (localElements(
segment.parentElement,
"name",
)[0]?.textContent?.trim() ?? "Track")
: "Track",
},
geometry: { type: "LineString", coordinates },
});
}
for (const route of localElements(document, "rte")) {
const coordinates = localElements(route, "rtept").map(pointFromAttributes);
if (coordinates.length)
features.push({
type: "Feature",
properties: {
name: localElements(route, "name")[0]?.textContent?.trim() ?? "Route",
},
geometry: { type: "LineString", coordinates },
});
}
for (const waypoint of localElements(document, "wpt"))
features.push({
type: "Feature",
properties: {
name:
localElements(waypoint, "name")[0]?.textContent?.trim() ?? "Waypoint",
},
geometry: { type: "Point", coordinates: pointFromAttributes(waypoint) },
});
if (!features.length)
throw new Error("No GPX tracks, routes, or waypoints were found.");
const collection: GeoCollection = { type: "FeatureCollection", features };
collectionPositions(collection);
return {
collection,
sourceFormat: "gpx",
warnings: [
"GPX latitude/longitude values are treated as WGS 84. Extensions are not preserved in v0.1.",
],
};
}
function kmlPositions(value: string): Position[] {
return value
.trim()
.split(/\s+/u)
.filter(Boolean)
.map((tuple) => validatePosition(tuple.split(",").map(Number)));
}
export function parseKml(source: string): GeoParseResult {
const document = parseXml(source);
if (document.documentElement.localName.toLowerCase() !== "kml")
throw new Error("The XML root is not KML.");
const features: GeoFeature[] = [];
for (const placemark of localElements(document, "Placemark")) {
const properties = {
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)
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 },
});
}
}
if (!features.length)
throw new Error(
"No supported KML Point, LineString, or Polygon placemarks were found.",
);
const collection: GeoCollection = { type: "FeatureCollection", features };
collectionPositions(collection);
return {
collection,
sourceFormat: "kml",
warnings: [
"KML styling, folders, NetworkLinks, models, tours, and extensions are not preserved in v0.1.",
],
};
}
function csvRow(line: string): string[] {
const values: string[] = [];
let value = "";
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const character = line[index]!;
if (quoted && character === '"' && line[index + 1] === '"') {
value += '"';
index += 1;
} else if (character === '"') quoted = !quoted;
else if (character === "," && !quoted) {
values.push(value);
value = "";
} else value += character;
}
if (quoted) throw new Error("CSV contains an unclosed quote.");
values.push(value);
return values;
}
export function parseCoordinateCsv(source: string): GeoParseResult {
bounded(source);
const lines = source
.replaceAll("\r\n", "\n")
.split("\n")
.filter((line) => line.trim());
if (lines.length < 2)
throw new Error("CSV requires a header and at least one data row.");
const headers = csvRow(lines[0]!).map((header) =>
header.trim().toLowerCase(),
);
const latitudeIndex = headers.findIndex((name) =>
["lat", "latitude", "y"].includes(name),
);
const longitudeIndex = headers.findIndex((name) =>
["lon", "lng", "longitude", "x"].includes(name),
);
const elevationIndex = headers.findIndex((name) =>
["ele", "elevation", "alt", "altitude", "z"].includes(name),
);
const nameIndex = headers.findIndex((name) =>
["name", "label", "title"].includes(name),
);
if (latitudeIndex < 0 || longitudeIndex < 0)
throw new Error(
"CSV header must contain latitude/lat and longitude/lon columns.",
);
const features = lines.slice(1).map((line, index): GeoFeature => {
const row = csvRow(line);
const coordinate = validatePosition([
row[longitudeIndex],
row[latitudeIndex],
elevationIndex >= 0 && row[elevationIndex] !== ""
? row[elevationIndex]
: undefined,
]);
return {
type: "Feature",
properties: {
name:
nameIndex >= 0
? (row[nameIndex] ?? `Point ${index + 1}`)
: `Point ${index + 1}`,
},
geometry: { type: "Point", coordinates: coordinate },
};
});
if (features.length > MAX_COORDINATES)
throw new Error(`CSV exceeds ${MAX_COORDINATES.toLocaleString()} rows.`);
return {
collection: { type: "FeatureCollection", features },
sourceFormat: "csv",
warnings: [
"CSV values are interpreted as decimal WGS 84 latitude and longitude.",
],
};
}
export function detectAndParse(
source: string,
requested: "auto" | GeoParseResult["sourceFormat"],
): GeoParseResult {
const format =
requested === "auto"
? source.trimStart().startsWith("{") || source.trimStart().startsWith("[")
? "geojson"
: /<gpx\b/iu.test(source)
? "gpx"
: /<kml\b/iu.test(source)
? "kml"
: "csv"
: requested;
if (format === "geojson") return parseGeoJson(source);
if (format === "gpx") return parseGpx(source);
if (format === "kml") return parseKml(source);
return parseCoordinateCsv(source);
}
function xml(value: unknown): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function tuple(position: Position): string {
return position.join(",");
}
export function serializeGeo(
collection: GeoCollection,
format: GeoParseResult["sourceFormat"],
): { text: string; mime: string; extension: string; losses: string[] } {
collectionPositions(collection);
if (format === "geojson")
return {
text: JSON.stringify(collection, null, 2),
mime: "application/geo+json",
extension: "geojson",
losses: [],
};
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();
positions.forEach((position, positionIndex) =>
rows.push(
[
featureIndex + 1,
positionIndex + 1,
JSON.stringify(feature.properties.name ?? ""),
position[0],
position[1],
position[2] ?? "",
].join(","),
),
);
});
return {
text: rows.join("\r\n") + "\r\n",
mime: "text/csv",
extension: "csv",
losses: [
"Geometry structure beyond feature/position order and most properties are not represented in CSV.",
],
};
}
if (format === "gpx") {
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>`;
})
.join("");
return {
text: `<?xml version="1.0" encoding="UTF-8"?>\n<gpx version="1.1" creator="add-ideas Geo Tools" xmlns="http://www.topografix.com/GPX/1/1">${body}</gpx>\n`,
mime: "application/gpx+xml",
extension: "gpx",
losses: [
"Only names, coordinates, and elevation are exported; Polygon rings become tracks.",
],
};
}
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>`;
return `<Placemark><name>${name}</name>${geometry}</Placemark>`;
})
.join("");
return {
text: `<?xml version="1.0" encoding="UTF-8"?>\n<kml xmlns="http://www.opengis.net/kml/2.2"><Document>${body}</Document></kml>\n`,
mime: "application/vnd.google-earth.kml+xml",
extension: "kml",
losses: [
"Only names and supported geometry are exported; KML styling and extensions are not represented.",
],
};
}
+65
View File
@@ -0,0 +1,65 @@
export type Position = [
longitude: number,
latitude: number,
elevation?: number,
];
export type Geometry =
| { type: "Point"; coordinates: Position }
| { type: "LineString"; coordinates: Position[] }
| { type: "Polygon"; coordinates: Position[][] };
export interface GeoFeature {
type: "Feature";
properties: Record<string, string | number | boolean | null>;
geometry: Geometry;
}
export interface GeoCollection {
type: "FeatureCollection";
features: GeoFeature[];
}
export interface GeoParseResult {
collection: GeoCollection;
sourceFormat: "geojson" | "gpx" | "kml" | "csv";
warnings: string[];
}
export const MAX_COORDINATES = 200_000;
export function validatePosition(value: unknown): Position {
if (!Array.isArray(value) || value.length < 2 || value.length > 3)
throw new Error(
"A coordinate must contain longitude, latitude, and optional elevation.",
);
const longitude = Number(value[0]);
const latitude = Number(value[1]);
const elevation =
value[2] === undefined || value[2] === null ? undefined : Number(value[2]);
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180)
throw new Error("Longitude must be from 180 to 180 degrees.");
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90)
throw new Error("Latitude must be from 90 to 90 degrees.");
if (elevation !== undefined && !Number.isFinite(elevation))
throw new Error("Elevation must be finite.");
return elevation === undefined
? [longitude, latitude]
: [longitude, latitude, elevation];
}
export function collectionPositions(collection: GeoCollection): Position[] {
const positions: Position[] = [];
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.`,
);
}
return positions;
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+328
View File
@@ -0,0 +1,328 @@
:root {
--toolbox-background: #f6f7fb;
--toolbox-surface: #fff;
--toolbox-surface-soft: #eff1f7;
--toolbox-text: #202332;
--toolbox-muted: #656b7d;
--toolbox-border: #d9dce7;
--toolbox-accent: #5b4ec4;
--toolbox-accent-hover: #493caf;
--toolbox-accent-soft: #ece9ff;
--toolbox-accent-contrast: #fff;
--toolbox-focus: #137d75;
--toolbox-danger: #b42342;
}
* {
box-sizing: border-box;
}
html {
min-width: 20rem;
min-height: 100%;
background: var(--toolbox-background);
scrollbar-gutter: stable;
}
body {
min-width: 20rem;
min-height: 100vh;
margin: 0;
background: var(--toolbox-background);
color: var(--toolbox-text);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
.button {
min-height: 2.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
font-weight: 720;
cursor: pointer;
}
button:hover:not(:disabled),
.button:hover {
border-color: var(--toolbox-accent);
background: var(--toolbox-surface-soft);
}
:where(button, input, select, textarea, a):focus-visible {
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
outline-offset: 2px;
}
input,
select,
textarea {
width: 100%;
min-height: 2.55rem;
padding: 0.58rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
textarea {
min-height: 10rem;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.48;
}
.toolbox-shell__main {
width: min(100%, 90rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
.workbench {
display: grid;
gap: 1rem;
}
.hero,
.panel {
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
}
.hero {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
padding: clamp(1.1rem, 3vw, 2rem);
}
.hero h1,
.panel h2,
.panel h3,
.help-dialog h2,
.fatal h1 {
margin: 0;
letter-spacing: -0.025em;
}
.hero p:not(.eyebrow) {
max-width: 52rem;
margin: 0.55rem 0 0;
color: var(--toolbox-muted);
line-height: 1.55;
}
.eyebrow {
margin: 0 0 0.3rem;
color: var(--toolbox-accent);
font-size: 0.69rem;
font-weight: 820;
letter-spacing: 0.115em;
text-transform: uppercase;
}
.privacy-pill {
flex: 0 0 auto;
padding: 0.38rem 0.62rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.75rem;
font-weight: 760;
}
.panel {
padding: 1rem;
}
.panel-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: end;
margin-bottom: 0.9rem;
}
.capability-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: 0.75rem;
}
.capability-grid article {
padding: 0.9rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.72rem;
background: var(--toolbox-surface-soft);
}
.capability-grid p {
margin: 0.4rem 0 0;
color: var(--toolbox-muted);
line-height: 1.48;
}
.workspace-tabs {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.2rem;
}
.workspace-tabs button[aria-selected="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: 0.85rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field > span {
font-size: 0.76rem;
font-weight: 750;
}
.muted {
color: var(--toolbox-muted);
}
.result {
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
background: var(--toolbox-surface-soft);
overflow-wrap: anywhere;
}
.loading,
.fatal {
width: min(100% - 2rem, 60rem);
margin: 2rem auto;
padding: 1rem;
}
.help-dialog {
width: min(36rem, calc(100% - 2rem));
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.help-dialog::backdrop {
background: rgb(20 24 45 / 55%);
}
.dialog-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
.workspace {
display: grid;
gap: 0.9rem;
}
.workspace h2 + p {
margin: 0.35rem 0 0;
}
.format-row {
display: grid;
grid-template-columns: minmax(12rem, 1fr) auto;
gap: 0.7rem;
align-items: end;
}
.primary {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.file-button {
position: relative;
overflow: hidden;
}
.file-button input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
gap: 0.55rem;
margin: 0;
}
.facts div {
min-width: 0;
padding: 0.7rem;
border-radius: 0.65rem;
background: var(--toolbox-surface-soft);
}
.facts dt {
color: var(--toolbox-muted);
font-size: 0.7rem;
font-weight: 760;
}
.facts dd {
margin: 0.2rem 0 0;
overflow-wrap: anywhere;
}
.warning,
.error,
.notice {
margin: 0;
padding: 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
line-height: 1.5;
}
.warning {
border-color: #d9a72e;
background: #fff8df;
color: #725000;
}
.error {
border-color: var(--toolbox-danger);
color: var(--toolbox-danger);
}
.notice {
background: var(--toolbox-surface-soft);
}
.plot {
margin: 0;
}
.plot svg {
display: block;
width: 100%;
max-height: 30rem;
}
.plot-background {
fill: var(--toolbox-surface-soft);
stroke: var(--toolbox-border);
}
.plot polyline {
fill: none;
stroke: var(--toolbox-accent);
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.plot polyline.closed {
fill: color-mix(in srgb, var(--toolbox-accent) 12%, transparent);
}
.plot circle {
fill: var(--toolbox-accent);
}
.plot figcaption {
margin-top: 0.4rem;
color: var(--toolbox-muted);
font-size: 0.75rem;
}
.coordinate-output {
display: grid;
gap: 0.4rem;
padding: 0.8rem;
border-radius: 0.65rem;
background: var(--toolbox-surface-soft);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
@media (max-width: 42rem) {
.hero {
flex-direction: column;
}
.privacy-pill {
order: -1;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"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.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["geography", "data", "files"],
"tags": ["geojson", "gpx", "kml", "track", "coordinates"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/geo-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/geo-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />