From f2ecc1803f3ea294a0c6b7915b61348ed0395b26 Mon Sep 17 00:00:00 2001 From: Marcin Zelent Date: Wed, 16 Nov 2022 15:16:38 +0100 Subject: Remade and extended the app using React --- src/App.module.css | 43 ++ src/App.test.tsx | 9 + src/App.tsx | 303 +++++++++++ src/components/Lightbox/Lightbox.css | 7 + src/components/Lightbox/Lightbox.ts | 152 ++++++ .../LoadingScreen/LoadingScreen.module.css | 27 + src/components/LoadingScreen/LoadingScreen.tsx | 10 + src/components/Map/Map.module.css | 28 + src/components/Map/Map.tsx | 190 +++++++ .../ReactLeafletControl/ReactLeafletControl.tsx | 104 ++++ src/components/Sidebar/Sidebar.module.css | 158 ++++++ src/components/Sidebar/Sidebar.tsx | 152 ++++++ .../TileLayerControl/TileLayerControl.module.css | 20 + .../TileLayerControl/TileLayerControl.tsx | 21 + src/generate-data.ts | 563 +++++++++++++++++++++ src/index.css | 19 + src/index.tsx | 19 + src/lib/MarkerClusterGroup.js | 32 ++ src/lib/util.ts | 30 ++ src/models/Group.ts | 35 ++ src/models/MediaItem.ts | 53 ++ src/models/MediaType.ts | 6 + src/models/Trip.ts | 28 + src/models/index.guard.ts | 58 +++ src/models/index.ts | 7 + src/react-app-env.d.ts | 1 + src/reportWebVitals.ts | 17 + src/setupTests.ts | 5 + src/types/geojson-bounds.d.ts | 33 ++ src/types/photoswipe-lightbox.d.ts | 104 ++++ src/types/types.d.ts | 14 + 31 files changed, 2248 insertions(+) create mode 100644 src/App.module.css create mode 100644 src/App.test.tsx create mode 100644 src/App.tsx create mode 100644 src/components/Lightbox/Lightbox.css create mode 100644 src/components/Lightbox/Lightbox.ts create mode 100644 src/components/LoadingScreen/LoadingScreen.module.css create mode 100644 src/components/LoadingScreen/LoadingScreen.tsx create mode 100644 src/components/Map/Map.module.css create mode 100644 src/components/Map/Map.tsx create mode 100644 src/components/ReactLeafletControl/ReactLeafletControl.tsx create mode 100644 src/components/Sidebar/Sidebar.module.css create mode 100644 src/components/Sidebar/Sidebar.tsx create mode 100644 src/components/TileLayerControl/TileLayerControl.module.css create mode 100644 src/components/TileLayerControl/TileLayerControl.tsx create mode 100644 src/generate-data.ts create mode 100644 src/index.css create mode 100644 src/index.tsx create mode 100644 src/lib/MarkerClusterGroup.js create mode 100644 src/lib/util.ts create mode 100644 src/models/Group.ts create mode 100644 src/models/MediaItem.ts create mode 100644 src/models/MediaType.ts create mode 100644 src/models/Trip.ts create mode 100644 src/models/index.guard.ts create mode 100644 src/models/index.ts create mode 100644 src/react-app-env.d.ts create mode 100644 src/reportWebVitals.ts create mode 100644 src/setupTests.ts create mode 100644 src/types/geojson-bounds.d.ts create mode 100644 src/types/photoswipe-lightbox.d.ts create mode 100644 src/types/types.d.ts (limited to 'src') diff --git a/src/App.module.css b/src/App.module.css new file mode 100644 index 0000000..075f53c --- /dev/null +++ b/src/App.module.css @@ -0,0 +1,43 @@ +.container { + width: 100%; + height: 100%; + display: flex; + overflow: hidden; +} + +.main { + height: 100%; + flex: 1 1 auto; +} + +.asideToggle { + display: none; +} + +@media only screen and (max-width: 500px) { + .asideToggle { + width: 36px; + height: 36px; + padding: 0; + border: 2px solid rgba(0, 0, 0, 0.2); + border-radius: 4px; + display: block; + position: absolute; + top: 10px; + left: 10px; + z-index: 999; + background-color: #fff; + background-clip: padding-box; + cursor: pointer; + user-select: none; + outline: currentcolor none medium; + } + + .asideToggle:hover { + background-color: #f4f4f4; + } + + .asideOpen { + left: 0; + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..585a39e --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + render(); + const linkElement = screen.getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8dece4e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,303 @@ +import React, { useEffect, useState } from 'react'; +import { SlideData } from 'photoswipe'; +import PhotoSwipeLightbox from 'photoswipe/lightbox'; +import Sidebar from './components/Sidebar/Sidebar'; +import Map from './components/Map/Map'; +import { createLightbox } from './components/Lightbox/Lightbox'; +import { MediaType, Trip } from './models'; +import { isTrip } from './models/index.guard'; + +import styles from './App.module.css'; +import 'photoswipe/style.css'; +import LoadingScreen from './components/LoadingScreen/LoadingScreen'; + +function App(): JSX.Element { + const [allTrips, setAllTrips] = useState([]); + const [currentTripIndex, setCurrentTripIndex] = useState(0); + const [currentGroupIndex, setCurrentGroupIndex] = useState(1); + const [asideOpen, setAsideOpen] = useState(false); + const [lightbox, setLightbox] = useState(); + const [openLightbox, setOpenLightbox] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + + const currentTrip = allTrips[currentTripIndex]; + const currentGroup = currentTrip?.groups?.[currentGroupIndex]; + + async function getAllTrips(): Promise { + try { + const url = process.env.REACT_APP_DATA_URL ?? '/data/index.json'; + + const res = await fetch(url); + const data = await res.json(); + + if (!Array.isArray(data) || data.length === 0) { + return []; + } + + data.forEach((obj) => { + if (!isTrip(obj)) { + throw new Error('The requested data file has incorrect structure or types.'); + } + }); + + return data; + } catch (err) { + setError(`An error occurred while retrieving data: "${err as string}"`); + console.error(err); + return []; + } + } + + async function getTrip(url: string): Promise { + try { + if (url === undefined) { + return; + } + + const res = await fetch(url); + const data: Trip = await res.json(); + + if (!isTrip(data)) { + throw new Error('The requested data file has incorrect structure or types.'); + } + + const trip = { + ...data, + url, + downloaded: true, + }; + + if (data.groups !== undefined) { + + trip.groups = [ + { + id: 'all', + name: 'Show all', + media: data.groups?.flatMap((g) => g.media), + geoData: data.groups?.flatMap((g) => g.geoData !== undefined ? g.geoData : []) + }, + ...data.groups, + ]; + } + + return trip; + } catch (err) { + setError(`An error occurred while retrieving data: "${err as string}"`); + console.error(err); + } + } + + function updateAllTrips(trips: Trip[], trip: Trip): void { + const updatedTrips = (trips).map((t) => { + if (t.id === trip.id) { + t = trip; + } + + return t; + }); + + setAllTrips(updatedTrips); + } + + function updateUrlHash(trip: Trip): void { + const groupId = trip.groups?.[1] !== undefined ? `/${trip.groups[1].id}` : ''; + window.location.hash = `${trip.id}${groupId}`; + } + + async function getFirstTrip(trips: Trip[]): Promise { + const trip = await getTrip(trips[0].url); + + if (trip !== undefined) { + updateAllTrips(trips, trip); + updateUrlHash(trip); + } + } + + async function getTripFromUrlHash(trips: Trip[]): Promise { + if (window.location.hash.length === 0) { + return await getFirstTrip(trips); + } + + const hash = window.location.hash.endsWith('/') + ? window.location.hash.slice(0, window.location.hash.length - 1) + : window.location.hash; + + const [tripId, groupId] = hash.substring(1).split('/'); + + const tripIndex = trips.findIndex((t) => t.id === tripId); + + if (tripIndex < 0) { + return await getFirstTrip(trips); + } + + const trip = await getTrip(trips[tripIndex].url); + + if (trip === undefined) { + return await getFirstTrip(trips); + } + + updateAllTrips(trips, trip); + setCurrentTripIndex(tripIndex); + + const groupIndex = trip.groups?.findIndex((g) => g.id === groupId); + if (groupIndex !== undefined && groupIndex > -1) { + setCurrentGroupIndex(groupIndex); + } else { + updateUrlHash(trip); + } + } + + useEffect(() => { + void getAllTrips().then(async (trips) => { + setAllTrips(trips); + await getTripFromUrlHash(trips); + setIsLoading(false); + }); + }, []); + + useEffect(() => { + if (currentTrip !== undefined && currentGroup !== undefined) { + window.location.hash = `${currentTrip.id}/${currentGroup.id}`; + } + }, [currentTripIndex, currentGroupIndex]); + + useEffect(() => { + if (lightbox !== undefined) { + lightbox.destroy(); + } + + if (currentGroup?.media === undefined || currentGroup?.media.length === 0) { + return; + } + + const dataSource = currentGroup?.media.map((mediaItem) => { + const slideData: SlideData = { + width: mediaItem.width, + height: mediaItem.height, + alt: mediaItem.caption, + }; + + if (mediaItem.type === MediaType.Photo) { + slideData.type = 'image'; + slideData.src = mediaItem.src; + } else if (mediaItem.type === MediaType.Video) { + slideData.type = 'video'; + slideData.videoSrc = mediaItem.src; + } + + return slideData; + }); + + const lb = createLightbox(dataSource); + + lb.on('beforeOpen', () => setIsLoading(false)); + + lb.init(); + + if (openLightbox) { + lb.loadAndOpen(0); + setOpenLightbox(false); + } + + setLightbox(lb); + }, [currentGroup]); + + function handleMarkerClick(mediaName: string): void { + if (currentGroup === undefined) { + return; + } + + const index = currentGroup.media.findIndex((p) => p.name === mediaName); + if (index < 0) { + lightbox?.loadAndOpen(0); + } else { + lightbox?.loadAndOpen(index); + } + } + + function handleGroupChange(groupIndex: number, openGallery?: boolean): void { + setCurrentGroupIndex(groupIndex); + + if (openGallery ?? false) { + setOpenLightbox(true); + } + } + + function handleTripChange(tripIndex: number): void { + if (allTrips[tripIndex] === undefined) { + return; + } + + setIsLoading(true); + + if (!allTrips[tripIndex].downloaded) { + void getTrip(allTrips[tripIndex].url).then((trip) => { + if (trip === undefined) { + return; + } + + updateAllTrips(allTrips, trip); + + setCurrentTripIndex(tripIndex); + setCurrentGroupIndex(1); + setIsLoading(false); + }); + } else { + setCurrentTripIndex(tripIndex); + setCurrentGroupIndex(1); + setIsLoading(false); + } + } + + if (error !== undefined) { + return <>{error}; + } + + if (currentTrip === undefined || currentTrip.groups === undefined) { + return ; + } + + return ( +
+ setAsideOpen(false)} + handleGroupChange={handleGroupChange} + handleTripChange={handleTripChange} + /> + + + +
+ +
+ {isLoading && } +
+ ); +} + +export default App; diff --git a/src/components/Lightbox/Lightbox.css b/src/components/Lightbox/Lightbox.css new file mode 100644 index 0000000..4873419 --- /dev/null +++ b/src/components/Lightbox/Lightbox.css @@ -0,0 +1,7 @@ +.pswp__dynamic-caption { + max-width: unset !important; + padding: 10px !important; + text-align: center; + font-size: 0.9rem; + color: #ccc !important; +} diff --git a/src/components/Lightbox/Lightbox.ts b/src/components/Lightbox/Lightbox.ts new file mode 100644 index 0000000..4eadba9 --- /dev/null +++ b/src/components/Lightbox/Lightbox.ts @@ -0,0 +1,152 @@ +import { DataSource } from 'photoswipe'; +import PhotoSwipeLightbox from 'photoswipe/lightbox'; +import { Slide } from 'photoswipe/dist/types/slide/content'; +import PhotoSwipeDynamicCaption from 'photoswipe-dynamic-caption-plugin'; +import PhotoSwipeVideoPlugin from 'photoswipe-video-plugin'; + +import './Lightbox.css'; +import 'photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css'; + +export function createLightbox(dataSource: DataSource): PhotoSwipeLightbox { + const lb = new PhotoSwipeLightbox({ + dataSource, + pswpModule: async () => await import('photoswipe'), + }); + + const fullscreenSVG = + ''; + + lb.on('uiRegister', () => { + lb.pswp?.ui.registerElement({ + name: 'fullscreen-button', + title: 'Toggle fullscreen', + order: 9, + isButton: true, + html: fullscreenSVG, + onClick: () => { + toggleFullscreen(); + }, + }); + }); + + // eslint-disable-next-line no-new + new PhotoSwipeDynamicCaption(lb, { + type: 'below', + captionContent: (slide: Slide) => slide.data.alt, + }); + + // eslint-disable-next-line no-new + new PhotoSwipeVideoPlugin(lb, { + autoplay: false, + }); + + return lb; +} + +interface WebkitDocument extends Document { + webkitExitFullscreen?: () => Promise; + webkitFullscreenElement?: Element; + [key: string]: any; +} + +interface WebkitHTMLElement extends HTMLElement { + webkitRequestFullscreen: () => Promise; + [key: string]: any; +} + +interface FullscreenAPI { + request: (element: WebkitHTMLElement) => void; + exit: () => void; + isFullscreen: () => boolean; + change: string; + error: string; +} + +function getFullscreenAPI(): FullscreenAPI | undefined { + let api; + let enterFS = ''; + let exitFS = ''; + let elementFS = ''; + let changeEvent = ''; + let errorEvent = ''; + + if (document.documentElement.requestFullscreen !== undefined) { + enterFS = 'requestFullscreen'; + exitFS = 'exitFullscreen'; + elementFS = 'fullscreenElement'; + changeEvent = 'fullscreenchange'; + errorEvent = 'fullscreenerror'; + } else if ( + (document.documentElement as WebkitHTMLElement).webkitRequestFullscreen !== undefined + ) { + enterFS = 'webkitRequestFullscreen'; + exitFS = 'webkitExitFullscreen'; + elementFS = 'webkitFullscreenElement'; + changeEvent = 'webkitfullscreenchange'; + errorEvent = 'webkitfullscreenerror'; + } + + if (enterFS !== '') { + api = { + request: (element: WebkitHTMLElement) => { + if (enterFS === 'webkitRequestFullscreen') { + void element.webkitRequestFullscreen(); + } else { + void element.requestFullscreen(); + } + }, + exit: (): void => { + return (document as WebkitDocument)[exitFS](); + }, + isFullscreen: (): boolean => { + return (document as WebkitDocument)[elementFS]; + }, + change: changeEvent, + error: errorEvent, + }; + } + + return api; +} + +// Toggle full-screen mode function +function toggleFullscreen(): void { + const fullscreenAPI = getFullscreenAPI(); + + if (fullscreenAPI !== undefined) { + if (fullscreenAPI.isFullscreen()) { + // Exit full-screen mode + fullscreenAPI.exit(); + // Toggle "Exit" and "Enter" full-screen SVG icon display + setTimeout(function () { + const exitIcon = document.getElementById('pswp__icn-fullscreen-exit'); + if (exitIcon !== null) { + exitIcon.style.display = 'none'; + } + + const requestIcon = document.getElementById('pswp__icn-fullscreen-request'); + if (requestIcon !== null) { + requestIcon.style.display = 'inline'; + } + }, 300); + } else { + // Enter full-screen mode + const pswp = document.querySelector(`.pswp`); + if (pswp != null) { + fullscreenAPI.request(pswp as WebkitHTMLElement); + } + // Toggle "Exit" and "Enter" full-screen SVG icon display + setTimeout(function () { + const exitIcon = document.getElementById('pswp__icn-fullscreen-exit'); + if (exitIcon !== null) { + exitIcon.style.display = 'inline'; + } + + const requestIcon = document.getElementById('pswp__icn-fullscreen-request'); + if (requestIcon !== null) { + requestIcon.style.display = 'none'; + } + }, 300); + } + } +} diff --git a/src/components/LoadingScreen/LoadingScreen.module.css b/src/components/LoadingScreen/LoadingScreen.module.css new file mode 100644 index 0000000..1f690bd --- /dev/null +++ b/src/components/LoadingScreen/LoadingScreen.module.css @@ -0,0 +1,27 @@ +.container { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + display: flex; + z-index: 99999; + background: rgb(255 255 255 / 75%); +} + +.spinner { + width: 50px; + height: 50px; + margin: auto; + display: inline-block; + border: 3px solid #c9c9c9; + border-radius: 50%; + border-top-color: #25a070; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/LoadingScreen/LoadingScreen.tsx b/src/components/LoadingScreen/LoadingScreen.tsx new file mode 100644 index 0000000..22509ee --- /dev/null +++ b/src/components/LoadingScreen/LoadingScreen.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './LoadingScreen.module.css'; + +export default function LoadingScreen(): JSX.Element { + return ( +
+
+
+ ); +} diff --git a/src/components/Map/Map.module.css b/src/components/Map/Map.module.css new file mode 100644 index 0000000..a265138 --- /dev/null +++ b/src/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/src/components/Map/Map.tsx b/src/components/Map/Map.tsx new file mode 100644 index 0000000..6f2f469 --- /dev/null +++ b/src/components/Map/Map.tsx @@ -0,0 +1,190 @@ +import { LatLngBounds, Icon, MarkerCluster, DivIcon } from 'leaflet'; +import React, { useMemo, useState } from 'react'; +import ReactDom from 'react-dom/server'; +import { GeoJSON, MapContainer, Marker, TileLayer, ZoomControl, useMap } from 'react-leaflet'; + +import { Group, MediaItem } from '../../models'; +import MarkerClusterGroup from '../../lib/MarkerClusterGroup'; + +import 'leaflet/dist/leaflet.css'; +import styles from './Map.module.css'; +import TileLayerControl, { TileLayerType } from '../TileLayerControl/TileLayerControl'; +import ReactLeafletControl from '../ReactLeafletControl/ReactLeafletControl'; +import { LineString } from 'geojson'; + +interface Props { + group: Group | undefined; + handleMarkerClick: (photo: string) => void; +} + +export default function Map({ group, handleMarkerClick }: Props): JSX.Element { + const [tileLayer, setTileLayer] = useState(TileLayerType.map); + + const displayedMedia = useMemo(() => { + if (group?.media !== undefined) { + return group.media.filter( + (p) => + p.latitude !== undefined && + p.latitude !== null && + !isNaN(p.latitude) && + p.latitude !== 0 && + p.longitude !== undefined && + p.longitude !== null && + !isNaN(p.longitude) && + p.longitude !== 0 && + p.longitude !== 0, + ); + } + + return []; + }, [group]); + + const bounds = useMemo(() => { + if (group === undefined || (group.geoData === undefined && displayedMedia.length === 0)) { + return new LatLngBounds([75, -145], [-52, 145]); + } + + const latitudes = displayedMedia.map((p) => p.latitude) as number[]; + const longitudes = displayedMedia.map((p) => p.longitude) as number[]; + + group.geoData?.forEach((data) => { + if (data.bbox !== undefined) { + latitudes.push(data.bbox[1]); + latitudes.push(data.bbox[3]); + longitudes.push(data.bbox[0]); + longitudes.push(data.bbox[2]); + } + }); + + const minLatitude = Math.min(...latitudes); + const minLongitude = Math.min(...longitudes); + const maxLatitude = Math.max(...latitudes); + const maxLongitude = Math.max(...longitudes); + + return new LatLngBounds([minLatitude, minLongitude], [maxLatitude, maxLongitude]); + }, [group]); + + function getClusterGroupIcon(markerCluster: MarkerCluster): DivIcon { + const iconUrl = markerCluster + .getAllChildMarkers() + .filter((m) => m.getIcon().options.iconUrl !== '/icons/thumb-placeholder.png')[0] + .getIcon().options.iconUrl; + + return new DivIcon({ + html: ReactDom.renderToString( + <> + + {markerCluster.getChildCount()} + , + ), + iconSize: [36, 36], + className: styles.markerIcon, + }); + } + + function createMarkers(media: MediaItem[]): JSX.Element { + if (media.length === 0) { + return <>; + } + + return ( + + {media.map((mediaItem, index) => ( + handleMarkerClick(mediaItem.name) }} + /> + ))} + + ); + } + + const mapBounds = useMemo(() => { + function MapBounds({ bounds }: { bounds: LatLngBounds }): JSX.Element { + const map = useMap(); + + if (map !== undefined) { + map.setView(bounds.getCenter()); + map.fitBounds(bounds, { padding: [40, 40] }); + } + + return <>; + } + + return ; + }, [group]); + + return ( + + <> + + + setTileLayer( + tileLayer === TileLayerType.map ? TileLayerType.satellite : TileLayerType.map, + ) + } + /> + + {tileLayer === TileLayerType.map ? ( + + ) : ( + + )} + + {mapBounds} + {createMarkers(displayedMedia)} + {group?.geoData?.map((data, i) => { + const coordinates = (data.features?.[0]?.geometry as LineString).coordinates; + const startCoords = coordinates[0].slice(0, 2).reverse(); + const finishCoords = coordinates[coordinates.length - 1].slice(0, 2).reverse(); + + return ( + + + + + + ); + })} + + + ); +} diff --git a/src/components/ReactLeafletControl/ReactLeafletControl.tsx b/src/components/ReactLeafletControl/ReactLeafletControl.tsx new file mode 100644 index 0000000..2592b25 --- /dev/null +++ b/src/components/ReactLeafletControl/ReactLeafletControl.tsx @@ -0,0 +1,104 @@ +import React, { + useState, + useEffect, + forwardRef, + useImperativeHandle, + Ref, + ReactNode, + ReactPortal, +} from 'react'; +import { createPortal } from 'react-dom'; +import { Control, ControlOptions, DomUtil, DomEvent, Map } from 'leaflet'; +import { + ElementHook, + LeafletProvider, + LeafletElement, + createElementHook, + LeafletContextInterface, + createControlHook, +} from '@react-leaflet/core'; +import { useMap } from 'react-leaflet'; + +interface IDumbControl extends Control {} +interface PropsWithChildren { + children?: ReactNode; +} +interface ControlOptionsWithChildren extends ControlOptions { + children?: ReactNode; +} + +const DumbControl = Control.extend({ + options: { + className: '', + onOff: '', + handleOff: function noop() {}, + }, + + onAdd(/* map */) { + const _controlDiv = DomUtil.create('div', this.options.className); + + DomEvent.on(_controlDiv, 'click', (event) => { + DomEvent.stopPropagation(event); + }); + DomEvent.disableScrollPropagation(_controlDiv); + DomEvent.disableClickPropagation(_controlDiv); + + return _controlDiv; + }, + + onRemove(map: Map) { + if (this.options.onOff !== '') { + map.off(this.options.onOff, this.options.handleOff, this); + } + + return this; + }, +}); + +const useForceUpdate: () => () => void = () => { + const [, setValue] = useState(0); // integer state + return () => setValue((value) => value + 1); // update the state to force render +}; + +export function createContainerComponent( + useElement: ElementHook, +): React.ForwardRefExoticComponent & React.RefAttributes> { + function ContainerComponent(props: P, ref: Ref): ReactPortal | null { + const forceUpdate = useForceUpdate(); + const map = useMap(); + const ctx = { __version: 0, map }; + const { instance, context } = useElement(props, ctx).current; + const children = props.children; + const contentNode = (instance as any).getContainer(); + + useImperativeHandle(ref, () => instance); + useEffect(() => { + forceUpdate(); + }, [contentNode]); + + if (children === undefined || contentNode === undefined) return null; + + return createPortal({children}, contentNode); + } + + return forwardRef(ContainerComponent); +} + +export function createControlComponent( + createInstance: (props: P) => E, +): React.ForwardRefExoticComponent & React.RefAttributes> { + function createElement(props: P, context: LeafletContextInterface): LeafletElement { + return { instance: createInstance(props), context }; + } + const useElement = createElementHook(createElement); + const useControl = createControlHook(useElement); + return createContainerComponent(useControl); +} + +const ReactLeafletControl = createControlComponent( + function createControlWithChildren(props) { + return new DumbControl(props); + }, +); + +export default ReactLeafletControl; diff --git a/src/components/Sidebar/Sidebar.module.css b/src/components/Sidebar/Sidebar.module.css new file mode 100644 index 0000000..8081492 --- /dev/null +++ b/src/components/Sidebar/Sidebar.module.css @@ -0,0 +1,158 @@ +.aside { + min-width: 272px; + width: 20%; + 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; +} + +.headline { + padding-left: 16px; + position: relative; +} + +.headline h2 { + display: inline-block; + line-height: 40px; + margin-bottom: 0; +} + +.headline button { + width: 40px; + height: 40px; + padding: 0; + margin-left: 2px; + display: inline-block; + border: none; + border-radius: 100%; + background: none; + cursor: pointer; +} + +.headline button:hover { + background: #e9f3ff; +} + +.headline button div { + width: 8px; + height: 8px; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #000; + display: inline-block; +} + +.tripList { + min-width: 80%; + padding: 5px 0; + position: absolute; + top: 100%; + left: 16px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 5px; + box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1); +} + +.tripList ul { + margin: 0; + padding: 0; + list-style-type: none; +} + +.tripList li { + padding: 5px; + user-select: none; +} + +.tripList li a { + padding: 10px 20px 10px 10px; + display: block; + border-radius: 5px; + font-weight: bold; + cursor: pointer; +} + +.tripList li a:hover { + background-color: #f5f5f5; +} + +.list { + margin: 24px 0 0 0; + padding: 0; + list-style: none; + overflow-y: auto; +} + +.listItem { + min-height: 56px; + margin-bottom: 5px; + padding: 10px 16px; + display: flex; + border-radius: 10px; + cursor: pointer; + user-select: none; + outline: currentcolor none medium; +} + +.listItem:hover { + background-color: #f5f5f5; +} + +.listItem:hover > .listItemButton{ + display: flex; +} + +.listItemActive { + background-color: #e9f3ff !important; +} + +.listItemContent { + max-width: calc(100% - 36px); + margin: auto 0; + flex-grow: 1; +} + +.listItemButton { + width: 36px; + height: 36px; + padding: 9px; + margin: auto; + display: none; + border: none; + border-radius: 36px; + box-shadow: 0 0 0 1px rgb(0 0 0 / 10%); + background: #fff; + cursor: pointer; +} + +.listItemButton:hover { + background: #fafafa; +} + +.listItemButton svg { + margin: auto; +} + +.preformatted { + white-space: pre; +} + +@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/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 0000000..b4ddaa9 --- /dev/null +++ b/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { Group, Trip } from '../../models'; + +import styles from './Sidebar.module.css'; + +interface Props { + trips: Trip[]; + currentTrip: Trip; + groups: Group[]; + currentGroup: Group | undefined; + headline?: string; + asideOpen: boolean; + handleClose: () => void; + handleGroupChange: (groupIndex: number, openGallery?: boolean) => void; + handleTripChange: (tripIndex: number) => void; +} + +export default function Sidebar({ + trips, + currentTrip, + groups, + currentGroup, + asideOpen, + handleClose, + handleGroupChange, + handleTripChange, +}: Props): JSX.Element { + const [tripListOpen, setTripListOpen] = useState(false); + const wrapperRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: Event): void { + if ( + wrapperRef.current !== null && + e.target !== null && + !wrapperRef.current.contains(e.target as Node) + ) { + handleClose(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [wrapperRef]); + + function groupChangeHandler(groupIndex: number, openGallery?: boolean): void { + handleGroupChange(groupIndex, openGallery); + handleClose(); + } + + function tripChangeHandler(tripIndex: number): void { + handleTripChange(tripIndex); + setTripListOpen(false); + } + + function buildGroupDescription( + template: string | undefined, + tokens: { [key: string]: string | number } | undefined, + ): string { + if (template === undefined) { + return ''; + } + + let description = template; + + if (tokens === undefined) { + return description; + } + + Object.keys(tokens).forEach((key) => { + description = description.replaceAll(`{${key}}`, `${tokens[key]}`); + }); + + return description; + } + + const asideStyle = asideOpen ? `${styles.aside} ${styles.asideOpen}` : styles.aside; + + return ( + + ); +} diff --git a/src/components/TileLayerControl/TileLayerControl.module.css b/src/components/TileLayerControl/TileLayerControl.module.css new file mode 100644 index 0000000..ffff063 --- /dev/null +++ b/src/components/TileLayerControl/TileLayerControl.module.css @@ -0,0 +1,20 @@ +.tileLayerControl { + width: 64px; + height: 64px; +} + +.map { + background-image: url('/public/icons/map.png'); +} + +.satellite { + background-image: url('/public/icons/satellite.png'); +} + +.tileLayerControl button { + width: 100%; + height: 100%; + border: none; + cursor: pointer; +} + diff --git a/src/components/TileLayerControl/TileLayerControl.tsx b/src/components/TileLayerControl/TileLayerControl.tsx new file mode 100644 index 0000000..026ade1 --- /dev/null +++ b/src/components/TileLayerControl/TileLayerControl.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './TileLayerControl.module.css'; + +export enum TileLayerType { + map, + satellite, +} + +interface Props { + tileType: TileLayerType; + onClick: () => void; +} + +export default function TileLayerControl({ tileType, onClick }: Props): JSX.Element { + const bgClass = tileType === TileLayerType.map ? styles.satellite : styles.map; + return ( +
+ +
+ ); +} diff --git a/src/generate-data.ts b/src/generate-data.ts new file mode 100644 index 0000000..a67c620 --- /dev/null +++ b/src/generate-data.ts @@ -0,0 +1,563 @@ +import { constants as fsConstants, Dirent } from 'fs'; +import { access, mkdir, readdir, readFile, writeFile } from 'fs/promises'; +import { promisify } from 'util'; +import path from 'path'; +import exifReader from 'exif-reader'; +import sizeOf from 'image-size'; +import sharp from 'sharp'; +import ffmpegPath from 'ffmpeg-static'; +import ffprobePath from 'ffprobe-static'; +import FfmpegCommand, { FfprobeData } from 'fluent-ffmpeg'; +import { DOMParser } from 'xmldom'; +import { FeatureCollection, LineString } from 'geojson'; +import { gpx } from '@tmcw/togeojson'; +import gb from 'geojson-bounds'; +import { MediaItem, Group, MediaType } from './models'; +import { distanceBetween, secondsToTimeString } from './lib/util'; + +const promisifiedSizeOf = promisify(sizeOf); + +interface GroupedFiles { + photos: Dirent[]; + videos: Dirent[]; + geoData: Dirent[]; +} + +interface DimensionsTuple { + width: number; + height: number; +} + +interface FileError { + file: string; + error: any; +} + +const failedFiles: FileError[] = []; + +function setupFfmpeg(): void { + if (ffmpegPath === null) { + console.warn( + 'Could not use built-in FFmpeg. Make sure you have FFmpeg installed, if you want to process videos.', + ); + return; + } + + FfmpegCommand.setFfmpegPath(ffmpegPath); + FfmpegCommand.setFfprobePath(ffprobePath.path); +} + +function hasExtension(filename: string, acceptedExtensions: string[]): boolean { + const index = filename.lastIndexOf('.'); + if (index < 0) { + return false; + } + + const extension = filename.substring(index + 1); + + return acceptedExtensions.includes(extension); +} + +function hasImageExtension(filename: string): boolean { + const acceptedExtensions = [ + 'apng', + 'avif', + 'bmp', + 'dib', + 'gif', + 'jfi', + 'jfif', + 'jif', + 'jpe', + 'jpeg', + 'jpg', + 'png', + 'tif', + 'tiff', + 'webp', + ]; + + return hasExtension(filename, acceptedExtensions); +} + +function hasVideoExtension(filename: string): boolean { + const acceptedExtensions = ['avi', 'mkv', 'mov', 'mp4', 'webm']; + + return hasExtension(filename, acceptedExtensions); +} + +function convertCoordinate(coordinate: number[], coordinateRef: string): number | undefined { + if (coordinate !== undefined && coordinateRef !== undefined) { + const ref = coordinateRef === 'N' || coordinateRef === 'E' ? 1 : -1; + return ref * (coordinate[0] + coordinate[1] / 60 + coordinate[2] / 3600); + } +} + +async function checkIfFileExists(filepath: string): Promise { + try { + await access(filepath, fsConstants.F_OK); + return true; + } catch (err: any) { + if (err.code === 'ENOENT') { + return false; + } + + throw err; + } +} + +function getNormalImageSize({ + width, + height, + orientation, +}: { + width: number; + height: number; + orientation: number; +}): DimensionsTuple { + return orientation >= 5 ? { width: height, height: width } : { width, height }; +} + +async function resizePhoto( + photo: sharp.Sharp, + filepath: string, + orientation: number, +): Promise { + const outputInfo = await photo + .withMetadata({ orientation }) + .resize({ + width: orientation >= 5 ? 1080 : undefined, + height: 1080, + fit: sharp.fit.inside, + withoutEnlargement: true, + }) + .toFile(filepath); + + return getNormalImageSize({ ...outputInfo, orientation }); +} + +async function resizePhotoOrGetSize( + photo: sharp.Sharp, + filepath: string, + orientation: number, +): Promise { + const absolutePath = path.resolve(filepath); + const fileExists = await checkIfFileExists(absolutePath); + + if (!fileExists) { + console.log(`File "${absolutePath}" doesn't exist, resizing the image...`); + return await resizePhoto(photo, absolutePath, orientation); + } + + console.log(`File ${absolutePath} already exists, trying to get dimensions of the image...`); + + try { + const dimensions = await promisifiedSizeOf(absolutePath); + if (dimensions?.width !== undefined || dimensions?.height !== undefined) { + return getNormalImageSize({ + width: dimensions.width as number, + height: dimensions.height as number, + orientation, + }); + } + } catch (err) { + console.error(`Could not get the dimensions of the image ${absolutePath}.`); + console.error(err); + } + + console.log(`Trying to resize the image ${absolutePath}...`); + return await resizePhoto(photo, absolutePath, orientation); +} + +async function generateThumbnail( + photo: sharp.Sharp, + filepath: string, + orientation?: number, +): Promise { + const fileExists = await checkIfFileExists(filepath); + if (!fileExists) { + await photo + .withMetadata({ orientation }) + .resize({ width: 36, height: 36, withoutEnlargement: true }) + .toFile(filepath); + } +} + +async function getPhotos( + inputDir: string, + outputPath: string, + serverPath: string, + relativePath: string, + files: Dirent[], +): Promise { + const photos: MediaItem[] = []; + + for (const file of files) { + const inputFile = `${inputDir}/${file.name}`; + const outputDir = `${outputPath}/${relativePath}`; + + try { + // load photo using sharp + const photo = sharp(inputFile, { failOnError: false }); + + // read photo metadata + const metadata = await photo.metadata(); + const exifData = exifReader(metadata.exif); + + // reformat EXIF coordinates to usable ones + const latitude = convertCoordinate(exifData.gps?.GPSLatitude, exifData.gps?.GPSLatitudeRef); + const longitude = convertCoordinate( + exifData.gps?.GPSLongitude, + exifData.gps?.GPSLongitudeRef, + ); + + await mkdir(outputDir, { recursive: true }); + + // resize the photo or get size of existing + const outputInfo = await resizePhotoOrGetSize( + photo, + `${outputDir}/${file.name}`, + metadata.orientation ?? 1, + ); + + await generateThumbnail(photo, `${outputDir}/thumb-${file.name}`, metadata.orientation); + + photos.push({ + name: file.name, + src: `${serverPath}/${relativePath}/${file.name}`, + type: MediaType.Photo, + width: outputInfo.width, + height: outputInfo.height, + latitude, + longitude, + time: exifData.exif?.DateTimeOriginal, + thumbnail: `${serverPath}/${relativePath}/thumb-${file.name}`, + caption: exifData.image?.ImageDescription, + }); + } catch (err) { + console.error(`Could not process the file ${inputFile}.`); + console.error(err); + failedFiles.push({ file: inputFile, error: err }); + } + } + + return photos; +} + +async function getVideoInfo(file: string): Promise { + return await new Promise((resolve, reject) => { + FfmpegCommand.ffprobe(file, (err, data) => { + if (err !== undefined && err !== null) { + return reject(err); + } + + return resolve(data); + }); + }); +} + +async function optimizeVideo(inputFile: string, outputFile: string): Promise { + const command = FfmpegCommand(inputFile); + + return await new Promise((resolve, reject) => { + command + .complexFilter(["scale=-2:min'(1080,ih)'"]) + .on('progress', (progress) => { + console.log(`Processing file ${inputFile}: ${progress.percent as number}% done`); + }) + .on('error', (err) => reject(err)) + .on('end', () => resolve(getVideoInfo(outputFile))) + .save(outputFile); + }); +} + +async function generateVideoThumbnail( + inputFile: string, + outputDir: string, + outputFileName: string, +): Promise { + const command = FfmpegCommand(inputFile); + + return await new Promise((resolve, reject) => { + command + .screenshots({ size: '36x36', count: 1, folder: outputDir, filename: outputFileName }) + .on('error', (err) => reject(err)) + .on('end', () => resolve()); + }); +} + +async function optimizeVideoOrGetInfo(inputFile: string, outputFile: string): Promise { + const fileExists = await checkIfFileExists(outputFile); + + if (!fileExists) { + console.log(`File "${outputFile}" doesn't exist, optimizing the video...`); + return await optimizeVideo(inputFile, outputFile); + } + + console.log(`File ${outputFile} already exists, trying to get info about the video...`); + + try { + return await getVideoInfo(outputFile); + } catch (err) { + console.error(`Could not get the info about video ${outputFile}.`); + console.error(err); + } + + return await optimizeVideo(inputFile, outputFile); +} + +async function processVideo( + inputDir: string, + outputPath: string, + serverPath: string, + relativePath: string, + file: Dirent, +): Promise { + const inputFile = `${inputDir}/${file.name}`; + const outputDir = `${outputPath}/${relativePath}`; + const outputFileName = `${file.name.substring(0, file.name.lastIndexOf('.'))}.webm`; + const outputFile = path.resolve(`${outputDir}/${outputFileName}`); + + try { + await mkdir(outputDir, { recursive: true }); + + // re-encode video or get info about the existing one + const info = await optimizeVideoOrGetInfo(inputFile, outputFile); + const videoStream = info.streams.find((s) => s.codec_type === 'video'); + + // generate thumbnail + const thumbnailFileName = `thumb-${file.name.substring(0, file.name.lastIndexOf('.'))}.jpg`; + const thumbnailExists = await checkIfFileExists(`${outputDir}/${thumbnailFileName}`); + if (!thumbnailExists) { + await generateVideoThumbnail(inputFile, outputDir, thumbnailFileName); + } + + return { + name: outputFileName, + src: `${serverPath}/${relativePath}/${outputFileName}`, + type: MediaType.Video, + width: videoStream?.width ?? 0, + height: videoStream?.height ?? 0, + thumbnail: `${serverPath}/${relativePath}/${thumbnailFileName}`, + }; + } catch (err) { + console.error(`Could not process the file ${inputFile}.`); + console.error(err); + failedFiles.push({ file: inputFile, error: err }); + throw err; + } +} + +async function getVideos( + inputDir: string, + outputPath: string, + serverPath: string, + relativePath: string, + files: Dirent[], +): Promise { + const videos: MediaItem[] = []; + + for (const file of files) { + const video = await processVideo(inputDir, outputPath, serverPath, relativePath, file); + videos.push(video); + } + + return videos; +} + +async function getMedia( + inputDir: string, + outputPath: string, + serverPath: string, + relativePath: string, + files: GroupedFiles, +): Promise { + const photos = await getPhotos(inputDir, outputPath, serverPath, relativePath, files.photos); + const videos = await getVideos(inputDir, outputPath, serverPath, relativePath, files.videos); + const media = photos.concat(videos); + + return media; +} + +async function getFiles(inputDir: string): Promise { + console.log(`Processing files in ${inputDir} directory...`); + + const dirents = await readdir(inputDir, { withFileTypes: true }); + const files = dirents.reduce( + (prev, dirent) => { + if (!dirent.isFile()) { + return prev; + } + + if (hasImageExtension(dirent.name)) { + prev.photos.push(dirent); + } else if (hasVideoExtension(dirent.name)) { + prev.videos.push(dirent); + } else if (hasExtension(dirent.name, ['gpx'])) { + prev.geoData.push(dirent); + } + + return prev; + }, + { photos: [], videos: [], geoData: [] }, + ); + + return files; +} + +async function processGeoData(inputDir: string, files: Dirent[]): Promise { + const geoData: FeatureCollection[] = []; + + for (const file of files) { + // read file contents + const fileContents = await readFile(`${inputDir}/${file.name}`, 'utf8'); + const contentsWithoutNS = fileContents.replace(/\sxmlns[^"]+"[^"]+"/g, ''); + + // create DOM from string + const doc = new DOMParser().parseFromString(contentsWithoutNS, 'text/xml'); + + // 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)]; + + geoData.push(track); + } + + return geoData; +} + +function createMetadataFromGeoJSON( + geoData: FeatureCollection, +): { [key: string]: string | number } | undefined { + if (geoData === undefined) { + return; + } + + const coordTimes = geoData.features[0].properties?.coordinateProperties?.times; + + if (coordTimes === undefined || coordTimes?.length === 0) { + return; + } + + // 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; + const durationString = secondsToTimeString(duration); + + // distance and speed + let totalDistance = 0; + const speeds = []; + const coords = (geoData.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; + + return { duration: durationString, distance, speed }; +} + +async function getGroups( + inputPath: string, + outputPath: string, + serverPath: string, + relativePath: string, +): Promise { + const dirs = (await readdir(inputPath, { withFileTypes: true })).filter((dirent) => + dirent.isDirectory(), + ); + + const groups: Group[] = []; + + for (const dir of dirs) { + const name = dir.name; + const id = name.replaceAll(' ', '-').toLowerCase(); + + const inputDir = `${inputPath}/${name}`; + const relativeGroupPath = `${relativePath}/${id}`; + + const files = await getFiles(inputDir); + + const media = await getMedia(inputDir, outputPath, serverPath, relativeGroupPath, files); + media.sort((a, b) => (a.name > b.name ? 1 : -1)); + + const geoData = await processGeoData(inputDir, files.geoData); + + const metadata = createMetadataFromGeoJSON(geoData[0]); + + const group: Group = { id, name, media, geoData, metadata }; + + if (metadata !== undefined) { + group.description = + 'Total distance: {distance} km\nDuration: {duration}\nAverage speed: {speed} km/h'; + } + + groups.push(group); + } + + return groups; +} + +async function main(): Promise { + if (process.argv[2] === undefined || process.argv[3] === undefined) { + console.log( + 'Usage: npx ts-node ./src/generate-data.ts [INPUT_PATH] [OUTPUT_PATH] [SERVER_PATH]', + ); + return; + } + + setupFfmpeg(); + + const inputPath = process.argv[2]; + const outputPath = process.argv[3]; + const serverPath = process.argv[4] ?? ''; + + const name = path.basename(inputPath); + const id = name.replaceAll(' ', '-').toLowerCase(); + const groups = await getGroups(inputPath, outputPath, serverPath, id); + + const trip = { id, name, groups }; + const tripJson = JSON.stringify(trip); + + const outputDir = `${outputPath}/${id}`; + const outputFile = `${outputDir}/index.json`; + + console.log(`Writing output to ${outputFile}...`); + + try { + await mkdir(outputDir, { recursive: true }); + await writeFile(outputFile, tripJson); + } catch (err) { + console.error(err); + } + + if (failedFiles.length > 0) { + console.error( + `Failed to process:\n${failedFiles + .map((f) => `- ${f.file}\n ${f.error as string}`) + .join('\n')}`, + ); + } +} + +void main(); diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..1b274d3 --- /dev/null +++ b/src/index.css @@ -0,0 +1,19 @@ +html, +body, +#root { + width: 100%; + height: 100%; + padding: 0; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, + Fira Sans, Droid Sans, Helvetica Neue, sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +* { + box-sizing: border-box; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..48aff58 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; +import reportWebVitals from './reportWebVitals'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); +root.render( + + + +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/src/lib/MarkerClusterGroup.js b/src/lib/MarkerClusterGroup.js new file mode 100644 index 0000000..70e2134 --- /dev/null +++ b/src/lib/MarkerClusterGroup.js @@ -0,0 +1,32 @@ +import L from 'leaflet' +import { createPathComponent } from '@react-leaflet/core' +import 'leaflet.markercluster' + +function createMarkerCluster({ children: _c, ...props }, context) { + const clusterProps = {} + const clusterEvents = {} + // Splitting props and events to different objects + Object.entries(props).forEach(([propName, prop]) => + propName.startsWith('on') + ? (clusterEvents[propName] = prop) + : (clusterProps[propName] = prop) + ) + const instance = new L.MarkerClusterGroup(clusterProps) + + // Initializing event listeners + Object.entries(clusterEvents).forEach(([eventAsProp, callback]) => { + const clusterEvent = `cluster${eventAsProp.substring(2).toLowerCase()}` + instance.on(clusterEvent, callback) + }) + return { + instance, + context: { + ...context, + layerContainer: instance, + }, + } +} + +const MarkerClusterGroup = createPathComponent(createMarkerCluster) + +export default MarkerClusterGroup \ No newline at end of file diff --git a/src/lib/util.ts b/src/lib/util.ts new file mode 100644 index 0000000..1b000c0 --- /dev/null +++ b/src/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/src/models/Group.ts b/src/models/Group.ts new file mode 100644 index 0000000..9b3aa6d --- /dev/null +++ b/src/models/Group.ts @@ -0,0 +1,35 @@ +import { FeatureCollection } from 'geojson'; +import MediaItem from './MediaItem'; + +export default interface Group { + /** + * ID of the group used in URLs. + */ + id: string; + + /** + * Name of the group. + */ + name?: string; + + /** + * Description of the group. + */ + + description?: string; + + /** + * Medias in the group. + */ + media: MediaItem[]; + + /** + * Geo data track info in GeoJSON format. + */ + geoData?: FeatureCollection[]; + + /** + * Metadata that can be displayed in the description. + */ + metadata?: { [key: string]: string | number }; +} diff --git a/src/models/MediaItem.ts b/src/models/MediaItem.ts new file mode 100644 index 0000000..de1410a --- /dev/null +++ b/src/models/MediaItem.ts @@ -0,0 +1,53 @@ +import MediaType from "./MediaType"; + +export default interface MediaItem { + /** + * Name of the media item or the file. + */ + name: string; + + /** + * Path to the media item. + */ + src: string; + + /** + * Type of the media item. Can be a "photo" or a "video". + */ + type: MediaType; + + /** + * Width of the media item in pixels. + */ + width: number; + + /** + * Height of the media item in pixels. + */ + height: number; + + /** + * Latitude where the media item was taken. + */ + latitude?: number; + + /** + * Logitude where the media item was taken. + */ + longitude?: number; + + /** + * Date and time when the media item was taken. + */ + time?: string; + + /** + * Media item thumbnail. + */ + thumbnail?: string; + + /** + * Caption on the media item. + */ + caption?: string; +} diff --git a/src/models/MediaType.ts b/src/models/MediaType.ts new file mode 100644 index 0000000..74bb933 --- /dev/null +++ b/src/models/MediaType.ts @@ -0,0 +1,6 @@ +enum MediaType { + Photo = "photo", + Video = "video", +} + +export default MediaType; diff --git a/src/models/Trip.ts b/src/models/Trip.ts new file mode 100644 index 0000000..7f92669 --- /dev/null +++ b/src/models/Trip.ts @@ -0,0 +1,28 @@ +import Group from './Group'; + +export default interface Trip { + /** + * ID of the trip, used internally within the application. + */ + id: string; + + /** + * Name of the trip, displayed to the user. + */ + name: string; + + /** + * Groups the trip is split into. + */ + groups?: Group[]; + + /** + * URL to a JSON file containing data for the trip. + */ + url: string; + + /** + * Property indicating if the trip data has been already downloaded. + */ + downloaded: boolean; +} diff --git a/src/models/index.guard.ts b/src/models/index.guard.ts new file mode 100644 index 0000000..d564ff9 --- /dev/null +++ b/src/models/index.guard.ts @@ -0,0 +1,58 @@ +import { Trip, Group, MediaItem } from './index'; + +export function isGroup(obj: unknown): obj is Group { + const typedObj = obj as Group; + const isValid = + ((typedObj !== null && typeof typedObj === 'object') || typeof typedObj === 'function') && + typeof typedObj.id === 'string' && + (typeof typedObj.name === 'undefined' || typeof typedObj.name === 'string') && + (typeof typedObj.description === 'undefined' || typeof typedObj.description === 'string') && + Array.isArray(typedObj.media) && + typedObj.media.every((e: any) => isMediaItem(e)); + + if (!isValid) { + throw new Error(`Invalid object: ${JSON.stringify(obj)}`); + } + + return isValid; +} + +export function isMediaItem(obj: unknown): obj is MediaItem { + const typedObj = obj as MediaItem; + const isValid = + ((typedObj !== null && typeof typedObj === 'object') || typeof typedObj === 'function') && + typeof typedObj.name === 'string' && + typeof typedObj.src === 'string' && + typeof typedObj.type === 'string' && + (typedObj.type === 'photo' || typedObj.type === 'video') && + typeof typedObj.width === 'number' && + typeof typedObj.height === 'number' && + (typeof typedObj.latitude === 'number' || typeof typedObj.latitude === 'undefined') && + (typeof typedObj.longitude === 'number' || typeof typedObj.longitude === 'undefined') && + (typeof typedObj.time === 'undefined' || typeof typedObj.time === 'string') && + (typeof typedObj.thumbnail === 'string' || typeof typedObj.thumbnail === 'undefined') && + (typeof typedObj.caption === 'undefined' || typeof typedObj.caption === 'string'); + + if (!isValid) { + throw new Error(`Invalid object: ${JSON.stringify(obj)}`); + } + + return isValid; +} + +export function isTrip(obj: unknown): obj is Trip { + const typedObj = obj as Trip; + const isValid = + ((typedObj !== null && typeof typedObj === 'object') || typeof typedObj === 'function') && + typeof typedObj.id === 'string' && + typeof typedObj.name === 'string' && + (typeof typedObj.groups === 'undefined' || + (Array.isArray(typedObj.groups) && typedObj.groups.every((e: any) => isGroup(e))) + ); + + if (!isValid) { + throw new Error(`Invalid object: ${JSON.stringify(obj)}`); + } + + return isValid; +} diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..506de25 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,7 @@ +import Group from './Group'; +import MediaItem from './MediaItem'; +import MediaType from './MediaType'; +import Trip from './Trip'; + +export type { Group, MediaItem, Trip }; +export { MediaType }; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000..9f9bcb2 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +import 'react-scripts'; diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts new file mode 100644 index 0000000..73d7c3e --- /dev/null +++ b/src/reportWebVitals.ts @@ -0,0 +1,17 @@ +import { ReportHandler } from 'web-vitals'; + +type FuncType = (onPerfEntry?: ReportHandler) => void; + +const reportWebVitals: FuncType = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry !== null && onPerfEntry instanceof Function) { + void import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..141e479 --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/src/types/geojson-bounds.d.ts b/src/types/geojson-bounds.d.ts new file mode 100644 index 0000000..99eb4a0 --- /dev/null +++ b/src/types/geojson-bounds.d.ts @@ -0,0 +1,33 @@ +declare module 'geojson-bounds' { + import geojson from 'geojson'; + + /** + * Returns an array of coordinates in the order [West, South, East, North] that represents the extent of the provided feature or geometry + */ + export function extent(geojson: geojson.GeoJSON): [number, number, number, number]; + + /** + * Returns a GeoJSON Feature polygon that repesents the bounding box of the provided feature or geometry + */ + export function envelope(geojson: geojson.GeoJSON): geojson.Feature; + + /** + * Returns the western-most longitude of the provided feature or geometry + */ + export function xMin(geojson: geojson.GeoJSON): number; + + /** + * Returns the eastern-most longitude of the provided feature or geometry + */ + export function xMax(geojson: geojson.GeoJSON): number; + + /** + * Returns the southern-most latitude of the provided feature or geometry + */ + export function yMin(geojson: geojson.GeoJSON): number; + + /** + * Returns the northern-most latitude of the provided feature or geometry + */ + export function yMax(geojson: geojson.GeoJSON): number; +} diff --git a/src/types/photoswipe-lightbox.d.ts b/src/types/photoswipe-lightbox.d.ts new file mode 100644 index 0000000..d8847cf --- /dev/null +++ b/src/types/photoswipe-lightbox.d.ts @@ -0,0 +1,104 @@ +declare module 'photoswipe/lightbox' { + import PhotoSwipeBase from 'photoswipe/dist/types/core/base'; + + export default PhotoSwipeLightbox; + /** + * + */ + export type Type = import('photoswipe/dist/types/types').Type; + export type PhotoSwipe = import('photoswipe/dist/types/photoswipe').default; + export type PhotoSwipeOptions = import('photoswipe/dist/types/photoswipe').PhotoSwipeOptions; + export type DataSource = import('photoswipe/dist/types/photoswipe').DataSource; + export type Content = import('photoswipe/dist/types/slide/content').default; + export type PhotoSwipeEventsMap = import('photoswipe/dist/types/core/eventable').PhotoSwipeEventsMap; + export type PhotoSwipeFiltersMap = import('photoswipe/dist/types/core/eventable').PhotoSwipeFiltersMap; + /** + * + */ + export type EventCallback = import('photoswipe/dist/types/core/eventable').EventCallback; + /** + * @template T + * @typedef {import('../types.js').Type} Type + */ + /** @typedef {import('photoswipe/dist/types/photoswipe').default} PhotoSwipe */ + /** @typedef {import('photoswipe/dist/types/photoswipe').PhotoSwipeOptions} PhotoSwipeOptions */ + /** @typedef {import('photoswipe/dist/types/photoswipe').DataSource} DataSource */ + /** @typedef {import('photoswipe/dist/types/slide/content').default} Content */ + /** @typedef {import('photoswipe/dist/types/core/eventable').PhotoSwipeEventsMap} PhotoSwipeEventsMap */ + /** @typedef {import('photoswipe/dist/types/core/eventable').PhotoSwipeFiltersMap} PhotoSwipeFiltersMap */ + /** + * @template T + * @typedef {import('photoswipe/dist/types/core/eventable').EventCallback} EventCallback + */ + /** + * PhotoSwipe Lightbox + * + * - If user has unsupported browser it falls back to default browser action (just opens URL) + * - Binds click event to links that should open PhotoSwipe + * - parses DOM strcture for PhotoSwipe (retrieves large image URLs and sizes) + * - Initializes PhotoSwipe + * + * + * Loader options use the same object as PhotoSwipe, and supports such options: + * + * gallery - Element | Element[] | NodeList | string selector for the gallery element + * children - Element | Element[] | NodeList | string selector for the gallery children + * + */ + declare class PhotoSwipeLightbox extends PhotoSwipeBase { + /** + * @param {PhotoSwipeOptions} options + */ + constructor(options: PhotoSwipeOptions); + _uid: number; + /** + * Initialize lightbox, should be called only once. + * It's not included in the main constructor, so you may bind events before it. + */ + init(): void; + /** + * @param {MouseEvent} e + */ + onThumbnailsClick(e: MouseEvent): void; + /** + * Get index of gallery item that was clicked. + * + * @param {MouseEvent} e click event + */ + getClickedIndex(e: MouseEvent): number; + /** + * Load and open PhotoSwipe + * + * @param {number} index + * @param {DataSource=} dataSource + * @param {{ x?: number; y?: number }} [initialPoint] + */ + loadAndOpen( + index: number, + dataSource?: DataSource | undefined, + initialPoint?: { + x?: number; + y?: number; + }, + ): boolean; + shouldOpen: boolean; + /** + * Load the main module and the slide content by index + * + * @param {number} index + * @param {DataSource=} dataSource + */ + preload(index: number, dataSource?: DataSource | undefined): void; + _preloadedContent: import('photoswipe/dist/types/slide/content').default; + /** + * @private + * @param {Type | { default: Type }} module + * @param {number} uid + */ + private readonly _openPhotoswipe; + /** + * Unbinds all events, closes PhotoSwipe if it's open. + */ + destroy(): void; + } +} diff --git a/src/types/types.d.ts b/src/types/types.d.ts new file mode 100644 index 0000000..ebf6fd5 --- /dev/null +++ b/src/types/types.d.ts @@ -0,0 +1,14 @@ +declare global { + interface Document { + webkitExitFullscreen?: () => Promise; + webkitFullscreenElement?: Element; + } + + interface HTMLElement { + webkitRequestFullscreen?: () => Promise; + } +} + +declare module 'exif-reader'; +declare module 'photoswipe-dynamic-caption-plugin'; +declare module 'photoswipe-video-plugin'; -- cgit v1.2.3