aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMarcin Zelent <marcin@zelent.net>2022-11-16 15:16:38 +0100
committerMarcin Zelent <marcin@zelent.net>2022-11-16 15:16:38 +0100
commitf2ecc1803f3ea294a0c6b7915b61348ed0395b26 (patch)
treee8c6fb1350ae4f659b3f9ef8d17157158b974b16 /src
parentefb64f24d6200a39870c0e8966ab4f87e07c93a9 (diff)
Remade and extended the app using React
Diffstat (limited to 'src')
-rw-r--r--src/App.module.css43
-rw-r--r--src/App.test.tsx9
-rw-r--r--src/App.tsx303
-rw-r--r--src/components/Lightbox/Lightbox.css7
-rw-r--r--src/components/Lightbox/Lightbox.ts152
-rw-r--r--src/components/LoadingScreen/LoadingScreen.module.css27
-rw-r--r--src/components/LoadingScreen/LoadingScreen.tsx10
-rw-r--r--src/components/Map/Map.module.css28
-rw-r--r--src/components/Map/Map.tsx190
-rw-r--r--src/components/ReactLeafletControl/ReactLeafletControl.tsx104
-rw-r--r--src/components/Sidebar/Sidebar.module.css158
-rw-r--r--src/components/Sidebar/Sidebar.tsx152
-rw-r--r--src/components/TileLayerControl/TileLayerControl.module.css20
-rw-r--r--src/components/TileLayerControl/TileLayerControl.tsx21
-rw-r--r--src/generate-data.ts563
-rw-r--r--src/index.css19
-rw-r--r--src/index.tsx19
-rw-r--r--src/lib/MarkerClusterGroup.js32
-rw-r--r--src/lib/util.ts30
-rw-r--r--src/models/Group.ts35
-rw-r--r--src/models/MediaItem.ts53
-rw-r--r--src/models/MediaType.ts6
-rw-r--r--src/models/Trip.ts28
-rw-r--r--src/models/index.guard.ts58
-rw-r--r--src/models/index.ts7
-rw-r--r--src/react-app-env.d.ts1
-rw-r--r--src/reportWebVitals.ts17
-rw-r--r--src/setupTests.ts5
-rw-r--r--src/types/geojson-bounds.d.ts33
-rw-r--r--src/types/photoswipe-lightbox.d.ts104
-rw-r--r--src/types/types.d.ts14
31 files changed, 2248 insertions, 0 deletions
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(<App />);
+ 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<Trip[]>([]);
+ const [currentTripIndex, setCurrentTripIndex] = useState<number>(0);
+ const [currentGroupIndex, setCurrentGroupIndex] = useState<number>(1);
+ const [asideOpen, setAsideOpen] = useState<boolean>(false);
+ const [lightbox, setLightbox] = useState<PhotoSwipeLightbox>();
+ const [openLightbox, setOpenLightbox] = useState<boolean>(false);
+ const [isLoading, setIsLoading] = useState<boolean>(true);
+ const [error, setError] = useState<string>();
+
+ const currentTrip = allTrips[currentTripIndex];
+ const currentGroup = currentTrip?.groups?.[currentGroupIndex];
+
+ async function getAllTrips(): Promise<Trip[]> {
+ 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<Trip | undefined> {
+ 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<void> {
+ const trip = await getTrip(trips[0].url);
+
+ if (trip !== undefined) {
+ updateAllTrips(trips, trip);
+ updateUrlHash(trip);
+ }
+ }
+
+ async function getTripFromUrlHash(trips: Trip[]): Promise<void> {
+ 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<SlideData>((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 <LoadingScreen />;
+ }
+
+ return (
+ <div className={styles.container}>
+ <Sidebar
+ trips={allTrips}
+ currentTrip={currentTrip}
+ groups={currentTrip.groups}
+ currentGroup={currentGroup}
+ asideOpen={asideOpen}
+ handleClose={() => setAsideOpen(false)}
+ handleGroupChange={handleGroupChange}
+ handleTripChange={handleTripChange}
+ />
+
+ <button
+ type="button"
+ title="Toggle sidebar menu"
+ className={styles.asideToggle}
+ onClick={() => setAsideOpen(true)}
+ >
+ <svg
+ width="32"
+ height="32"
+ version="1.1"
+ viewBox="0 0 64 64"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <g strokeMiterlimit="10" strokeWidth=".4165" fill="#666">
+ <rect x="8" y="16" width="48" height="6" />
+ <rect x="8" y="30" width="48" height="6" />
+ <rect x="8" y="44" width="48" height="6" />
+ </g>
+ </svg>
+ </button>
+
+ <main className={styles.main}>
+ <Map group={currentGroup} handleMarkerClick={handleMarkerClick} />
+ </main>
+ {isLoading && <LoadingScreen />}
+ </div>
+ );
+}
+
+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 =
+ '<svg class="pswp__icn" width="26" height="22" aria-hidden="true" version="1.1" viewBox="-16 -8 26 22" xmlns="http://www.w3.org/2000/svg"><use class="pswp__icn-shadow" xlink:href="#pswp__icn-fullscreen-request"></use><use class="pswp__icn-shadow" xlink:href="#pswp__icn-fullscreen-exit"></use><path id="pswp__icn-fullscreen-request" d="m-12 10v-5h2v3h3v2zm0-14h5v2h-3v3h-2zm18 0v5h-2v-3h-3v-2zm0 14h-5v-2h3v-3h2z" style="stroke-width:1.0001"/> <path id="pswp__icn-fullscreen-exit" d="m-7 5v5h-2v-3h-3v-2zm0-4h-5v-2h3v-3h2zm8 0v-5h2v3h3v2zm0 4h5v2h-3v3h-2z" style="display:none"/></svg>';
+
+ 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<void>;
+ webkitFullscreenElement?: Element;
+ [key: string]: any;
+}
+
+interface WebkitHTMLElement extends HTMLElement {
+ webkitRequestFullscreen: () => Promise<void>;
+ [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 (
+ <div className={styles.container}>
+ <div className={styles.spinner}></div>
+ </div>
+ );
+}
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>(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(
+ <>
+ <img style={{ width: 36, height: 36 }} src={iconUrl} alt="" />
+ <span className={styles.markerItemCount}>{markerCluster.getChildCount()}</span>
+ </>,
+ ),
+ iconSize: [36, 36],
+ className: styles.markerIcon,
+ });
+ }
+
+ function createMarkers(media: MediaItem[]): JSX.Element {
+ if (media.length === 0) {
+ return <></>;
+ }
+
+ return (
+ <MarkerClusterGroup iconCreateFunction={getClusterGroupIcon}>
+ {media.map((mediaItem, index) => (
+ <Marker
+ key={index}
+ position={[mediaItem.latitude as number, mediaItem.longitude as number]}
+ icon={
+ new Icon({
+ iconUrl: mediaItem.thumbnail ?? '/icons/thumb-placeholder.png',
+ iconSize: [36, 36],
+ className: styles.markerIcon,
+ })
+ }
+ eventHandlers={{ click: () => handleMarkerClick(mediaItem.name) }}
+ />
+ ))}
+ </MarkerClusterGroup>
+ );
+ }
+
+ 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 <MapBounds bounds={bounds} />;
+ }, [group]);
+
+ return (
+ <MapContainer
+ bounds={bounds}
+ scrollWheelZoom
+ style={{ height: '100%', width: '100%' }}
+ keyboard
+ zoomControl={false}
+ >
+ <>
+ <ReactLeafletControl position="bottomleft">
+ <TileLayerControl
+ key="tile-layer-control"
+ tileType={tileLayer}
+ onClick={() =>
+ setTileLayer(
+ tileLayer === TileLayerType.map ? TileLayerType.satellite : TileLayerType.map,
+ )
+ }
+ />
+ </ReactLeafletControl>
+ {tileLayer === TileLayerType.map ? (
+ <TileLayer
+ key="tile-layer"
+ attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
+ url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+ maxNativeZoom={19}
+ maxZoom={19}
+ />
+ ) : (
+ <TileLayer
+ key="tile-layer-satellite"
+ attribution='&copy; <a href="https://google.com/">Google</a>'
+ url="https://{s}.google.com/kh/v=930?x={x}&amp;y={y}&amp;z={z}"
+ subdomains={['khms0', 'khms1', 'khms2', 'khms3']}
+ maxNativeZoom={21}
+ maxZoom={21}
+ />
+ )}
+ <ZoomControl key="zoom-control" position="bottomright" />
+ {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 (
+ <React.Fragment key={i}>
+ <GeoJSON key={`geodata-${group.id}-${i}`} data={data} />
+ <Marker
+ key={`start-marker-${group.id}-${i}`}
+ position={startCoords as [number, number]}
+ icon={new Icon({ iconUrl: '/icons/start.png', className: styles.iconShadow })}
+ />
+ <Marker
+ key={`finish-marker-${group.id}-${i}`}
+ position={finishCoords as [number, number]}
+ icon={new Icon({ iconUrl: '/icons/finish.png', className: styles.iconShadow })}
+ />
+ </React.Fragment>
+ );
+ })}
+ </>
+ </MapContainer>
+ );
+}
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;
+}