Initial release of Geo Tools 0.1.0
This commit is contained in:
+35
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 Douglas–Peucker 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">
|
||||
Douglas–Peucker 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
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.",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user