diff --git a/package-lock.json b/package-lock.json
index c3b0ca1..8db8097 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,6 +5,8 @@
"packages": {
"": {
"dependencies": {
+ "file-saver": "^2.0.5",
+ "gpx-builder": "^5.2.1",
"leaflet": "^1.9.4",
"nanoid": "^5.0.3",
"preact": "^10.13.1"
@@ -17,6 +19,7 @@
"@babel/plugin-syntax-jsx": "^7.23.3",
"@preact/preset-vite": "^2.5.0",
"@tsconfig/strictest": "^2.0.2",
+ "@types/file-saver": "^2.0.7",
"@types/leaflet": "^1.9.8",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
@@ -416,6 +419,17 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.23.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
+ "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
@@ -1041,6 +1055,50 @@
"node": ">= 8"
}
},
+ "node_modules/@oozcitak/dom": {
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
+ "integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
+ "dependencies": {
+ "@oozcitak/infra": "1.0.8",
+ "@oozcitak/url": "1.0.4",
+ "@oozcitak/util": "8.3.8"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/@oozcitak/infra": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
+ "integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
+ "dependencies": {
+ "@oozcitak/util": "8.3.8"
+ },
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
+ "node_modules/@oozcitak/url": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
+ "integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
+ "dependencies": {
+ "@oozcitak/infra": "1.0.8",
+ "@oozcitak/util": "8.3.8"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/@oozcitak/util": {
+ "version": "8.3.8",
+ "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
+ "integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==",
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/@pkgr/utils": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz",
@@ -1144,6 +1202,12 @@
"integrity": "sha512-jt4jIsWKvUvuY6adJnQJlb/UR7DdjC8CjHI/OaSQruj2yX9/K6+KOvDt/vD6udqos/FUk5Op66CvYT7TBLYO5Q==",
"dev": true
},
+ "node_modules/@types/file-saver": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
+ "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
+ "dev": true
+ },
"node_modules/@types/geojson": {
"version": "7946.0.13",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
@@ -2955,6 +3019,18 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/esquery": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
@@ -3114,6 +3190,11 @@
"node": "^10.12.0 || >=12.0.0"
}
},
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+ },
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -3367,6 +3448,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gpx-builder": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/gpx-builder/-/gpx-builder-5.2.1.tgz",
+ "integrity": "sha512-j7g9/bWQncZ2TaHXxjNSnNI+EaFWLQqm03A2si0dvzc78H/lWFVF770tdwEzdIFbd5eZeT4d99/UGbPmkplLBg==",
+ "dependencies": {
+ "@babel/runtime": "^7.20.7",
+ "xmlbuilder2": "^3.0.2"
+ }
+ },
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -4753,6 +4843,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz",
@@ -5226,6 +5321,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
+ },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -5875,6 +5975,40 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
+ "node_modules/xmlbuilder2": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz",
+ "integrity": "sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==",
+ "dependencies": {
+ "@oozcitak/dom": "1.15.10",
+ "@oozcitak/infra": "1.0.8",
+ "@oozcitak/util": "8.3.8",
+ "js-yaml": "3.14.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
+ "node_modules/xmlbuilder2/node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/xmlbuilder2/node_modules/js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 5547f33..08d296f 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "file-saver": "^2.0.5",
+ "gpx-builder": "^5.2.1",
"leaflet": "^1.9.4",
"nanoid": "^5.0.3",
"preact": "^10.13.1"
@@ -23,6 +25,7 @@
"@babel/plugin-syntax-jsx": "^7.23.3",
"@preact/preset-vite": "^2.5.0",
"@tsconfig/strictest": "^2.0.2",
+ "@types/file-saver": "^2.0.7",
"@types/leaflet": "^1.9.8",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
diff --git a/src/exporters/gpx.ts b/src/exporters/gpx.ts
new file mode 100644
index 0000000..db53c3c
--- /dev/null
+++ b/src/exporters/gpx.ts
@@ -0,0 +1,19 @@
+import { saveAs } from 'file-saver';
+import { GarminBuilder, buildGPX } from 'gpx-builder';
+import { Waypoint } from '../shared/types';
+
+export const exportMarkersToGpx = (waypoints: Waypoint[]) => {
+ const builder = new GarminBuilder();
+ builder.setSegmentPoints(
+ waypoints.map(
+ ({ coordinates }) =>
+ new GarminBuilder.MODELS.Point(
+ coordinates.lat,
+ coordinates.lng,
+ ),
+ ),
+ );
+ const gpx = buildGPX(builder.toObject());
+ const gpxBlob = new Blob([gpx], { type: 'application/gpx+xml' });
+ saveAs(gpxBlob, 'route.gpx');
+};
diff --git a/src/routePlanner/export.tsx b/src/routePlanner/export.tsx
new file mode 100644
index 0000000..ab0a86d
--- /dev/null
+++ b/src/routePlanner/export.tsx
@@ -0,0 +1,14 @@
+import { exportMarkersToGpx } from '../exporters/gpx';
+import { InternalProps } from './types';
+
+import 'leaflet/dist/leaflet.css';
+
+export const ExportComponent = ({ markers }: InternalProps) => {
+ return (
+ <>
+
+ >
+ );
+};
diff --git a/src/routePlanner/index.tsx b/src/routePlanner/index.tsx
index 69bf26d..9364ead 100644
--- a/src/routePlanner/index.tsx
+++ b/src/routePlanner/index.tsx
@@ -1,10 +1,12 @@
+import { nanoid } from 'nanoid';
import { useMemo, useState } from 'preact/hooks';
+import { Coordinates } from '../shared/types';
+import { ExportComponent } from './export';
import { MapComponent } from './map';
-import { Coordinates, Marker } from './types';
+import { MarkersComponent } from './markers';
+import { Marker } from './types';
import './style.css';
-import { MarkersComponent } from './markers';
-import { nanoid } from 'nanoid';
export const RoutePlanner = () => {
const [markers, setMarkers] = useState([]);
@@ -41,6 +43,9 @@ export const RoutePlanner = () => {
+
diff --git a/src/routePlanner/style.css b/src/routePlanner/style.css
index 2662279..72289d0 100644
--- a/src/routePlanner/style.css
+++ b/src/routePlanner/style.css
@@ -1,15 +1,23 @@
section.route-planner {
width: 100%;
display: grid;
- grid-template-columns: 1fr 2fr;
column-gap: 1rem;
- grid-template-areas: "markers map";
+ row-gap: 1rem;
+ grid-template-columns: 1fr 2fr;
+ grid-template-rows: 1fr min-content;
+ grid-template-areas:
+ "markers map"
+ "export map";
}
section.route-planner > section.markers {
grid-area: markers;
}
+section.route-planner > section.export {
+ grid-area: export;
+}
+
section.route-planner > section.map {
grid-area: map;
}
@@ -30,9 +38,10 @@ section.route-planner .map-container .leaflet-tooltip-pane .text {
@media (max-width: 639px) {
section.route-planner {
grid-template-columns: 1fr;
- row-gap: 1rem;
+ grid-template-rows: 1fr min-content 2fr;
grid-template-areas:
"markers"
+ "export"
"map";
}
diff --git a/src/routePlanner/types.ts b/src/routePlanner/types.ts
index cc618f6..9fe80e4 100644
--- a/src/routePlanner/types.ts
+++ b/src/routePlanner/types.ts
@@ -1,12 +1,9 @@
-import type { LatLng } from 'leaflet';
+import { Coordinates, Waypoint } from '../shared/types';
-export type Coordinates = LatLng;
-
-export type Marker = {
+export type Marker = Waypoint & {
key: string;
remove: () => void;
index: number;
- coordinates: Coordinates;
};
export type InternalProps = {
diff --git a/src/shared/types.ts b/src/shared/types.ts
new file mode 100644
index 0000000..ce5eba8
--- /dev/null
+++ b/src/shared/types.ts
@@ -0,0 +1,7 @@
+import type { LatLng } from 'leaflet';
+
+export type Coordinates = LatLng;
+
+export type Waypoint = {
+ coordinates: Coordinates;
+};
diff --git a/src/style.css b/src/style.css
index 21e599f..38f5277 100644
--- a/src/style.css
+++ b/src/style.css
@@ -14,7 +14,7 @@
}
body {
- padding: 1em;
+ padding: 1rem;
display: flex;
align-items: center;
}