feat: add minimap support (#61)

This commit is contained in:
Wenzhuo Liu
2025-07-15 00:42:03 +08:00
committed by GitHub
parent da487a3000
commit d16d6b9547
11 changed files with 244 additions and 75 deletions

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
)}

View 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>
)
}

View File

@@ -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
*/

View 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
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -33,6 +33,7 @@ export interface BaseMapProps {
id?: string
initialViewState?: MapViewState
markers?: PhotoMarker[]
selectedMarkerId?: string | null
geoJsonData?: GeoJSON.FeatureCollection
className?: string
style?: React.CSSProperties

View File

@@ -11,5 +11,6 @@
},
"map": [
"maplibre"
]
}
],
"mapStyle": "builtin"
}

View File

@@ -12,6 +12,7 @@ export interface SiteConfig {
social?: Social
feed?: Feed
map?: MapConfig
mapStyle?: string
}
/**