diff --git a/src/components/FeaturePanel/Climbing/CameraMarker.tsx b/src/components/FeaturePanel/Climbing/CameraMarker.tsx new file mode 100644 index 00000000..0bee5f5c --- /dev/null +++ b/src/components/FeaturePanel/Climbing/CameraMarker.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +type CameraTopDownMarkerProps = { + width?: number; + height?: number; + index?: number; + azimuth?: number; + onClick?: () => void; +}; + +export const CameraMarker = ({ + width, + height, + azimuth = 0, + index, + onClick, +}: CameraTopDownMarkerProps) => ( + + + + + + + + + + {index} + + + +); diff --git a/src/components/FeaturePanel/Climbing/ClimbingViewContent.tsx b/src/components/FeaturePanel/Climbing/ClimbingViewContent.tsx index 49731475..f3dc3002 100644 --- a/src/components/FeaturePanel/Climbing/ClimbingViewContent.tsx +++ b/src/components/FeaturePanel/Climbing/ClimbingViewContent.tsx @@ -8,7 +8,7 @@ import { useClimbingContext } from './contexts/ClimbingContext'; import { invertedBoltCodeMap } from './utils/boltCodes'; import { RouteList } from './RouteList/RouteList'; import { ContentContainer } from './ContentContainer'; -import { Button, ButtonGroup } from '@mui/material'; +import { Button, ButtonGroup, Typography } from '@mui/material'; import { RouteDistribution } from './RouteDistribution'; import React from 'react'; @@ -17,6 +17,9 @@ import { EditButton } from '../EditButton'; import { EditDialog } from '../EditDialog/EditDialog'; import { useGetCragViewLayout } from './utils/useCragViewLayout'; import { useUserSettingsContext } from '../../utils/UserSettingsContext'; +import { useFeatureContext } from '../../utils/FeatureContext'; +import { PanelLabel } from './PanelLabel'; +import { t } from '../../../services/intl'; const CragMapDynamic = dynamic(() => import('./CragMap'), { ssr: false, @@ -40,6 +43,7 @@ const ContentBelowRouteList = styled.div<{ export const ClimbingViewContent = ({ isMapVisible }) => { const { showDebugMenu, routes } = useClimbingContext(); + const { feature } = useFeatureContext(); const cragViewLayout = useGetCragViewLayout(); const { userSettings } = useUserSettingsContext(); const splitPaneSize = userSettings['climbing.splitPaneSize']; @@ -90,6 +94,17 @@ export const ClimbingViewContent = ({ isMapVisible }) => { )} + + {feature.tags.description ? ( + <> + {t('climbingview.description')} + + + {feature.tags.description} + + + ) : null} + diff --git a/src/components/FeaturePanel/Climbing/CragMap.tsx b/src/components/FeaturePanel/Climbing/CragMap.tsx index 82f456e0..057d54e2 100644 --- a/src/components/FeaturePanel/Climbing/CragMap.tsx +++ b/src/components/FeaturePanel/Climbing/CragMap.tsx @@ -1,11 +1,15 @@ -import React, { useCallback, useState } from 'react'; -import maplibregl, { GeoJSONSource } from 'maplibre-gl'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import maplibregl, { GeoJSONSource, LngLatLike, PointLike } from 'maplibre-gl'; import { outdoorStyle } from '../../Map/styles/outdoorStyle'; import { COMPASS_TOOLTIP } from '../../Map/useAddTopRightControls'; import styled from '@emotion/styled'; import { useFeatureContext } from '../../utils/FeatureContext'; import type { LayerSpecification } from '@maplibre/maplibre-gl-style-spec'; import { CircularProgress } from '@mui/material'; +import { useClimbingContext } from './contexts/ClimbingContext'; +import { addFilePrefix } from './utils/photo'; +import ReactDOMServer from 'react-dom/server'; +import { CameraMarker } from './CameraMarker'; const Map = styled.div<{ $isVisible: boolean }>` visibility: ${({ $isVisible }) => ($isVisible ? 'visible' : 'hidden')}; @@ -75,11 +79,118 @@ export const routes: LayerSpecification[] = [ }, ]; +const useGetPhotoExifs = (photoPaths) => { + const [photoExifs, setPhotoExifs] = useState< + Record> + >({}); + useEffect(() => { + async function fetchExifData(photos) { + const encodedTitles = photos.map((t) => addFilePrefix(t)).join('|'); + const url = `https://commons.wikimedia.org/w/api.php?action=query&prop=imageinfo&iiprop=metadata&titles=${encodedTitles}&format=json&origin=*`; + const response = await fetch(url); + const data = await response.json(); + return data.query.pages; + } + + fetchExifData(photoPaths).then((pages) => { + const data = Object.values(pages).reduce>( + (acc, item: any) => { + const metadata = item?.imageinfo?.[0]?.metadata.reduce( + (acc2, { name, value }) => ({ ...acc2, [name]: value }), + {}, + ); + + return { + ...acc, + [item.title]: metadata, + }; + }, + {}, + ); + setPhotoExifs(data); + }); + }, [photoPaths]); + return photoExifs; +}; + +function parseFractionOrNumber(input) { + if (input.includes('/')) { + const [numerator, denominator] = input.split('/'); + return parseFloat(numerator) / parseFloat(denominator); + } else { + return parseFloat(input); + } +} + +const usePhotoMarkers = (photoExifs, mapRef) => { + const { feature } = useFeatureContext(); + + const getMarker = useCallback((index: number, azimuth: number | null) => { + let svgElement; + // const photoPath = Object.keys(photoExifs)[index]; + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + svgElement = document.createElement('div'); + svgElement.innerHTML = ReactDOMServer.renderToStaticMarkup( + { + // @TODO onclick is not working + // Router.push( + // `${getOsmappLink(feature)}/climbing/photo/${photoPath}${window.location.hash}`, + // ); + }} + />, + ); + } else svgElement = undefined; + + return { + color: 'salmon', + element: svgElement, + offset: [0, -10] as PointLike, + }; + }, []); + + const markerRef = useRef(); + + useEffect(() => { + Object.keys(photoExifs).map((key, index) => { + const exifItems = photoExifs[key]; + + if (exifItems && exifItems.GPSLongitude && exifItems.GPSLatitude) { + const marker = getMarker( + index, + exifItems.GPSImgDirection + ? parseFractionOrNumber(exifItems.GPSImgDirection) + : null, + ); + markerRef.current = new maplibregl.Marker(marker) + .setLngLat([ + exifItems.GPSLongitude, + exifItems.GPSLatitude, + ] as LngLatLike) + .addTo(mapRef.current); + } + }); + + return () => { + Object.keys(photoExifs).map((key) => { + markerRef.current?.remove(); + }); + }; + }, [getMarker, mapRef, markerRef, photoExifs]); +}; + const useInitMap = () => { const containerRef = React.useRef(null); const mapRef = React.useRef(null); const [isMapLoaded, setIsMapLoaded] = useState(false); const { feature } = useFeatureContext(); + const { photoPaths } = useClimbingContext(); + + const photoExifs = useGetPhotoExifs(photoPaths); + usePhotoMarkers(photoExifs, mapRef); const getClimbingSource = useCallback( () => mapRef.current.getSource('climbing') as GeoJSONSource | undefined, diff --git a/src/components/FeaturePanel/Climbing/RouteDistribution.tsx b/src/components/FeaturePanel/Climbing/RouteDistribution.tsx index 4f4c816f..aeffcaa6 100644 --- a/src/components/FeaturePanel/Climbing/RouteDistribution.tsx +++ b/src/components/FeaturePanel/Climbing/RouteDistribution.tsx @@ -52,7 +52,7 @@ const Chart = styled.div<{ $ratio: number; $color: string }>` const getGroupingLabel = (label: string) => String(parseFloat(label)); export const RouteDistribution = () => { - const { userSettings, setUserSetting } = useUserSettingsContext(); + const { userSettings } = useUserSettingsContext(); const gradeSystem = userSettings['climbing.gradeSystem'] || 'uiaa'; const theme = useTheme(); diff --git a/src/components/FeaturePanel/Climbing/utils/photo.ts b/src/components/FeaturePanel/Climbing/utils/photo.ts index 85f392cd..470d6aef 100644 --- a/src/components/FeaturePanel/Climbing/utils/photo.ts +++ b/src/components/FeaturePanel/Climbing/utils/photo.ts @@ -7,6 +7,8 @@ import { naturalSort } from './array'; export const getWikimediaCommonsKey = (index: number) => `wikimedia_commons${index === 0 ? '' : `:${index + 1}`}`; +export const addFilePrefix = (name: string) => `File:${name}`; + export const removeFilePrefix = (name: string) => name?.replace(/^File:/, ''); export const isWikimediaCommons = (tag: string) => diff --git a/src/locales/vocabulary.js b/src/locales/vocabulary.js index 62036fbc..52457e82 100644 --- a/src/locales/vocabulary.js +++ b/src/locales/vocabulary.js @@ -281,6 +281,7 @@ export default { 'climbingpanel.delete_climbing_route': 'Delete route __route__ in schema', 'climbingpanel.create_first_node': 'Click on the beginning of the route and continue in the direction of the route', 'climbingpanel.create_next_node': 'Follow direction of the route and click "Done" when finished', + 'climbingview.description': 'Description', 'publictransport.tourism': 'Touristic trains', 'publictransport.night': 'Night trains',