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; }