diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/Gallery/Gallery.module.css | 63 | ||||
| -rw-r--r-- | components/Gallery/Gallery.tsx | 80 | ||||
| -rw-r--r-- | components/Map/Map.module.css | 28 | ||||
| -rw-r--r-- | components/Map/Map.tsx | 121 | ||||
| -rw-r--r-- | components/Sidebar/Sidebar.module.css | 54 | ||||
| -rw-r--r-- | components/Sidebar/Sidebar.tsx | 71 | 
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='© <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> +  ); +}  |