mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: add minimap support (#61)
This commit is contained in:
@@ -7,6 +7,8 @@ import type { BaseMapProps, PhotoMarker } from '~/types/map'
|
||||
interface GenericMapProps extends Omit<BaseMapProps, 'handlers'> {
|
||||
/** Photo markers to display */
|
||||
markers?: PhotoMarker[]
|
||||
/** ID of the marker to select */
|
||||
selectedMarkerId?: string | null
|
||||
/** Callback when marker is clicked */
|
||||
onMarkerClick?: (marker: PhotoMarker) => void
|
||||
/** Callback when GeoJSON feature is clicked */
|
||||
@@ -24,6 +26,7 @@ const DEFAULT_MARKERS: PhotoMarker[] = []
|
||||
*/
|
||||
export const GenericMap: React.FC<GenericMapProps> = ({
|
||||
markers = DEFAULT_MARKERS,
|
||||
selectedMarkerId,
|
||||
onMarkerClick,
|
||||
onGeoJsonClick,
|
||||
onGeolocate,
|
||||
@@ -61,6 +64,7 @@ export const GenericMap: React.FC<GenericMapProps> = ({
|
||||
<MapComponent
|
||||
{...props}
|
||||
markers={markers}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
initialViewState={calculatedInitialViewState}
|
||||
autoFitBounds={autoFitBounds}
|
||||
handlers={handlers}
|
||||
|
||||
@@ -4,10 +4,10 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Map from 'react-map-gl/maplibre'
|
||||
|
||||
import { getMapStyle } from '~/lib/map/style'
|
||||
import { calculateMapBounds } from '~/lib/map-utils'
|
||||
import type { PhotoMarker } from '~/types/map'
|
||||
|
||||
import MAP_STYLE from './MapLibreStyle.json'
|
||||
import {
|
||||
ClusterMarker,
|
||||
clusterMarkers,
|
||||
@@ -27,6 +27,7 @@ export interface PureMaplibreProps {
|
||||
zoom: number
|
||||
}
|
||||
markers?: PhotoMarker[]
|
||||
selectedMarkerId?: string | null
|
||||
geoJsonData?: GeoJSON.FeatureCollection
|
||||
onMarkerClick?: (marker: PhotoMarker) => void
|
||||
onGeoJsonClick?: (event: any) => void
|
||||
@@ -42,6 +43,7 @@ export const Maplibre = ({
|
||||
id,
|
||||
initialViewState = DEFAULT_VIEW_STATE,
|
||||
markers = DEFAULT_MARKERS,
|
||||
selectedMarkerId: externalSelectedMarkerId,
|
||||
geoJsonData,
|
||||
onMarkerClick,
|
||||
onGeoJsonClick,
|
||||
@@ -52,11 +54,20 @@ export const Maplibre = ({
|
||||
mapRef,
|
||||
autoFitBounds = true,
|
||||
}: PureMaplibreProps) => {
|
||||
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null)
|
||||
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(
|
||||
externalSelectedMarkerId || null,
|
||||
)
|
||||
const [currentZoom, setCurrentZoom] = useState(initialViewState.zoom)
|
||||
const [viewState, setViewState] = useState(initialViewState)
|
||||
const [isMapLoaded, setIsMapLoaded] = useState(false)
|
||||
|
||||
// Sync external selectedMarkerId with internal state
|
||||
useEffect(() => {
|
||||
if (externalSelectedMarkerId !== undefined) {
|
||||
setSelectedMarkerId(externalSelectedMarkerId)
|
||||
}
|
||||
}, [externalSelectedMarkerId])
|
||||
|
||||
// Handle marker click
|
||||
const handleMarkerClick = useCallback(
|
||||
(marker: PhotoMarker) => {
|
||||
@@ -194,8 +205,7 @@ export const Maplibre = ({
|
||||
ref={mapRef}
|
||||
{...viewState}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
// @ts-expect-error
|
||||
mapStyle={MAP_STYLE}
|
||||
mapStyle={getMapStyle()}
|
||||
attributionControl={false}
|
||||
interactiveLayerIds={geoJsonData ? ['data'] : undefined}
|
||||
onClick={onGeoJsonClick}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isNil } from 'es-toolkit/compat'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { m } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { Fragment } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { isExiftoolLoadedAtom } from '~/atoms/app'
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
TablerAperture,
|
||||
} from '~/icons'
|
||||
import { getImageFormat } from '~/lib/image-utils'
|
||||
import { convertExifGPSToDecimal } from '~/lib/map-utils'
|
||||
import { Spring } from '~/lib/spring'
|
||||
|
||||
import { MotionButtonBase } from '../button'
|
||||
import { formatExifData, Row } from './formatExifData'
|
||||
import { HistogramChart } from './HistogramChart'
|
||||
import { MiniMap } from './MiniMap'
|
||||
import { RawExifViewer } from './RawExifViewer'
|
||||
|
||||
export const ExifPanel: FC<{
|
||||
@@ -36,6 +38,16 @@ export const ExifPanel: FC<{
|
||||
const isMobile = useMobile()
|
||||
const formattedExifData = formatExifData(exifData)
|
||||
const isExiftoolLoaded = useAtomValue(isExiftoolLoadedAtom)
|
||||
|
||||
// Compute decimal GPS coordinates from raw EXIF data
|
||||
const gpsData = useMemo(
|
||||
() => convertExifGPSToDecimal(exifData),
|
||||
[exifData]
|
||||
)
|
||||
|
||||
const decimalLatitude = gpsData?.latitude || null
|
||||
const decimalLongitude = gpsData?.longitude || null
|
||||
|
||||
// 使用通用的图片格式提取函数
|
||||
const imageFormat = getImageFormat(
|
||||
currentPhoto.originalUrl || currentPhoto.s3Key || '',
|
||||
@@ -563,17 +575,17 @@ export const ExifPanel: FC<{
|
||||
value={`${formattedExifData.gps.altitude}m`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 text-right">
|
||||
<a
|
||||
href={`https://uri.amap.com/marker?position=${formattedExifData.gps.longitude},${formattedExifData.gps.latitude}&name=${encodeURIComponent(t('exif.gps.location.name'))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue inline-flex items-center gap-1 text-xs underline transition-colors hover:text-blue-300"
|
||||
>
|
||||
{t('exif.gps.view.map')}
|
||||
<i className="i-mingcute-external-link-line" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Maplibre MiniMap */}
|
||||
{decimalLatitude !== null && decimalLongitude !== null && (
|
||||
<div className="mt-3">
|
||||
<MiniMap
|
||||
latitude={decimalLatitude}
|
||||
longitude={decimalLongitude}
|
||||
photoId={currentPhoto.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
69
apps/web/src/components/ui/photo-viewer/MiniMap.tsx
Normal file
69
apps/web/src/components/ui/photo-viewer/MiniMap.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import Map from 'react-map-gl/maplibre'
|
||||
|
||||
import { getMapStyle } from '~/lib/map/style'
|
||||
|
||||
|
||||
interface MiniMapProps {
|
||||
latitude: number
|
||||
longitude: number
|
||||
photoId: string
|
||||
}
|
||||
|
||||
export const MiniMap = ({ latitude, longitude, photoId }: MiniMapProps) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
const handleMapLoad = useCallback(() => {
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
// 检查是否有有效的GPS坐标
|
||||
const hasValidCoordinates = latitude !== 0 && longitude !== 0
|
||||
|
||||
if (!hasValidCoordinates) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-40 w-full overflow-hidden rounded-lg border border-white/10">
|
||||
<Map
|
||||
mapLib={import('maplibre-gl')}
|
||||
key={`${latitude}-${longitude}`}
|
||||
longitude={longitude}
|
||||
latitude={latitude}
|
||||
zoom={15}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={getMapStyle()}
|
||||
attributionControl={false}
|
||||
onLoad={handleMapLoad}
|
||||
interactive={false}
|
||||
/>
|
||||
|
||||
{/* 中心标记 */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="relative">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-3 w-3 animate-ping rounded-full bg-blue-400 opacity-75" />
|
||||
<div className="relative h-2 w-2 rounded-full bg-blue-500 ring-2 ring-white/80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{!isLoaded && (
|
||||
<div className="bg-material-ultra-thin absolute inset-0 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="text-xs text-white/60">加载地图中...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 点击跳转到explore页面的遮罩 */}
|
||||
<Link
|
||||
to={`/explory?photoId=${photoId}`}
|
||||
target='_blank'
|
||||
className="absolute inset-0 cursor-pointer transition-opacity duration-200 hover:bg-black/10"
|
||||
aria-label="在地图中查看位置"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
import type { PhotoManifestItem, PickedExif } from '@afilmory/builder'
|
||||
|
||||
import type {
|
||||
GPSCoordinates,
|
||||
@@ -9,35 +9,16 @@ import type {
|
||||
import { GPSDirection } from '~/types/map'
|
||||
|
||||
/**
|
||||
* GPS coordinate validation function
|
||||
* Convert EXIF GPS data to decimal coordinates with proper directional handling
|
||||
*/
|
||||
export function isValidGPSCoordinates(
|
||||
coords: GPSCoordinates | null,
|
||||
): coords is GPSCoordinates {
|
||||
if (!coords) return false
|
||||
|
||||
const { latitude, longitude } = coords
|
||||
|
||||
return (
|
||||
typeof latitude === 'number' &&
|
||||
typeof longitude === 'number' &&
|
||||
!Number.isNaN(latitude) &&
|
||||
!Number.isNaN(longitude) &&
|
||||
latitude >= -90 &&
|
||||
latitude <= 90 &&
|
||||
longitude >= -180 &&
|
||||
longitude <= 180
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PhotoManifestItem to PhotoMarker if it has GPS coordinates in EXIF
|
||||
*/
|
||||
export function convertPhotoToMarkerFromEXIF(
|
||||
photo: PhotoManifestItem,
|
||||
): PhotoMarker | null {
|
||||
const { exif } = photo
|
||||
|
||||
export function convertExifGPSToDecimal(exif: PickedExif | null): {
|
||||
latitude: number
|
||||
longitude: number
|
||||
latitudeRef: GPSDirection.North | GPSDirection.South
|
||||
longitudeRef: GPSDirection.East | GPSDirection.West
|
||||
altitude?: number
|
||||
altitudeRef?: 'Above Sea Level' | 'Below Sea Level'
|
||||
} | null {
|
||||
if (!exif?.GPSLatitude || !exif?.GPSLongitude) {
|
||||
return null
|
||||
}
|
||||
@@ -97,37 +78,73 @@ export function convertPhotoToMarkerFromEXIF(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate coordinates
|
||||
if (
|
||||
Number.isNaN(latitude) ||
|
||||
Number.isNaN(longitude) ||
|
||||
latitude < -90 ||
|
||||
latitude > 90 ||
|
||||
longitude < -180 ||
|
||||
longitude > 180
|
||||
) {
|
||||
// Validate coordinates using the validation function
|
||||
const coordinatesToValidate = { latitude, longitude }
|
||||
if (!isValidGPSCoordinates(coordinatesToValidate)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
longitude,
|
||||
latitude,
|
||||
altitude,
|
||||
latitudeRef,
|
||||
longitudeRef,
|
||||
altitudeRef,
|
||||
photo,
|
||||
}
|
||||
return { latitude, longitude, latitudeRef, longitudeRef, altitude, altitudeRef }
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to parse GPS coordinates for photo ${photo.id}:`,
|
||||
error,
|
||||
)
|
||||
console.warn('Failed to parse GPS coordinates from EXIF:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GPS coordinate validation function
|
||||
*/
|
||||
export function isValidGPSCoordinates(
|
||||
coords: GPSCoordinates | null,
|
||||
): coords is GPSCoordinates {
|
||||
if (!coords) return false
|
||||
|
||||
const { latitude, longitude } = coords
|
||||
|
||||
return (
|
||||
typeof latitude === 'number' &&
|
||||
typeof longitude === 'number' &&
|
||||
!Number.isNaN(latitude) &&
|
||||
!Number.isNaN(longitude) &&
|
||||
latitude >= -90 &&
|
||||
latitude <= 90 &&
|
||||
longitude >= -180 &&
|
||||
longitude <= 180
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PhotoManifestItem to PhotoMarker if it has GPS coordinates in EXIF
|
||||
*/
|
||||
export function convertPhotoToMarkerFromEXIF(
|
||||
photo: PhotoManifestItem,
|
||||
): PhotoMarker | null {
|
||||
const { exif } = photo
|
||||
|
||||
if (!exif) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the common GPS conversion function
|
||||
const gpsData = convertExifGPSToDecimal(exif)
|
||||
if (!gpsData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { latitude, longitude, latitudeRef, longitudeRef, altitude, altitudeRef } = gpsData
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
longitude,
|
||||
latitude,
|
||||
altitude,
|
||||
latitudeRef,
|
||||
longitudeRef,
|
||||
altitudeRef,
|
||||
photo,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of PhotoManifestItem to PhotoMarker array using EXIF data
|
||||
*/
|
||||
|
||||
12
apps/web/src/lib/map/style.ts
Normal file
12
apps/web/src/lib/map/style.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { siteConfig } from '@config'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
|
||||
import BUILTIN_MAP_STYLE from '../../components/ui/map/MapLibreStyle.json'
|
||||
|
||||
export const getMapStyle = (): string | StyleSpecification => {
|
||||
const builtinStyle = BUILTIN_MAP_STYLE as StyleSpecification
|
||||
if (!siteConfig.mapStyle) {
|
||||
return builtinStyle
|
||||
}
|
||||
return siteConfig.mapStyle === 'builtin' ? builtinStyle : siteConfig.mapStyle
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export const MapLibreMapComponent: React.FC<BaseMapProps> = ({
|
||||
id,
|
||||
initialViewState,
|
||||
markers,
|
||||
selectedMarkerId,
|
||||
geoJsonData,
|
||||
className,
|
||||
style,
|
||||
@@ -100,6 +101,7 @@ export const MapLibreMapComponent: React.FC<BaseMapProps> = ({
|
||||
id={id}
|
||||
initialViewState={initialViewState}
|
||||
markers={markers}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
geoJsonData={geoJsonData}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onGeoJsonClick={handleGeoJsonClick}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { photoLoader } from '@afilmory/data'
|
||||
import { m } from 'motion/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import {
|
||||
GenericMap,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from '~/components/ui/map'
|
||||
import {
|
||||
calculateMapBounds,
|
||||
convertExifGPSToDecimal,
|
||||
convertPhotosToMarkersFromEXIF,
|
||||
getInitialViewStateForMarkers,
|
||||
} from '~/lib/map-utils'
|
||||
@@ -27,6 +29,7 @@ export const MapSection = () => {
|
||||
|
||||
const MapSectionContent = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
// Photo markers state and loading logic
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -64,11 +67,46 @@ const MapSectionContent = () => {
|
||||
loadPhotoMarkersData()
|
||||
}, [setMarkers])
|
||||
|
||||
// Initial view state calculation
|
||||
const initialViewState = useMemo(
|
||||
() => getInitialViewStateForMarkers(markers),
|
||||
[markers],
|
||||
)
|
||||
// Parse URL parameters - only use photoId
|
||||
const { latitude, longitude, zoom, photoId } = useMemo(() => {
|
||||
const photoIdParam = searchParams.get('photoId')
|
||||
|
||||
if (photoIdParam) {
|
||||
const photo = photoLoader.getPhoto(photoIdParam)
|
||||
const gpsData = convertExifGPSToDecimal(photo?.exif ?? null)
|
||||
|
||||
if (gpsData) {
|
||||
return {
|
||||
latitude: gpsData.latitude,
|
||||
longitude: gpsData.longitude,
|
||||
zoom: 15, // Default zoom when coordinates derived from photo
|
||||
photoId: photoIdParam,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
zoom: null,
|
||||
photoId: photoIdParam,
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Initial view state calculation - handle URL parameters
|
||||
const initialViewState = useMemo(() => {
|
||||
if (latitude !== null && longitude !== null) {
|
||||
// Use URL parameters if provided
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
zoom: zoom ?? 15,
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to markers-based view state
|
||||
return getInitialViewStateForMarkers(markers)
|
||||
}, [markers, latitude, longitude, zoom])
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
@@ -110,6 +148,8 @@ const MapSectionContent = () => {
|
||||
<GenericMap
|
||||
markers={markers}
|
||||
initialViewState={initialViewState}
|
||||
autoFitBounds={latitude === null || longitude === null}
|
||||
selectedMarkerId={photoId}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</m.div>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface BaseMapProps {
|
||||
id?: string
|
||||
initialViewState?: MapViewState
|
||||
markers?: PhotoMarker[]
|
||||
selectedMarkerId?: string | null
|
||||
geoJsonData?: GeoJSON.FeatureCollection
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
},
|
||||
"map": [
|
||||
"maplibre"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mapStyle": "builtin"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SiteConfig {
|
||||
social?: Social
|
||||
feed?: Feed
|
||||
map?: MapConfig
|
||||
mapStyle?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user