diff options
| author | Marcin Zelent <marcin@zelent.net> | 2021-01-08 19:47:37 +0100 |
|---|---|---|
| committer | Marcin Zelent <marcin@zelent.net> | 2021-01-08 19:47:37 +0100 |
| commit | 628632ff5092f1e3cf6c968d9bdfbd9a24f59541 (patch) | |
| tree | feddb4e82267bba700a15a6a7091f922f48cae40 | |
Initial commit
31 files changed, 10386 insertions, 0 deletions
diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..b8d8c34 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,51 @@ +{ + "extends": [ + "airbnb", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/eslint-recommended", + "prettier/react", + "plugin:prettier/recommended", + "prettier/@typescript-eslint", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript" + ], + "plugins": ["@typescript-eslint"], + "parser": "@typescript-eslint/parser", + "env": { "browser": true, "node": true, "es6": true }, + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "prettier/prettier": "error", + "react/prop-types": "off", + "jsx-a11y/anchor-is-valid": "off", + "react/react-in-jsx-scope": "off", + "react/jsx-filename-extension": [ + "error", + { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + ], + "import/extensions": [ + "error", + "ignorePackages", + { + "js": "never", + "jsx": "never", + "ts": "never", + "tsx": "never", + "d.ts": "never" + } + ] + }, + "settings": { + "import/extensions": [".js", ".jsx", ".ts", ".tsx", ".d.ts"], + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx", ".d.ts"] + }, + "import/resolver": { + "typescript": {} + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1437c53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..98b83ae --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "always" +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..72cb4c6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Launch Program", + "skipFiles": ["<node_internals>/**"], + "port": 9229 + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..55712c1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +}
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..053af27 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Trip Share + +A web application for sharing trips recorded in the GPX format along with +photos. + +## Preview + +|  |  | +| ------------------------------------- | ----------------------------------- | + +You can also see a demo at https://zelent.net/trips. + +## Description + +The idea of the application came when I wanted to share my bike trips with my +family and friends. I needed an easy way to display the route on a map and the +photos I've taken on the trip. I record my bike trips using +[OSMAnd](https://osmand.net/) navigation app and take photos with the location +saved in the image metadata. + +The application displays the route recorded with GPS in the GPX format on an +interactive map. The photos are displayed as markers in places they have been +taken, and when clicked on, they are opened in a slideshow view. It is usable +both on desktop and mobile. + +The application was developed using [Node.js](https://nodejs.org/), +[TypeScript](https://www.typescriptlang.org/), and +[Next.js](https://nextjs.org/) framework. The pages and components are built +with [React](https://reactjs.org/). The map was created with +[Leaflet](https://leafletjs.com/). + +The app is statically generated. GPX files and photos inside "trips" folder are +processed during build time. The data is converted to HTML and JSON, photos are +stripped of EXIF metadata, resized, and optimized. No requests are made to +external APIs, only map tiles are being downloaded. + +## Running + +To run the application, first install the dependencies: + +``` +npm install +``` + +Then, you can run it in development mode: + +``` +npm run dev +``` + +To build the application use: + +``` +npm run build +``` + +The built files will be in "out" directory. + +### Trips data + +To display the trips, place them in the "trips" folder with the following +structure: + +``` +trips/ +├── trip-1/ +│ ├── trip-1.gpx +│ ├── photo-1.jpg +│ ├── photo-2.jpg +│ ├── photo-3.jpg +├── trip-2/ +│ ├── trip-2.gpx +``` diff --git a/components/Gallery/Gallery.module.css b/components/Gallery/Gallery.module.css new file mode 100644 index 0000000..01e8037 --- /dev/null +++ b/components/Gallery/Gallery.module.css @@ -0,0 +1,63 @@ +.galleryContainer { + padding: 20px; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 99999; + background: rgba(0, 0, 0, 0.8); +} + +.photoContainer { + width: 100%; + height: 100%; + padding: 20px; + position: relative; +} + +.photo { + max-width: 100%; + max-height: 100%; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.btn { + position: absolute; + z-index: 99999; + font-size: 2rem; + color: #eee; + background: none; + border: none; + cursor: pointer; + outline: currentcolor none medium; +} + +.btn:hover { + color: #fff; +} + +.arrowLeft { + top: 50%; + left: 0; + transform: translateY(-50%); +} + +.arrowRight { + top: 50%; + right: 0; + transform: translateY(-50%); +} + +.closeBtn { + width: 64px; + height: 64px; + top: 0; + right: 0; + font-size: 4rem; + line-height: 4rem; + font-weight: 300; +} diff --git a/components/Gallery/Gallery.tsx b/components/Gallery/Gallery.tsx new file mode 100644 index 0000000..dfadbc0 --- /dev/null +++ b/components/Gallery/Gallery.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef } from 'react'; + +import { Photo } from 'models'; +import useEvent from 'lib/useEvent'; +import styles from './Gallery.module.css'; + +interface Props { + currentPhoto: Photo; + handleClose: () => void; + handlePhotoChange: (direction: boolean) => void; +} + +export default function Gallery({ + currentPhoto, + handleClose, + handlePhotoChange, +}: Props): JSX.Element { + const wrapperRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + wrapperRef.current && + !wrapperRef.current.contains(e.target) && + (e.target as Node).nodeName !== 'BUTTON' + ) { + handleClose(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [wrapperRef]); + + useEvent('keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + handlePhotoChange(false); + } else if (e.key === 'ArrowRight') { + handlePhotoChange(true); + } else if (e.key === 'Escape') { + handleClose(); + } + }); + + return ( + <div className={styles.galleryContainer} role="region"> + <button + type="button" + className={`${styles.arrowLeft} ${styles.btn}`} + onClick={() => handlePhotoChange(false)} + > + 〈 + </button> + <div className={styles.photoContainer}> + <img + ref={wrapperRef} + src={currentPhoto.src} + alt={currentPhoto.name} + className={styles.photo} + /> + </div> + <button + type="button" + className={`${styles.arrowRight} ${styles.btn}`} + onClick={() => handlePhotoChange(true)} + > + 〉 + </button> + <button + type="button" + className={`${styles.closeBtn} ${styles.btn}`} + onClick={() => handleClose()} + > + × + </button> + </div> + ); +} diff --git a/components/Map/Map.module.css b/components/Map/Map.module.css new file mode 100644 index 0000000..b13f5d2 --- /dev/null +++ b/components/Map/Map.module.css @@ -0,0 +1,28 @@ +.popup { + width: auto; + min-height: 90%; +} + +.popup .leaflet-popup-content { + margin: 0; +} + +.markerIcon { + border: 2px solid #fff; + border-radius: 2px; + overflow: hidden; +} + +.markerItemCount { + width: 18px; + height: 18px; + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border-radius: 10px; + background: #fff; + text-align: center; + font-weight: bold; +} diff --git a/components/Map/Map.tsx b/components/Map/Map.tsx new file mode 100644 index 0000000..44f8ea6 --- /dev/null +++ b/components/Map/Map.tsx @@ -0,0 +1,121 @@ +import { LatLngBounds, DivIcon, Icon } from 'leaflet'; +import { useEffect, useState } from 'react'; +import ReactDom from 'react-dom/server'; +import { MapContainer, Marker, TileLayer, GeoJSON, ZoomControl } from 'react-leaflet'; +import MarkerClusterGroup from 'react-leaflet-markercluster'; + +import { Trip, Photo } from 'models'; +import { distanceBetween } from 'lib/util'; + +import 'leaflet/dist/leaflet.css'; +import styles from './Map.module.css'; + +interface Props { + trip: Trip; + handleMarkerClick: (photo: string) => void; +} + +export default function Map({ trip, handleMarkerClick }: Props): JSX.Element { + const [map, setMap] = useState(null); + + const bounds = trip + ? new LatLngBounds( + [trip.track.bbox[1], trip.track.bbox[0]], + [trip.track.bbox[3], trip.track.bbox[2]], + ) + : new LatLngBounds([75, -145], [-52, 145]); + + function createMarkers(photos: Photo[]): JSX.Element[] { + // cluster photos that are close to each other + const clusters: Photo[][] = []; + for (let i = 0; i < photos.length; i += 1) { + if (clusters.filter((c) => c.includes(photos[i])).length === 0) { + const cluster = [photos[i]]; + for (let j = 0; j < photos.length; j += 1) { + if (photos[i] !== photos[j]) { + const a = [photos[i].latitude, photos[i].longitude]; + const b = [photos[j].latitude, photos[j].longitude]; + const distance = distanceBetween(a, b); + if (distance < 10) cluster.push(photos[j]); + } + } + clusters.push(cluster); + } + } + + // create React elements based on the clusters + const markers = clusters.map((cluster) => { + let Wrapper = ({ children }) => <>{children}</>; + if (cluster.length > 1) { + Wrapper = ({ children }) => ( + <MarkerClusterGroup + iconCreateFunction={(markerCluster) => + new DivIcon({ + html: ReactDom.renderToString( + <> + <img + style={{ width: 36, height: 36 }} + src={`data:image/jpg;base64,${cluster[0].thumbnail}`} + alt="" + /> + <span className={styles.markerItemCount}>{markerCluster.getChildCount()}</span> + </>, + ), + iconSize: [36, 36], + className: styles.markerIcon, + }) + } + > + {children} + </MarkerClusterGroup> + ); + } + + const children = cluster.map((photo) => ( + <Marker + key={photo.name} + position={[photo.latitude, photo.longitude]} + icon={ + new Icon({ + iconUrl: `data:image/jpg;base64,${photo.thumbnail}`, + iconSize: [36, 36], + className: styles.markerIcon, + }) + } + eventHandlers={{ click: () => handleMarkerClick(photo.name) }} + /> + )); + + return <Wrapper key={cluster[0].name}>{children}</Wrapper>; + }); + + return markers; + } + + useEffect(() => { + if (map) { + map.setView(bounds.getCenter()); + map.fitBounds(bounds); + } + }, [trip]); + + return ( + <MapContainer + center={bounds.getCenter()} + bounds={bounds} + scrollWheelZoom + whenCreated={setMap} + style={{ height: '100%', width: '100%' }} + keyboard + zoomControl={false} + > + <ZoomControl position="bottomright" /> + <TileLayer + attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors' + url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + /> + {trip && <GeoJSON key={trip.name} data={trip.track} />} + {trip && createMarkers(trip.photos)} + </MapContainer> + ); +} diff --git a/components/Sidebar/Sidebar.module.css b/components/Sidebar/Sidebar.module.css new file mode 100644 index 0000000..a3c8a1a --- /dev/null +++ b/components/Sidebar/Sidebar.module.css @@ -0,0 +1,54 @@ +.aside { + min-width: 272px; + height: 100%; + padding: 5px; + display: flex; + flex-direction: column; + border-right: 1px solid #e5e5e5; + box-shadow: -2px 0 10px #000; + z-index: 9999; + background-color: #fff; +} + +.aside h2 { + padding-left: 1.5rem; +} + +.list { + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; +} + +.listItem { + margin-bottom: 5px; + padding: 1rem 2rem 1rem 1.5rem; + display: block; + border-radius: 10px; + cursor: pointer; + user-select: none; + outline: currentcolor none medium; +} + +.listItem:hover { + background-color: #f5f5f5; +} + +.listItemActive { + background-color: #e9f3ff !important; +} + +@media only screen and (max-width: 500px) { + .aside { + position: absolute; + top: 0; + bottom: 0; + left: -100vw; + transition: left 0.3s ease-in; + } + + .asideOpen { + left: 0; + } +} diff --git a/components/Sidebar/Sidebar.tsx b/components/Sidebar/Sidebar.tsx new file mode 100644 index 0000000..6b0473a --- /dev/null +++ b/components/Sidebar/Sidebar.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; + +import { Trip } from 'models'; +import { secondsToTimeString } from 'lib/util'; + +import styles from './Sidebar.module.css'; + +interface Props { + trips: Trip[]; + currentTrip: Trip; + asideOpen: boolean; + handleClose: () => void; + setCurrentTrip: (trip: Trip) => void; +} + +export default function Sidebar({ + trips, + currentTrip, + asideOpen, + handleClose, + setCurrentTrip, +}: Props): JSX.Element { + const wrapperRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (wrapperRef.current && !wrapperRef.current.contains(e.target)) { + handleClose(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [wrapperRef]); + + function handleTripChange(trip: Trip): void { + setCurrentTrip(trip); + handleClose(); + } + + return ( + <aside ref={wrapperRef} className={`${styles.aside} ${asideOpen && styles.asideOpen}`}> + <h2>Trips</h2> + <ul className={styles.list}> + {trips.map((t) => ( + <li key={t.name}> + <a + onClick={() => handleTripChange(t)} + onKeyPress={() => handleTripChange(t)} + tabIndex={0} + role="menuitem" + className={`${styles.listItem} ${ + t.name === currentTrip.name && styles.listItemActive + }`} + > + <b>{new Date(t.start).toDateString()}</b> + <br /> + Total distance: {t.distance} km + <br /> + Duration: {secondsToTimeString(t.duration)} + <br /> + Average speed: {t.speed} km/h + </a> + </li> + ))} + </ul> + </aside> + ); +} diff --git a/lib/trips.ts b/lib/trips.ts new file mode 100644 index 0000000..6a58d34 --- /dev/null +++ b/lib/trips.ts @@ -0,0 +1,115 @@ +import path from 'path'; +import fs from 'fs'; +import { DOMParser } from 'xmldom'; +import { FeatureCollection, LineString } from 'geojson'; +import { gpx } from '@tmcw/togeojson'; +import gb from 'geojson-bounds'; +import * as ExifReader from 'exifreader'; + +import { Trip, Photo } from 'models'; +import { distanceBetween } from 'lib/util'; + +const tripsDirectory = path.join(process.cwd(), 'trips'); + +// extract photo metadata from EXIF +function getPhotoMetadata(filePath: string) { + const buffer = fs.readFileSync(filePath); + const tags = ExifReader.load(buffer, { expanded: true }); + + return { + latitude: tags.gps.Latitude, + longitude: tags.gps.Longitude, + time: tags.exif.DateTime.description.replace(':', '-').replace(':', '-'), + thumbnail: tags.Thumbnail.base64, + }; +} + +/** + * Reads GPX files and photos from trips folder. + */ +export default function getTripsData(): Trip[] { + // get folder names under /trips + const dirs = fs + .readdirSync(tripsDirectory, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); + const allTripsData = dirs.map((dir: string) => { + const dirPath = path.join(tripsDirectory, dir); + const files = fs.readdirSync(dirPath); + + const gpxFiles = files.filter((f) => f.endsWith('.gpx')); + + // read GPX file as string + const fullPath = path.join(dirPath, gpxFiles[0]); + const fileContents = fs.readFileSync(fullPath, 'utf8'); + const contentsWithoutNS = fileContents.replace(/\sxmlns[^"]+"[^"]+"/g, ''); + + // create DOM from string + const doc = new DOMParser().parseFromString(contentsWithoutNS); + + // convert GPX to GeoJSON + const track: FeatureCollection = gpx(doc); + + // add bounding box + track.bbox = [gb.xMin(track), gb.yMin(track), gb.xMax(track), gb.yMax(track)]; + + const { coordTimes } = track.features[0].properties; + + // time of the first point + const start = coordTimes[0]; + + // time of the last point + const end = coordTimes[coordTimes.length - 1]; + + // total duration in seconds + const duration = (new Date(end).getTime() - new Date(start).getTime()) / 1000; + + // distance and speed + let totalDistance = 0; + const speeds = []; + const coords = (track.features[0].geometry as LineString).coordinates; + + for (let i = 0; i < coords.length - 1; i += 1) { + const a = [coords[i][1], coords[i][0]]; + const b = [coords[i + 1][1], coords[i + 1][0]]; + const distance = distanceBetween(a, b); + if (distance > 0) { + totalDistance += distance; + const timeBetween = + new Date(coordTimes[i + 1]).getTime() - new Date(coordTimes[i]).getTime(); + speeds.push(distance / timeBetween); + } + } + + // total distance in km + const distance = Math.floor(totalDistance / 10) / 100; + + // average speed in km/h + const speedMps = speeds.reduce((acc, val) => acc + val) / speeds.length; + const speed = Math.floor(speedMps * 3600 * 100) / 100; + + // photos + const photoFiles = files.filter((f) => f.endsWith('.jpg')); + const photos: Photo[] = photoFiles.map((p) => { + // eslint-disable-next-line global-require, import/no-dynamic-require, @typescript-eslint/no-var-requires + const { src } = require(`trips/${dir}/${p}`); + return { + name: p, + src, + ...getPhotoMetadata(path.join(dirPath, p)), + }; + }); + + const trip: Trip = { name: dir, track, distance, start, end, duration, speed, photos }; + + return trip; + }); + + // sort trips by name + return allTripsData.sort((a, b) => { + if (a.name < b.name) { + return 1; + } + return -1; + }); +} diff --git a/lib/useEvent.ts b/lib/useEvent.ts new file mode 100644 index 0000000..70d51b1 --- /dev/null +++ b/lib/useEvent.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +/** + * A hook for creating event handlers. + */ +export default function useEvent( + event: string, + handler: (e: KeyboardEvent | MouseEvent) => void, + passive = false, +): void { + useEffect(() => { + // initiate the event handler + window.addEventListener(event, handler, passive); + + // this will clean up the event every time the component is re-rendered + return function cleanup() { + window.removeEventListener(event, handler); + }; + }); +} diff --git a/lib/util.ts b/lib/util.ts new file mode 100644 index 0000000..5a00aed --- /dev/null +++ b/lib/util.ts @@ -0,0 +1,30 @@ +/** + * Converts time in seconds to HH:mm format. + * @param time Time to convert in seconds. + */ +export function secondsToTimeString(time: number): string { + const h = Math.floor(time / 3600); + const m = Math.floor((time % 3600) / 60); + + return `${h < 10 ? `0${h}` : h}:${m < 10 ? `0${m}` : m}`; +} + +/** + * Calculates distance between two geographical points. + * @param latlng1 Coordinates of the first point. + * @param latlng2 Coordinates of the second point. + */ +export function distanceBetween(latlng1: number[], latlng2: number[]): number { + const R = 6371000; + const rad = Math.PI / 180; + const lat1 = latlng1[0] * rad; + const lat2 = latlng2[0] * rad; + const sinDLat = Math.sin(((latlng2[0] - latlng1[0]) * rad) / 2); + const sinDLon = Math.sin(((latlng2[1] - latlng1[1]) * rad) / 2); + const a = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +export default { secondsToTimeString, distanceBetween }; diff --git a/models/Photo.ts b/models/Photo.ts new file mode 100644 index 0000000..f40d492 --- /dev/null +++ b/models/Photo.ts @@ -0,0 +1,31 @@ +export default interface Photo { + /** + * Name of the photo or the file. + */ + name: string; + + /** + * Path to the photo. + */ + src: string; + + /** + * Latitude where the photo was taken. + */ + latitude: number; + + /** + * Logitude where the photo was taken. + */ + longitude: number; + + /** + * Date and time when the photo was taken. + */ + time: string; + + /** + * Photo thumnnail. + */ + thumbnail: string; +} diff --git a/models/Trip.ts b/models/Trip.ts new file mode 100644 index 0000000..cc01cda --- /dev/null +++ b/models/Trip.ts @@ -0,0 +1,46 @@ +import { GeoJsonObject } from 'geojson'; +import Photo from './Photo'; + +export default interface Trip { + /** + * Name of the trip. + */ + name: string; + + /** + * Total distance made during the trip. + */ + distance: number; + + /** + * Date and time of the beginning of the trip. + * This property is a string because Date is not serializable in Next.js. + */ + start: string; + + /** + * Date and time of the end of the trip. + * This property is a string because Date is not serializable in Next.js. + */ + end: string; + + /** + * Total duration of the trip in seconds. + */ + duration: number; + + /** + * Average speed in km/h. + */ + speed: number; + + /** + * GeoJSON object representing waypoints of the trip + */ + track: GeoJsonObject; + + /** + * Photos taken during the trip. + */ + photos: Photo[]; +} diff --git a/models/index.ts b/models/index.ts new file mode 100644 index 0000000..764b342 --- /dev/null +++ b/models/index.ts @@ -0,0 +1,4 @@ +import Trip from './Trip'; +import Photo from './Photo'; + +export type { Trip, Photo }; diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..7b7aa2c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,2 @@ +/// <reference types="next" /> +/// <reference types="next/types/global" /> |