aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/Gallery/Gallery.module.css63
-rw-r--r--components/Gallery/Gallery.tsx80
-rw-r--r--components/Map/Map.module.css28
-rw-r--r--components/Map/Map.tsx121
-rw-r--r--components/Sidebar/Sidebar.module.css54
-rw-r--r--components/Sidebar/Sidebar.tsx71
6 files changed, 417 insertions, 0 deletions
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='&copy; <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>
+ );
+}