feat: map explore page (#29)

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: MaxtuneLee <max@xox.im>
Co-authored-by: Innei <tukon479@gmail.com>
This commit is contained in:
ChrAlpha
2025-07-07 17:22:56 +08:00
committed by GitHub
parent 139607185e
commit 7310555c1a
43 changed files with 6327 additions and 106 deletions

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ apps/web/assets-git
apps/web/public/thumbnails
apps/web/src/data/photos-manifest.json
.vercel
apps/ssr/.next

View File

@@ -0,0 +1,44 @@
<!-- Generated by SpecStory -->
# Issue with photo viewer closing behavior (2025-07-06 11:08Z)
_**User**_
为什么关闭图片浏览器的时候会自动回到网站首页,这里似乎没有相关的逻辑存在
---
_**Assistant**_
我来分析一下你的问题。从你提供的 `usePhotoViewer.ts` 代码中,我确实没有看到任何会导致关闭图片浏览器时自动回到网站首页的逻辑。`closeViewer` 函数只是简单地关闭了查看器并恢复了背景滚动。
让我搜索一下相关的代码,看看是否有其他地方可能导致这个行为: 现在让我搜索一下 `useSyncStateToUrl` 相关的代码,这看起来是关键的地方: 让我读取这个 layout 文件的完整内容: 找到问题了!在 `useSyncStateToUrl` 函数中,有一个 `useEffect` 监听了 `isOpen` 状态的变化。当图片浏览器关闭时(`isOpen``false`),代码会检查当前路径是否为 `/explory`,如果不是,就会在 500ms 后自动导航到首页 `/`
具体的问题代码在第 106-116 行:
```tsx
useEffect(() => {
if (!isRestored) return
if (!isOpen) {
const isExploryPath = location.pathname === '/explory'
if (!isExploryPath) {
const timer = setTimeout(() => {
navigate('/') // 这里会导航到首页
}, 500)
return () => clearTimeout(timer)
}
} else {
// ... 其他逻辑
}
}, [currentIndex, isOpen, location.pathname, navigate])
```
这个逻辑的设计意图是:
1. 当图片浏览器打开时(`isOpen``true`),根据当前选中的图片更新 URL 路径
2. 当图片浏览器关闭时(`isOpen``false`),如果当前不在 `/explory` 路径,就延迟 500ms 后自动跳转到首页
所以关闭图片浏览器时自动回到首页的逻辑就在这里。如果你不希望这个行为,可以修改或移除这部分代码。
---

View File

@@ -0,0 +1,96 @@
<!-- Generated by SpecStory -->
# Troubleshooting triangle display in MapLibre component (2025-07-07 04:30Z)
_**User**_
为什么这里三角无法正常显示
---
_**Assistant**_
我来看看你选中的代码片段,这是一个用于显示地图标记弹出窗口底部三角形指示器的代码。让我分析一下可能导致三角形无法正常显示的原因: 我看到问题了!你选中的这段代码是用来创建一个指向地图标记的三角形箭头,但是有一个**层级问题**导致三角形无法正常显示。
**问题分析:**
1. **层级问题**:三角形元素 `<div className="h-4 w-4 rotate-45 bg-white dark:bg-gray-800" />` 位于弹出窗口容器内部,但它的 `absolute -bottom-2` 定位使其向下偏移,可能被弹出窗口的 `overflow-hidden` 属性裁剪掉了。
2. **容器限制**:弹出窗口容器使用了 `overflow-hidden rounded-lg`,这会裁剪掉超出容器边界的内容。
**解决方案:**
将三角形元素移出弹出窗口容器,或者调整容器的 overflow 属性:
````tsx
{/* Selected popup */}
{isSelected && (
<m.div
className="absolute -top-68 left-1/2 z-50 -translate-x-1/2 transform"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 20, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
<div className="relative w-64 cursor-default rounded-lg bg-white shadow-xl dark:bg-gray-800">
{/* Close button */}
<button
type="button"
onClick={handleClose}
className="absolute top-2 right-2 z-10 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70"
>
<span className="sr-only">Close</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5 text-white"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/* Photo */}
<div className="relative overflow-hidden rounded-t-lg">
<LazyImage
src={marker.photo.thumbnailUrl || marker.photo.originalUrl}
alt={marker.photo.title || marker.photo.id}
thumbHash={marker.photo.thumbHash}
className="h-32 w-full"
rootMargin="200px"
threshold={0.1}
/>
</div>
{/* Info */}
<div className="flex flex-col gap-2 p-4 rounded-b-lg">
{/* ...existing info content... */}
</div>
</div>
{/* Arrow pointing to marker - moved outside the main container */}
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 transform">
<div className="h-4 w-4 rotate-45 bg-white dark:bg-gray-800" />
</div>
</m.div>
)}
````
**主要改动:**
1. **移除了容器的 `overflow-hidden`**:只在需要的子元素上应用 `overflow-hidden`
2. **将三角形移出主容器**:现在三角形是 `m.div` 的直接子元素,不会被主容器裁剪
3. **调整了 rounded 样式**:分别对图片和信息区域应用圆角
这样修改后,三角形应该能够正常显示了。如果还有问题,可能需要检查一下:
1. **z-index 层级**:确保三角形没有被其他元素遮挡
2. **背景色匹配**:确保三角形的背景色与弹出窗口一致
3. **定位计算**:确保三角形的位置计算正确
---

View File

@@ -23,10 +23,12 @@
"@essentials/request-timeout": "1.3.0",
"@headlessui/react": "2.2.4",
"@lobehub/fluent-emoji": "2.0.0",
"@maplibre/maplibre-gl-geocoder": "^1.9.0",
"@radix-ui/react-avatar": "1.1.10",
"@radix-ui/react-context-menu": "2.2.15",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "2.1.15",
"@radix-ui/react-hover-card": "1.1.14",
"@radix-ui/react-popover": "1.1.14",
"@radix-ui/react-scroll-area": "1.2.9",
"@radix-ui/react-slot": "1.2.3",
@@ -49,6 +51,7 @@
"i18next-browser-languagedetector": "8.2.0",
"immer": "10.1.1",
"jotai": "2.12.5",
"maplibre-gl": "^5.6.1",
"masonic": "4.1.0",
"motion": "12.19.1",
"ofetch": "1.4.1",
@@ -60,6 +63,7 @@
"react-i18next": "15.5.3",
"react-image-gallery": "1.4.0",
"react-intersection-observer": "9.16.0",
"react-map-gl": "^8.0.4",
"react-remove-scroll": "2.7.1",
"react-router": "7.6.2",
"react-scan": "0.3.4",

View File

@@ -0,0 +1,47 @@
import type {
HTMLMotionProps,
} from 'motion/react'
import { m } from 'motion/react'
import type { FC, PropsWithChildren } from 'react'
import { clsxm } from '~/lib/cn'
import { Spring } from '~/lib/spring'
export const GlassButton: FC<HTMLMotionProps<'button'> & PropsWithChildren> = (
props,
) => {
return (
<m.button
type="button"
{...props}
className={clsxm(
// Base styles with modern glass morphism - perfect 1:1 circle
'pointer-events-auto relative flex size-10 items-center justify-center rounded-full',
'bg-black/20 text-white backdrop-blur-md',
// Border and shadow for depth
'border border-white/10 shadow-lg shadow-black/25',
// Text size
'text-lg',
props.className,
)}
initial={{ scale: 1 }}
whileHover={{
scale: 1.1,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
whileTap={{ scale: 0.95 }}
transition={Spring.presets.smooth}
>
{/* Glass effect overlay */}
<div className="absolute inset-0 rounded-full bg-gradient-to-t from-white/5 to-white/20 opacity-0 transition-opacity duration-300 hover:opacity-100" />
{/* Icon container */}
<div className="center relative z-10 flex">{props.children}</div>
{/* Subtle inner shadow for depth */}
<div className="absolute inset-0 rounded-full shadow-inner shadow-black/10" />
</m.button>
)
}

View File

@@ -0,0 +1,53 @@
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { m } from 'motion/react'
import * as React from 'react'
import { clsxm } from '~/lib/cn'
import { Spring } from '~/lib/spring'
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = ({
ref,
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<
typeof HoverCardPrimitive.Content
> | null>
}) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={clsxm(
'z-50 w-64 rounded-xl border border-border bg-material-thick p-4 shadow-2xl outline-none backdrop-blur-[80px]',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
asChild
{...props}
>
<m.div
initial={{ opacity: 0, scale: 0.95, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 4 }}
transition={Spring.presets.smooth}
>
{props.children}
</m.div>
</HoverCardPrimitive.Content>
</HoverCardPrimitive.Portal>
)
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardContent, HoverCardTrigger }

View File

@@ -0,0 +1,91 @@
import { useCallback, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { Thumbhash } from '~/components/ui/thumbhash'
import { clsxm } from '~/lib/cn'
export interface LazyImageProps {
src: string
alt: string
thumbHash?: string | null
className?: string
style?: React.CSSProperties
onLoad?: () => void
onError?: () => void
// Intersection observer options
rootMargin?: string
threshold?: number
}
export const LazyImage = ({
src,
alt,
thumbHash,
className,
style,
onLoad,
onError,
rootMargin = '50px',
threshold = 0.1,
}: LazyImageProps) => {
const [isLoaded, setIsLoaded] = useState(false)
const [hasError, setHasError] = useState(false)
const { ref, inView } = useInView({
triggerOnce: true,
rootMargin,
threshold,
})
const handleLoad = useCallback(() => {
setIsLoaded(true)
onLoad?.()
}, [onLoad])
const handleError = useCallback(() => {
setHasError(true)
onError?.()
}, [onError])
const shouldLoadImage = inView && !hasError
return (
<div
ref={ref}
className={clsxm('relative overflow-hidden', className)}
style={style}
>
{/* Thumbhash placeholder */}
{thumbHash && !isLoaded && (
<Thumbhash
thumbHash={thumbHash}
className="absolute inset-0 scale-110 blur-sm"
/>
)}
{/* Actual image */}
{shouldLoadImage && (
<img
src={src}
alt={alt}
className={clsxm(
'h-full w-full object-cover transition-opacity duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
onLoad={handleLoad}
onError={handleError}
loading="lazy"
/>
)}
{/* Error state */}
{hasError && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-200 dark:bg-gray-800">
<span className="text-sm text-gray-500 dark:text-gray-400">
Failed to load image
</span>
</div>
)}
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,166 @@
import { m } from 'motion/react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { LazyImage } from '~/components/ui/lazy-image'
import { Spring } from '~/lib/spring'
import type { PhotoMarker } from '~/types/map'
interface ClusterPhotoGridProps {
photos: PhotoMarker[]
onPhotoClick?: (photo: PhotoMarker) => void
}
export const ClusterPhotoGrid = ({
photos,
onPhotoClick,
}: ClusterPhotoGridProps) => {
// 最多显示 6 张照片
const displayPhotos = photos.slice(0, 6)
const remainingCount = Math.max(0, photos.length - 6)
const { i18n } = useTranslation()
return (
<div className="space-y-3">
{/* 标题 */}
<div className="flex items-center justify-between">
<h3 className="text-text text-sm font-semibold">
{photos.length}
</h3>
<div className="text-text-secondary text-xs"></div>
</div>
{/* 照片网格 */}
<div className="grid grid-cols-3 gap-2">
{displayPhotos.map((photoMarker, index) => (
<m.div
key={photoMarker.photo.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
...Spring.presets.smooth,
delay: index * 0.05,
}}
className="group relative aspect-square overflow-hidden rounded-lg"
>
<Link
to={`/${photoMarker.photo.id}`}
target="_blank"
onClick={(e) => {
e.stopPropagation()
onPhotoClick?.(photoMarker)
}}
className="block h-full w-full"
>
<LazyImage
src={
photoMarker.photo.thumbnailUrl ||
photoMarker.photo.originalUrl
}
alt={photoMarker.photo.title || photoMarker.photo.id}
thumbHash={photoMarker.photo.thumbHash}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
rootMargin="200px"
threshold={0.1}
/>
{/* 悬停遮罩 */}
<div className="absolute inset-0 bg-black/0 transition-colors duration-300 group-hover:bg-black/20" />
{/* 悬停图标 */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="rounded-full bg-black/50 p-2 backdrop-blur-sm">
<svg
className="h-4 w-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</div>
</div>
</Link>
</m.div>
))}
{/* 更多照片指示器 */}
{remainingCount > 0 && (
<m.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
...Spring.presets.smooth,
delay: displayPhotos.length * 0.05,
}}
className="bg-fill-secondary flex aspect-square items-center justify-center rounded-lg"
>
<div className="text-center">
<div className="text-text text-lg font-bold">
+{remainingCount}
</div>
<div className="text-text-secondary text-xs"></div>
</div>
</m.div>
)}
</div>
{/* 位置信息 */}
{photos[0] && (
<div className="border-border space-y-2 border-t pt-3">
<div className="text-text-secondary flex items-center gap-2 text-xs">
<i className="i-mingcute-location-line text-sm" />
<span className="font-mono">
{Math.abs(photos[0].latitude).toFixed(4)}°
{photos[0].latitudeRef || 'N'},{' '}
{Math.abs(photos[0].longitude).toFixed(4)}°
{photos[0].longitudeRef || 'E'}
</span>
</div>
{/* 拍摄时间范围 */}
{(() => {
const dates = photos
.map((p) => p.photo.exif?.DateTimeOriginal)
.filter(Boolean)
.map((d) => new Date(d!))
.sort((a, b) => a.getTime() - b.getTime())
if (dates.length === 0) return null
const earliest = dates[0]
const latest = dates.at(-1)
const isSameDay = earliest.toDateString() === latest?.toDateString()
return (
<div className="text-text-secondary flex items-center gap-2 text-xs">
<i className="i-mingcute-calendar-line text-sm" />
<span>
{isSameDay
? earliest.toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
: `${earliest.toLocaleDateString(i18n.language, {
month: 'short',
day: 'numeric',
year: 'numeric',
})} - ${latest?.toLocaleDateString(i18n.language, {
month: 'short',
day: 'numeric',
})}`}
</span>
</div>
)
})()}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,69 @@
import * as React from 'react'
import { getInitialViewStateForMarkers } from '~/lib/map-utils'
import { useMapAdapter } from '~/modules/map/MapProvider'
import type { BaseMapProps, PhotoMarker } from '~/types/map'
interface GenericMapProps extends Omit<BaseMapProps, 'handlers'> {
/** Photo markers to display */
markers?: PhotoMarker[]
/** Callback when marker is clicked */
onMarkerClick?: (marker: PhotoMarker) => void
/** Callback when GeoJSON feature is clicked */
onGeoJsonClick?: (feature: GeoJSON.Feature) => void
/** Callback for geolocation */
onGeolocate?: (longitude: number, latitude: number) => void
}
// Default empty array to avoid inline array creation
const DEFAULT_MARKERS: PhotoMarker[] = []
/**
* Generic map component that abstracts away the specific map provider
* This component automatically selects the best available provider from context
*/
export const GenericMap: React.FC<GenericMapProps> = ({
markers = DEFAULT_MARKERS,
onMarkerClick,
onGeoJsonClick,
onGeolocate,
initialViewState,
autoFitBounds = true,
...props
}) => {
const adapter = useMapAdapter()
// Calculate initial view state from markers (only if autoFitBounds is disabled)
const calculatedInitialViewState = React.useMemo(() => {
if (autoFitBounds) {
// 如果开启自动适配则使用传入的initialViewState或默认值
return initialViewState || { longitude: 0, latitude: 0, zoom: 2 }
}
return initialViewState || getInitialViewStateForMarkers(markers)
}, [initialViewState, markers, autoFitBounds])
// Prepare handlers for the specific map adapter
const handlers = React.useMemo(
() => ({
onMarkerClick,
onGeoJsonClick,
onGeolocate,
}),
[onMarkerClick, onGeoJsonClick, onGeolocate],
)
if (!adapter) {
return <div>Map provider not available</div>
}
const { MapComponent } = adapter
return (
<MapComponent
{...props}
markers={markers}
initialViewState={calculatedInitialViewState}
autoFitBounds={autoFitBounds}
handlers={handlers}
/>
)
}

View File

@@ -0,0 +1,26 @@
import { startTransition } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { GlassButton } from '../button/GlassButton'
export const MapBackButton = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const handleBack = () => {
startTransition(() => {
navigate('/')
})
}
return (
<GlassButton
className="absolute top-4 left-4 z-50"
onClick={handleBack}
title={t('explory.back.to.gallery')}
>
<i className="i-mingcute-arrow-left-line text-base text-white" />
</GlassButton>
)
}

View File

@@ -0,0 +1,160 @@
import { m } from 'motion/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
interface MapInfoPanelProps {
markersCount: number
bounds?: {
minLat: number
maxLat: number
minLng: number
maxLng: number
} | null
}
export const MapInfoPanel = ({ markersCount, bounds }: MapInfoPanelProps) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
return (
<m.div
className="absolute top-4 right-4 z-40 max-w-xs"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="bg-material-thick border-fill-tertiary rounded-2xl border shadow-2xl backdrop-blur-[120px]">
{/* Header Section */}
<div className="p-5">
<m.div
className="flex items-center gap-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
{/* Icon container with enhanced styling */}
<div className="bg-blue/10 ring-blue/20 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl ring-1 ring-inset">
<i className="i-mingcute-map-line text-blue text-lg" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<h1 className="text-text text-lg leading-tight font-semibold tracking-tight">
{t('explory.explore.map')}
</h1>
{/* Collapse/Expand Button */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="bg-fill-secondary/50 ring-fill-tertiary/20 hover:bg-fill-tertiary relative -top-2 -mb-2 flex size-8 flex-shrink-0 items-center justify-center rounded-xl ring-1 transition-all duration-200 ring-inset"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
<m.i
className="i-mingcute-down-line text-text-secondary text-base"
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
/>
</button>
</div>
<div className="mt-1.5 flex items-center gap-2">
<div className="bg-green/10 ring-green/20 flex items-center gap-1.5 rounded-full px-2.5 py-1 ring-1 ring-inset">
<div className="bg-green h-1.5 w-1.5 rounded-full" />
<span className="text-text-secondary text-xs font-medium">
{t('explory.found.locations', { count: markersCount })}
</span>
</div>
</div>
</div>
</m.div>
</div>
{/* Coordinates Section - Collapsible */}
<m.div
initial={false}
animate={{
height: isExpanded && bounds ? 'auto' : 0,
opacity: isExpanded && bounds ? 1 : 0,
}}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="overflow-hidden"
>
{bounds && (
<div className="border-fill-secondary border-t px-5 pt-4 pb-5">
{/* Section header */}
<div className="mb-4 flex items-center gap-2.5">
<i className="i-mingcute-location-line text-text-secondary" />
<span className="text-text text-sm font-medium tracking-tight">
{t('explory.shooting.range')}
</span>
</div>
{/* Enhanced coordinate cards */}
<div className="space-y-3">
{/* Min coordinates */}
<div className="bg-fill-vibrant-quinary border-fill-tertiary rounded-xl border p-4">
<div className="text-text-secondary mb-2 flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
<i className="i-mingcute-arrow-left-down-line text-sm" />
Southwest
</div>
<div className="space-y-1">
<div className="text-text flex items-center justify-between">
<span className="text-xs font-medium">Lat</span>
<span className="font-mono text-sm tabular-nums">
{bounds.minLat.toFixed(6)}°
</span>
</div>
<div className="text-text flex items-center justify-between">
<span className="text-xs font-medium">Lng</span>
<span className="font-mono text-sm tabular-nums">
{bounds.minLng.toFixed(6)}°
</span>
</div>
</div>
</div>
{/* Max coordinates */}
<div className="bg-fill-vibrant-quinary border-fill-tertiary rounded-xl border p-4">
<div className="text-text-secondary mb-2 flex items-center gap-2 text-xs font-medium tracking-wide uppercase">
<i className="i-mingcute-arrow-right-up-line text-sm" />
Northeast
</div>
<div className="space-y-1">
<div className="text-text flex items-center justify-between">
<span className="text-xs font-medium">Lat</span>
<span className="font-mono text-sm tabular-nums">
{bounds.maxLat.toFixed(6)}°
</span>
</div>
<div className="text-text flex items-center justify-between">
<span className="text-xs font-medium">Lng</span>
<span className="font-mono text-sm tabular-nums">
{bounds.maxLng.toFixed(6)}°
</span>
</div>
</div>
</div>
</div>
{/* Coverage area calculation */}
<div className="bg-gray/5 mt-4 rounded-xl p-3">
<div className="text-text-secondary flex items-center gap-2 text-xs">
<i className="i-mingcute-grid-line" />
<span className="font-medium">
Coverage: ~
{Math.abs(
(bounds.maxLat - bounds.minLat) *
(bounds.maxLng - bounds.minLng) *
111 *
111,
).toFixed(1)}{' '}
km²
</span>
</div>
</div>
</div>
)}
</m.div>
</div>
</m.div>
)
}

View File

@@ -0,0 +1,248 @@
// Styles
import 'maplibre-gl/dist/maplibre-gl.css'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Map from 'react-map-gl/maplibre'
import { calculateMapBounds } from '~/lib/map-utils'
import type { PhotoMarker } from '~/types/map'
import MAP_STYLE from './MapLibreStyle.json'
import {
ClusterMarker,
clusterMarkers,
DEFAULT_MARKERS,
DEFAULT_STYLE,
DEFAULT_VIEW_STATE,
GeoJsonLayer,
MapControls,
PhotoMarkerPin,
} from './shared'
export interface PureMaplibreProps {
id?: string
initialViewState?: {
longitude: number
latitude: number
zoom: number
}
markers?: PhotoMarker[]
geoJsonData?: GeoJSON.FeatureCollection
onMarkerClick?: (marker: PhotoMarker) => void
onGeoJsonClick?: (event: any) => void
onGeolocate?: (longitude: number, latitude: number) => void
onClusterClick?: (longitude: number, latitude: number) => void
className?: string
style?: React.CSSProperties
mapRef?: React.RefObject<any>
autoFitBounds?: boolean
}
export const Maplibre = ({
id,
initialViewState = DEFAULT_VIEW_STATE,
markers = DEFAULT_MARKERS,
geoJsonData,
onMarkerClick,
onGeoJsonClick,
onGeolocate,
onClusterClick,
className = 'w-full h-full',
style = DEFAULT_STYLE,
mapRef,
autoFitBounds = true,
}: PureMaplibreProps) => {
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null)
const [currentZoom, setCurrentZoom] = useState(initialViewState.zoom)
const [viewState, setViewState] = useState(initialViewState)
const [isMapLoaded, setIsMapLoaded] = useState(false)
// Handle marker click
const handleMarkerClick = useCallback(
(marker: PhotoMarker) => {
// Toggle selection: if already selected, deselect; otherwise select
setSelectedMarkerId((prev) => (prev === marker.id ? null : marker.id))
onMarkerClick?.(marker)
},
[onMarkerClick],
)
// Handle marker close
const handleMarkerClose = useCallback(() => {
setSelectedMarkerId(null)
}, [])
// Clustered markers
const clusteredMarkers = useMemo(
() => clusterMarkers(markers, currentZoom),
[markers, currentZoom],
)
// 计算合适的缩放级别
const calculateZoomLevel = useCallback((latDiff: number, lngDiff: number) => {
const maxDiff = Math.max(latDiff, lngDiff)
if (maxDiff < 0.001) return 16 // 非常接近的点
if (maxDiff < 0.01) return 14 // 很接近的点
if (maxDiff < 0.1) return 11 // 附近的点
if (maxDiff < 1) return 8 // 同一城市
if (maxDiff < 10) return 5 // 同一国家/地区
return 2 // 跨洲
}, [])
// 自动适配到包含所有照片的区域
const fitMapToBounds = useCallback(() => {
if (!autoFitBounds || markers.length === 0 || !isMapLoaded) return
const bounds = calculateMapBounds(markers)
if (!bounds) return
// 如果只有一个点,设置默认缩放级别
if (markers.length === 1) {
const newViewState = {
longitude: markers[0].longitude,
latitude: markers[0].latitude,
zoom: 13, // 单点时的合理缩放级别
}
setViewState(newViewState)
setCurrentZoom(newViewState.zoom)
return
}
// 使用 mapRef 的 fitBounds 方法(推荐方式)
if (mapRef?.current?.getMap) {
// 计算动态padding确保照片区域控制在窗口的80%内
// 这意味着每边留出10%的空间作为缓冲区
const mapContainer = mapRef.current.getContainer()
const containerWidth = mapContainer.offsetWidth
const containerHeight = mapContainer.offsetHeight
const paddingPercentage = 0.1 // 每边10%的padding
const horizontalPadding = containerWidth * paddingPercentage
const verticalPadding = containerHeight * paddingPercentage
const padding = {
top: Math.max(verticalPadding, 40), // 最小40px
bottom: Math.max(verticalPadding, 40),
left: Math.max(horizontalPadding, 40),
right: Math.max(horizontalPadding, 40),
}
try {
const map = mapRef.current.getMap()
map.fitBounds(
[
[bounds.minLng, bounds.minLat], // 西南角
[bounds.maxLng, bounds.maxLat], // 东北角
],
{
padding,
duration: 800, // 平滑动画
maxZoom: 15, // 最大缩放级别限制,避免过度放大
},
)
} catch (error) {
console.warn('使用 fitBounds 失败,使用备用方案:', error)
// 备用方案:手动计算视图状态
fallbackToViewState(bounds)
}
} else {
// mapRef 不可用时的备用方案
fallbackToViewState(bounds)
}
function fallbackToViewState(
bounds: ReturnType<typeof calculateMapBounds>,
) {
if (!bounds) return
const latDiff = bounds.maxLat - bounds.minLat
const lngDiff = bounds.maxLng - bounds.minLng
// 为备用方案也增加一些缓冲,降低一级缩放
const zoom = Math.max(calculateZoomLevel(latDiff, lngDiff) - 1, 2)
const newViewState = {
longitude: bounds.centerLng,
latitude: bounds.centerLat,
zoom,
}
setViewState(newViewState)
setCurrentZoom(zoom)
}
}, [markers, autoFitBounds, isMapLoaded, mapRef, calculateZoomLevel])
// 当地图加载完成时触发适配
const handleMapLoad = useCallback(() => {
setIsMapLoaded(true)
}, [])
// 当标记点变化时,重新适配边界
useEffect(() => {
// 延迟执行,确保地图已渲染
const timer = setTimeout(() => {
fitMapToBounds()
}, 100)
return () => clearTimeout(timer)
}, [fitMapToBounds])
return (
<div className={className} style={style}>
<Map
id={id}
ref={mapRef}
{...viewState}
style={{ width: '100%', height: '100%' }}
// @ts-expect-error
mapStyle={MAP_STYLE}
attributionControl={false}
interactiveLayerIds={geoJsonData ? ['data'] : undefined}
onClick={onGeoJsonClick}
onLoad={handleMapLoad}
onMove={(evt) => {
setCurrentZoom(evt.viewState.zoom)
setViewState(evt.viewState)
}}
>
{/* Map Controls */}
<MapControls onGeolocate={onGeolocate} />
{/* Photo Markers */}
{clusteredMarkers.map((clusterPoint) => {
if (clusterPoint.properties.cluster) {
// Render cluster marker
return (
<ClusterMarker
key={`cluster-${clusterPoint.geometry.coordinates[0]}-${clusterPoint.geometry.coordinates[1]}`}
longitude={clusterPoint.geometry.coordinates[0]}
latitude={clusterPoint.geometry.coordinates[1]}
pointCount={clusterPoint.properties.point_count || 0}
representativeMarker={clusterPoint.properties.marker}
clusteredPhotos={clusterPoint.properties.clusteredPhotos}
onClusterClick={onClusterClick}
/>
)
} else {
// Render individual marker
const { marker } = clusterPoint.properties
if (!marker) return null
return (
<PhotoMarkerPin
key={marker.id}
marker={marker}
isSelected={selectedMarkerId === marker.id}
onClick={handleMarkerClick}
onClose={handleMarkerClose}
/>
)
}
})}
{/* GeoJSON Layer */}
{geoJsonData && <GeoJsonLayer data={geoJsonData} />}
</Map>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import { m } from 'motion/react'
import { useTranslation } from 'react-i18next'
export const MapLoadingState = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-center">
<m.div
className="text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<m.div
className="loading-icon mb-4 text-4xl"
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
📍
</m.div>
<m.div
className="text-lg font-medium text-gray-900 dark:text-gray-100"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{t('explory.loading.map')}
</m.div>
<m.p
className="text-sm text-gray-600 dark:text-gray-400"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
{t('explory.parsing.location')}
</m.p>
</m.div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
export * from './GenericMap'
export * from './MapBackButton'
export * from './MapInfoPanel'
export * from './MapLoadingState'
export * from './shared'

View File

@@ -0,0 +1,132 @@
import { m } from 'motion/react'
import { Marker } from 'react-map-gl/maplibre'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '~/components/ui/hover-card'
import { LazyImage } from '~/components/ui/lazy-image'
import { ClusterPhotoGrid } from '../ClusterPhotoGrid'
import type { ClusterMarkerProps } from './types'
export const ClusterMarker = ({
longitude,
latitude,
pointCount,
representativeMarker: _representativeMarker,
clusteredPhotos = [],
onClusterClick,
}: ClusterMarkerProps) => {
const size = Math.min(64, Math.max(40, 32 + Math.log(pointCount) * 8))
return (
<Marker longitude={longitude} latitude={latitude}>
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
<m.div
className="group relative cursor-pointer"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 25,
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onClusterClick?.(longitude, latitude)}
>
{/* Subtle pulse ring for attention */}
<div
className="bg-blue/20 absolute inset-0 animate-pulse rounded-full opacity-60"
style={{
width: size + 12,
height: size + 12,
left: -6,
top: -6,
}}
/>
{/* Main cluster container */}
<div
className="relative flex items-center justify-center rounded-full border border-white/40 bg-white/95 shadow-lg backdrop-blur-md transition-all duration-300 hover:bg-white hover:shadow-xl dark:border-white/10 dark:bg-black/80 dark:hover:bg-black/90"
style={{
width: size,
height: size,
}}
>
{/* Background mosaic of photos */}
{clusteredPhotos.length > 0 && (
<div className="absolute inset-1 overflow-hidden rounded-full">
{/* Show up to 4 photos in a mosaic pattern */}
{clusteredPhotos.slice(0, 4).map((photoMarker, index) => {
const positions = [
{ left: '0%', top: '0%', width: '50%', height: '50%' },
{ left: '50%', top: '0%', width: '50%', height: '50%' },
{ left: '0%', top: '50%', width: '50%', height: '50%' },
{ left: '50%', top: '50%', width: '50%', height: '50%' },
]
const position = positions[index]
return (
<div
key={photoMarker.photo.id}
className="absolute opacity-30"
style={position}
>
<LazyImage
src={
photoMarker.photo.thumbnailUrl ||
photoMarker.photo.originalUrl
}
alt={photoMarker.photo.title || photoMarker.photo.id}
thumbHash={photoMarker.photo.thumbHash}
className="h-full w-full object-cover"
rootMargin="100px"
threshold={0.1}
/>
</div>
)
})}
{/* Overlay for mosaic effect */}
<div className="from-blue/40 to-indigo/60 absolute inset-0 bg-gradient-to-br" />
</div>
)}
{/* Glass morphism overlay */}
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/30 to-white/10 dark:from-white/20 dark:to-white/5" />
{/* Count display */}
<div className="relative z-10 flex flex-col items-center text-xs">
<span className="text-text font-bold">{pointCount}</span>
</div>
{/* Subtle inner shadow for depth */}
<div className="absolute inset-0 rounded-full shadow-inner shadow-black/5" />
</div>
</m.div>
</HoverCardTrigger>
<HoverCardContent
className="w-80 overflow-hidden border-white/20 bg-white/95 p-0 backdrop-blur-[120px] dark:bg-black/95"
side="top"
align="center"
sideOffset={8}
>
<div className="p-4">
<ClusterPhotoGrid
photos={clusteredPhotos}
onPhotoClick={(_photo) => {
// Optional: handle individual photo clicks
// Photo click handling can be implemented here if needed
}}
/>
</div>
</HoverCardContent>
</HoverCard>
</Marker>
)
}

View File

@@ -0,0 +1,23 @@
import { Layer, Source } from 'react-map-gl/maplibre'
import type { GeoJsonLayerProps, LayerProps } from './types'
const DEFAULT_LAYER_STYLE: LayerProps = {
id: 'data',
type: 'fill',
paint: {
'fill-color': '#0080ff',
'fill-opacity': 0.5,
},
}
export const GeoJsonLayer = ({
data,
layerStyle = DEFAULT_LAYER_STYLE,
}: GeoJsonLayerProps) => {
return (
<Source type="geojson" data={data}>
<Layer {...layerStyle} />
</Source>
)
}

View File

@@ -0,0 +1,113 @@
import { m } from 'motion/react'
import { useMap } from 'react-map-gl/maplibre'
import type { MapControlsProps } from './types'
export const MapControls = ({ onGeolocate }: MapControlsProps) => {
const { current: map } = useMap()
const handleZoomIn = () => {
if (map) {
const currentZoom = map.getZoom()
map.easeTo({ zoom: currentZoom + 1, duration: 300 })
}
}
const handleZoomOut = () => {
if (map) {
const currentZoom = map.getZoom()
map.easeTo({ zoom: currentZoom - 1, duration: 300 })
}
}
const handleCompass = () => {
if (map) {
map.easeTo({ bearing: 0, pitch: 0, duration: 500 })
}
}
const handleGeolocate = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const { longitude, latitude } = position.coords
if (map) {
map.flyTo({
center: [longitude, latitude],
zoom: 14,
duration: 1000,
})
}
onGeolocate?.(longitude, latitude)
},
(error) => {
console.warn('Geolocation error:', error)
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 60000,
},
)
}
}
return (
<m.div
className="absolute bottom-4 left-4 z-40 flex flex-col gap-3"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
{/* Control Group Container */}
<div className="bg-material-thick border-fill-tertiary flex flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-[120px]">
{/* Zoom In */}
<button
type="button"
onClick={handleZoomIn}
className="group hover:bg-fill-secondary active:bg-fill-tertiary flex h-12 w-12 items-center justify-center transition-colors"
title="放大"
>
<i className="i-mingcute-add-line text-text size-5 transition-transform group-hover:scale-110 group-active:scale-95" />
</button>
{/* Divider */}
<div className="bg-fill-secondary h-px w-full" />
{/* Zoom Out */}
<button
type="button"
onClick={handleZoomOut}
className="group hover:bg-fill-secondary active:bg-fill-tertiary flex h-12 w-12 items-center justify-center transition-colors"
title="缩小"
>
<i className="i-mingcute-minimize-line text-text size-5 transition-transform group-hover:scale-110 group-active:scale-95" />
</button>
</div>
{/* Compass Button */}
<div className="bg-material-thick border-fill-tertiary overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-[120px]">
<button
type="button"
onClick={handleCompass}
className="group hover:bg-fill-secondary active:bg-fill-tertiary flex h-12 w-12 items-center justify-center transition-colors"
title="重置方向"
>
<i className="i-mingcute-navigation-line text-text size-5 transition-transform group-hover:scale-110 group-active:scale-95" />
</button>
</div>
{/* Geolocate Button */}
<div className="bg-material-thick border-fill-tertiary overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-[120px]">
<button
type="button"
onClick={handleGeolocate}
className="group hover:bg-fill-secondary active:bg-fill-tertiary flex h-12 w-12 items-center justify-center transition-colors"
title="定位到我的位置"
>
<i className="i-mingcute-location-fill text-text size-5 transition-transform group-hover:scale-110 group-active:scale-95" />
</button>
</div>
</m.div>
)
}

View File

@@ -0,0 +1,286 @@
import { m } from 'motion/react'
import { Marker } from 'react-map-gl/maplibre'
import { Link } from 'react-router'
import { GlassButton } from '~/components/ui/button/GlassButton'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '~/components/ui/hover-card'
import { LazyImage } from '~/components/ui/lazy-image'
import type { PhotoMarkerPinProps } from './types'
export const PhotoMarkerPin = ({
marker,
isSelected = false,
onClick,
onClose,
}: PhotoMarkerPinProps) => {
const handleClick = () => {
onClick?.(marker)
}
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation()
onClose?.()
}
return (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
>
<HoverCard openDelay={400} closeDelay={100}>
<HoverCardTrigger asChild>
<m.div
className="group relative cursor-pointer"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={handleClick}
>
{/* Selection ring */}
{isSelected && (
<div className="bg-blue/30 absolute inset-0 -m-2 animate-pulse rounded-full" />
)}
{/* Photo background preview */}
<div className="absolute inset-0 overflow-hidden rounded-full">
<LazyImage
src={marker.photo.thumbnailUrl || marker.photo.originalUrl}
alt={marker.photo.title || marker.photo.id}
thumbHash={marker.photo.thumbHash}
className="h-full w-full object-cover opacity-40"
rootMargin="100px"
threshold={0.1}
/>
{/* Overlay */}
<div className="from-green/60 to-emerald/80 dark:from-green/70 dark:to-emerald/90 absolute inset-0 bg-gradient-to-br" />
</div>
{/* Main marker container */}
<div
className={`relative flex h-10 w-10 items-center justify-center rounded-full border shadow-lg backdrop-blur-md transition-all duration-300 hover:shadow-xl ${
isSelected
? 'border-blue/40 bg-blue/90 shadow-blue/50 dark:border-blue/30 dark:bg-blue/80'
: 'border-white/40 bg-white/95 hover:bg-white dark:border-white/20 dark:bg-black/80 dark:hover:bg-black/90'
}`}
>
{/* Glass morphism overlay */}
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/30 to-white/10 dark:from-white/20 dark:to-white/5" />
{/* Camera icon */}
<i
className={`i-mingcute-camera-line relative z-10 text-lg drop-shadow-sm ${
isSelected ? 'text-white' : 'text-gray-700 dark:text-white'
}`}
/>
{/* Subtle inner shadow for depth */}
<div className="absolute inset-0 rounded-full shadow-inner shadow-black/5" />
</div>
</m.div>
</HoverCardTrigger>
<HoverCardContent
className="w-80 overflow-hidden border-white/20 bg-white/95 p-0 backdrop-blur-[120px] dark:bg-black/95"
side="top"
align="center"
sideOffset={8}
>
<div className="relative">
{/* Photo header */}
<div className="relative h-32 overflow-hidden">
<LazyImage
src={marker.photo.thumbnailUrl || marker.photo.originalUrl}
alt={marker.photo.title || marker.photo.id}
thumbHash={marker.photo.thumbHash}
className="h-full w-full object-cover"
rootMargin="200px"
threshold={0.1}
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
</div>
{/* Content */}
<div className="space-y-3 p-4">
{/* Title with link */}
<Link
to={`/${marker.photo.id}`}
target="_blank"
className="group/link hover:text-blue flex items-center gap-2 transition-colors"
>
<h3
className="text-text flex-1 truncate text-sm font-semibold"
title={marker.photo.title || marker.photo.id}
>
{marker.photo.title || marker.photo.id}
</h3>
<i className="i-mingcute-arrow-right-line text-text-secondary transition-transform group-hover/link:translate-x-0.5" />
</Link>
{/* Metadata */}
<div className="space-y-2">
{marker.photo.exif?.DateTimeOriginal && (
<div className="text-text-secondary flex items-center gap-2 text-xs">
<i className="i-mingcute-calendar-line text-sm" />
<span>
{new Date(
marker.photo.exif.DateTimeOriginal,
).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
)}
{marker.photo.exif?.Make && marker.photo.exif?.Model && (
<div className="text-text-secondary flex items-center gap-2 text-xs">
<i className="i-mingcute-camera-line text-sm" />
<span className="truncate">
{marker.photo.exif.Make} {marker.photo.exif.Model}
</span>
</div>
)}
<div className="text-text-secondary space-y-1 text-xs">
<div className="flex items-center gap-2">
<i className="i-mingcute-location-line text-sm" />
<span className="font-mono">
{Math.abs(marker.latitude).toFixed(4)}°
{marker.latitudeRef || 'N'},{' '}
{Math.abs(marker.longitude).toFixed(4)}°
{marker.longitudeRef || 'E'}
</span>
</div>
{marker.altitude !== undefined && (
<div className="flex items-center gap-2">
<i className="i-mingcute-mountain-2-line text-sm" />
<span className="font-mono">
{marker.altitudeRef === 'Below Sea Level' ? '-' : ''}
{Math.abs(marker.altitude).toFixed(1)}m
</span>
</div>
)}
</div>
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
{/* Enhanced popup for selected state */}
{isSelected && (
<m.div
className="absolute -top-80 left-1/2 z-50 -translate-x-1/2 transform"
initial={{ y: 20, opacity: 0, scale: 0.9 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 20, opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
<div className="border-fill-tertiary bg-material-thick relative w-72 cursor-default overflow-hidden rounded-xl border shadow-2xl backdrop-blur-[80px]">
{/* Close button */}
<GlassButton
className="absolute top-3 right-3 z-10 size-8"
onClick={handleClose}
>
<i className="i-mingcute-close-line text-lg" />
</GlassButton>
{/* Photo container */}
<div className="relative overflow-hidden">
<LazyImage
src={marker.photo.thumbnailUrl || marker.photo.originalUrl}
alt={marker.photo.title || marker.photo.id}
thumbHash={marker.photo.thumbHash}
className="h-40 w-full object-cover"
rootMargin="200px"
threshold={0.1}
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent" />
</div>
{/* Content */}
<div className="flex flex-col gap-3 p-4">
{/* Title with link */}
<Link
to={`/${marker.photo.id}`}
target="_blank"
className="group/link hover:text-blue flex items-center gap-2 transition-colors"
>
<h3
className="text-text flex-1 truncate text-base font-semibold"
title={marker.photo.title || marker.photo.id}
>
{marker.photo.title || marker.photo.id}
</h3>
<i className="i-mingcute-arrow-right-line" />
</Link>
{/* Metadata */}
<div className="space-y-2">
{marker.photo.exif?.DateTimeOriginal && (
<div className="text-text-secondary flex items-center gap-2 text-sm">
<i className="i-mingcute-calendar-line" />
<span className="text-xs">
{new Date(
marker.photo.exif.DateTimeOriginal,
).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
)}
{marker.photo.exif?.Make && marker.photo.exif?.Model && (
<div className="text-text-secondary flex items-center gap-2 text-sm">
<i className="i-mingcute-camera-line" />
<span className="truncate text-xs">
{marker.photo.exif.Make} {marker.photo.exif.Model}
</span>
</div>
)}
<div className="text-text-secondary space-y-1 text-sm">
<div className="flex items-center gap-2">
<i className="i-mingcute-location-line" />
<span className="font-mono text-xs">
{Math.abs(marker.latitude).toFixed(6)}°
{marker.latitudeRef || 'N'},{' '}
{Math.abs(marker.longitude).toFixed(6)}°
{marker.longitudeRef || 'E'}
</span>
</div>
{marker.altitude !== undefined && (
<div className="flex items-center gap-2">
<i className="i-mingcute-mountain-2-line" />
<span className="font-mono text-xs">
{marker.altitudeRef === 'Below Sea Level' ? '-' : ''}
{Math.abs(marker.altitude).toFixed(1)}m
</span>
</div>
)}
</div>
</div>
</div>
</div>
</m.div>
)}
</Marker>
)
}

View File

@@ -0,0 +1,91 @@
import type { PhotoMarker } from '~/types/map'
import type { ClusterPoint } from './types'
/**
* Simple clustering algorithm for small datasets
* @param markers Array of photo markers to cluster
* @param zoom Current zoom level
* @returns Array of cluster points
*/
export function clusterMarkers(
markers: PhotoMarker[],
zoom: number,
): ClusterPoint[] {
if (markers.length === 0) return []
// At high zoom levels, don't cluster
if (zoom >= 15) {
return markers.map((marker) => ({
type: 'Feature' as const,
properties: { marker },
geometry: {
type: 'Point' as const,
coordinates: [marker.longitude, marker.latitude],
},
}))
}
const clusters: ClusterPoint[] = []
const processed = new Set<string>()
// Simple distance-based clustering
const threshold = Math.max(0.001, 0.01 / Math.pow(2, zoom - 10)) // Adjust threshold based on zoom
for (const marker of markers) {
if (processed.has(marker.id)) continue
const nearby = [marker]
processed.add(marker.id)
// Find nearby markers
for (const other of markers) {
if (processed.has(other.id)) continue
const distance = Math.sqrt(
Math.pow(marker.longitude - other.longitude, 2) +
Math.pow(marker.latitude - other.latitude, 2),
)
if (distance < threshold) {
nearby.push(other)
processed.add(other.id)
}
}
if (nearby.length === 1) {
// Single marker
clusters.push({
type: 'Feature',
properties: { marker },
geometry: {
type: 'Point',
coordinates: [marker.longitude, marker.latitude],
},
})
} else {
// Cluster
const centerLng =
nearby.reduce((sum, m) => sum + m.longitude, 0) / nearby.length
const centerLat =
nearby.reduce((sum, m) => sum + m.latitude, 0) / nearby.length
clusters.push({
type: 'Feature',
properties: {
cluster: true,
point_count: nearby.length,
point_count_abbreviated: nearby.length.toString(),
marker: nearby[0], // Representative marker for the cluster
clusteredPhotos: nearby, // All photos in the cluster
},
geometry: {
type: 'Point',
coordinates: [centerLng, centerLat],
},
})
}
}
return clusters
}

View File

@@ -0,0 +1,6 @@
export * from './clustering'
export * from './ClusterMarker'
export * from './GeoJsonLayer'
export * from './MapControls'
export * from './PhotoMarkerPin'
export * from './types'

View File

@@ -0,0 +1,71 @@
import type { PhotoMarker } from '~/types/map'
// LayerProps type definition for compatibility
export interface LayerProps {
id: string
type: 'fill' | 'line' | 'symbol' | 'circle' | 'raster' | 'fill-extrusion'
paint?: Record<string, any>
layout?: Record<string, any>
}
// Clustering utilities
export interface ClusterPoint {
type: 'Feature'
properties: {
cluster?: boolean
cluster_id?: number
point_count?: number
point_count_abbreviated?: string
marker?: PhotoMarker
clusteredPhotos?: PhotoMarker[]
}
geometry: {
type: 'Point'
coordinates: [number, number]
}
}
// Common interfaces
export interface GeoJsonLayerProps {
data: GeoJSON.FeatureCollection
layerStyle?: LayerProps
}
export interface MapControlsProps {
onGeolocate?: (longitude: number, latitude: number) => void
}
export interface PhotoMarkerPinProps {
marker: PhotoMarker
isSelected?: boolean
onClick?: (marker: PhotoMarker) => void
onClose?: () => void
}
export interface ClusterMarkerProps {
longitude: number
latitude: number
pointCount: number
representativeMarker?: PhotoMarker
clusteredPhotos?: PhotoMarker[]
onClusterClick?: (longitude: number, latitude: number) => void
}
// Default values
export const DEFAULT_VIEW_STATE = {
longitude: -122.4,
latitude: 37.8,
zoom: 14,
}
export const DEFAULT_MARKERS: PhotoMarker[] = []
export const DEFAULT_STYLE = { width: '100%', height: '100%' }
export const DEFAULT_LAYER_STYLE: LayerProps = {
id: 'data',
type: 'fill',
paint: {
'fill-color': '#0080ff',
'fill-opacity': 0.5,
},
}

View File

@@ -0,0 +1,206 @@
import type { PhotoManifestItem } from '@afilmory/builder'
import type {
GPSCoordinates,
MapBounds,
MapViewState,
PhotoMarker,
} from '~/types/map'
import { GPSDirection } from '~/types/map'
/**
* 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?.GPSLatitude || !exif?.GPSLongitude) {
return null
}
// Convert GPS coordinates from EXIF format to decimal degrees
let latitude: number
let longitude: number
try {
// Handle different EXIF coordinate formats
if (typeof exif.GPSLatitude === 'number') {
latitude = exif.GPSLatitude
} else {
latitude = Number(exif.GPSLatitude)
}
if (typeof exif.GPSLongitude === 'number') {
longitude = exif.GPSLongitude
} else {
longitude = Number(exif.GPSLongitude)
}
// Get GPS direction references
const latitudeRef =
exif.GPSLatitudeRef === 'S' || exif.GPSLatitudeRef === 'South'
? GPSDirection.South
: GPSDirection.North
const longitudeRef =
exif.GPSLongitudeRef === 'W' || exif.GPSLongitudeRef === 'West'
? GPSDirection.West
: GPSDirection.East
// Apply reference direction to coordinates
if (latitudeRef === GPSDirection.South) {
latitude = -latitude
}
if (longitudeRef === GPSDirection.West) {
longitude = -longitude
}
// Process altitude information
let altitude: number | undefined
let altitudeRef: 'Above Sea Level' | 'Below Sea Level' | undefined
if (exif.GPSAltitude && typeof exif.GPSAltitude === 'number') {
altitude = exif.GPSAltitude
altitudeRef =
exif.GPSAltitudeRef === 'Below Sea Level'
? 'Below Sea Level'
: 'Above Sea Level'
// Apply altitude reference
if (altitudeRef === 'Below Sea Level') {
altitude = -altitude
}
}
// Validate coordinates
if (
Number.isNaN(latitude) ||
Number.isNaN(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
return null
}
return {
id: photo.id,
longitude,
latitude,
altitude,
latitudeRef,
longitudeRef,
altitudeRef,
photo,
}
} catch (error) {
console.warn(
`Failed to parse GPS coordinates for photo ${photo.id}:`,
error,
)
return null
}
}
/**
* Convert array of PhotoManifestItem to PhotoMarker array using EXIF data
*/
export function convertPhotosToMarkersFromEXIF(
photos: PhotoManifestItem[],
): PhotoMarker[] {
return photos
.map((photo) => convertPhotoToMarkerFromEXIF(photo))
.filter((marker): marker is PhotoMarker => marker !== null)
}
/**
* Calculate the bounds and center point for a set of markers
*/
export function calculateMapBounds(markers: PhotoMarker[]): MapBounds | null {
if (markers.length === 0) {
return null
}
const latitudes = markers.map((m) => m.latitude)
const longitudes = markers.map((m) => m.longitude)
const minLat = Math.min(...latitudes)
const maxLat = Math.max(...latitudes)
const minLng = Math.min(...longitudes)
const maxLng = Math.max(...longitudes)
return {
minLat,
maxLat,
minLng,
maxLng,
centerLat: (minLat + maxLat) / 2,
centerLng: (minLng + maxLng) / 2,
bounds: [
[minLng, minLat], // Southwest coordinates
[maxLng, maxLat], // Northeast coordinates
] as [[number, number], [number, number]],
}
}
/**
* Get initial view state that fits all markers
*/
export function getInitialViewStateForMarkers(
markers: PhotoMarker[],
): MapViewState {
const bounds = calculateMapBounds(markers)
if (!bounds) {
// Default view if no markers
return {
longitude: -122.4,
latitude: 37.8,
zoom: 10,
}
}
// Calculate zoom level based on bounds
const latDiff = bounds.maxLat - bounds.minLat
const lngDiff = bounds.maxLng - bounds.minLng
const maxDiff = Math.max(latDiff, lngDiff)
let zoom = 10
if (maxDiff < 0.01) zoom = 15
else if (maxDiff < 0.1) zoom = 12
else if (maxDiff < 1) zoom = 8
else if (maxDiff < 10) zoom = 5
else zoom = 2
return {
longitude: bounds.centerLng,
latitude: bounds.centerLat,
zoom,
}
}

View File

@@ -2,6 +2,7 @@ import { photoLoader } from '@afilmory/data'
import { useAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { Drawer } from 'vaul'
import { gallerySettingAtom } from '~/atoms/app'
@@ -311,9 +312,21 @@ const ResponsiveActionButton = ({
export const ActionGroup = () => {
const { t } = useTranslation()
const [gallerySetting] = useAtom(gallerySettingAtom)
const navigate = useNavigate()
return (
<div className="flex items-center justify-center gap-3">
{/* 地图探索按钮 */}
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/explory')}
className="h-10 w-10 rounded-full border-0 bg-gray-100 transition-all duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
title={t('action.map.explore')}
>
<i className="i-mingcute-map-pin-line text-base text-gray-600 dark:text-gray-300" />
</Button>
{/* 标签筛选按钮 */}
<ResponsiveActionButton
icon="i-mingcute-tag-line"

View File

@@ -0,0 +1,120 @@
import * as React from 'react'
import { lazy } from 'react'
import type { MapRef } from 'react-map-gl/maplibre'
import type { BaseMapProps, PhotoMarker } from '~/types/map'
import type { MapAdapter } from './MapProvider'
const Maplibre = lazy(() =>
import('~/components/ui/map/MapLibre').then((m) => ({ default: m.Maplibre })),
)
/**
* MapLibre map adapter implementation
* This adapts MapLibre to work with our generic map provider system
*/
export class MapLibreMapAdapter implements MapAdapter {
name = 'maplibre'
readonly isAvailable: boolean = true
MapComponent = MapLibreMapComponent
async initialize(): Promise<void> {
// MapLibre doesn't require additional async initialization
}
cleanup(): void {
// No cleanup needed for MapLibre
}
}
/**
* MapLibre map component that integrates with the Map Provider context
* This component reads configuration from the MapProvider context
*/
export const MapLibreMapComponent: React.FC<BaseMapProps> = ({
id,
initialViewState,
markers,
geoJsonData,
className,
style,
handlers,
autoFitBounds,
}) => {
const mapRef = React.useRef<MapRef>(null)
// Default map config constants
const DEFAULT_ANIMATION_DURATION = 1000
const DEFAULT_ZOOM = 14
// Handle GeoJSON click
const handleGeoJsonClick = React.useCallback(
(
event: maplibregl.MapMouseEvent & {
features?: maplibregl.GeoJSONFeature[]
},
) => {
if (!handlers?.onGeoJsonClick) return
const feature = event.features?.[0]
if (feature) {
handlers.onGeoJsonClick(feature as GeoJSON.Feature)
}
},
[handlers],
)
// Fly to location with animation duration from config
const flyToLocation = React.useCallback(
(longitude: number, latitude: number, zoom?: number) => {
mapRef.current?.flyTo({
center: [longitude, latitude],
duration: DEFAULT_ANIMATION_DURATION,
zoom: zoom || DEFAULT_ZOOM,
})
},
[], // No dependencies needed as constants don't change
)
// Handle marker click
const handleMarkerClick = React.useCallback(
(marker: PhotoMarker) => {
handlers?.onMarkerClick?.(marker)
},
[handlers],
)
// Handle geolocate
const handleGeolocate = React.useCallback(
(longitude: number, latitude: number) => {
flyToLocation(longitude, latitude)
handlers?.onGeolocate?.(longitude, latitude)
},
[flyToLocation, handlers],
)
return (
<Maplibre
id={id}
initialViewState={initialViewState}
markers={markers}
geoJsonData={geoJsonData}
onMarkerClick={handleMarkerClick}
onGeoJsonClick={handleGeoJsonClick}
onGeolocate={handleGeolocate}
className={className}
style={style}
mapRef={mapRef}
autoFitBounds={autoFitBounds}
/>
)
}
/**
* Create a MapLibre adapter instance
*/
export const createMapLibreAdapter = (): MapAdapter => {
return new MapLibreMapAdapter()
}

View File

@@ -0,0 +1,152 @@
import { siteConfig } from '@config'
import { createContext, use, useMemo } from 'react'
import type { BaseMapProps } from '~/types/map'
import { createMapLibreAdapter } from './MapLibreAdapter'
/**
* Defines the interface for a map adapter.
* This allows for different map providers to be used interchangeably.
*/
export interface MapAdapter {
name: string
isAvailable: boolean
initialize: () => Promise<void>
cleanup?: () => void
MapComponent: React.FC<BaseMapProps>
}
/**
* Context for providing the current map adapter.
*/
interface MapContextType {
adapter: MapAdapter | null
}
export const MapContext = createContext<MapContextType | null>(null)
/**
* Hook to get the current map adapter from the context.
*/
export const useMapAdapter = () => {
const context = use(MapContext)
if (!context) {
throw new Error('useMapAdapter must be used within a MapProvider')
}
return context.adapter
}
const maplibreAdapter = createMapLibreAdapter()
const ADAPTERS = [
{
name: 'maplibre',
adapter: maplibreAdapter,
component: maplibreAdapter.MapComponent,
},
]
/**
* Get the preferred map adapter based on configuration
*/
const getPreferredAdapter = () => {
const mapConfig = siteConfig.map
// If no map configuration is provided, use the first available adapter
if (!mapConfig) {
const adapter = ADAPTERS.find((a) => a.adapter.isAvailable) || null
if (adapter) {
console.info(`Map: Selected default adapter: ${adapter.name}`)
}
return adapter
}
// If mapConfig is a string (single provider)
if (typeof mapConfig === 'string') {
const adapter = ADAPTERS.find(
(a) => a.name === mapConfig && a.adapter.isAvailable,
)
if (adapter) {
console.info(`Map: Selected specified adapter: ${adapter.name}`)
return adapter
}
// If specified provider is not available, fall back to first available
const fallbackAdapter = ADAPTERS.find((a) => a.adapter.isAvailable) || null
if (fallbackAdapter) {
console.info(
`Map: Specified adapter '${mapConfig}' not available, using fallback: ${fallbackAdapter.name}`,
)
}
return fallbackAdapter
}
// If mapConfig is an array (priority list)
if (Array.isArray(mapConfig)) {
for (const providerName of mapConfig) {
const adapter = ADAPTERS.find(
(a) => a.name === providerName && a.adapter.isAvailable,
)
if (adapter) {
console.info(
`Map: Selected adapter from priority list: ${adapter.name}`,
)
return adapter
}
}
// If none of the priority providers are available, use first available
const fallbackAdapter = ADAPTERS.find((a) => a.adapter.isAvailable) || null
if (fallbackAdapter) {
console.info(
`Map: None of the priority providers available, using fallback: ${fallbackAdapter.name}`,
)
}
return fallbackAdapter
}
// Default to first available adapter
const adapter = ADAPTERS.find((a) => a.adapter.isAvailable) || null
if (adapter) {
console.info(`Map: Selected default adapter: ${adapter.name}`)
} else {
console.warn('Map: No adapters are available')
}
return adapter
}
export const MapProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const adapter = useMemo(() => {
const preferredAdapter = getPreferredAdapter()
if (preferredAdapter) {
return {
...preferredAdapter.adapter,
MapComponent: preferredAdapter.component,
}
}
return null
}, [])
const value = useMemo(() => ({ adapter }), [adapter])
return <MapContext value={value}>{children}</MapContext>
}
/**
* Utility function to get information about all available map adapters
* Useful for debugging and diagnostics
*/
export const getMapAdapterInfo = () => {
return ADAPTERS.map((adapter) => ({
name: adapter.name,
isAvailable: adapter.adapter.isAvailable,
adapterName: adapter.adapter.name,
}))
}
/**
* Get the current map configuration from site config
*/
export const getMapConfig = () => siteConfig.map

View File

@@ -0,0 +1,118 @@
import { photoLoader } from '@afilmory/data'
import { m } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
GenericMap,
MapBackButton,
MapInfoPanel,
MapLoadingState,
} from '~/components/ui/map'
import {
calculateMapBounds,
convertPhotosToMarkersFromEXIF,
getInitialViewStateForMarkers,
} from '~/lib/map-utils'
import { MapProvider } from '~/modules/map/MapProvider'
import type { MapBounds, PhotoMarker } from '~/types/map'
export const MapSection = () => {
return (
<MapProvider>
<MapSectionContent />
</MapProvider>
)
}
const MapSectionContent = () => {
const { t } = useTranslation()
// Photo markers state and loading logic
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [markers, setMarkers] = useState<PhotoMarker[]>([])
// Calculate bounds from markers
const bounds = useMemo<MapBounds | null>(() => {
if (markers.length === 0) return null
return calculateMapBounds(markers)
}, [markers])
// Load photo markers effect
useEffect(() => {
const loadPhotoMarkersData = async () => {
setIsLoading(true)
setError(null)
try {
const photos = photoLoader.getPhotos()
const photoMarkers = convertPhotosToMarkersFromEXIF(photos)
setMarkers(photoMarkers)
console.info(`Found ${photoMarkers.length} photos with GPS coordinates`)
} catch (err) {
const error =
err instanceof Error ? err : new Error('Failed to load photo markers')
setError(error)
console.error('Failed to load photo markers:', error)
} finally {
setIsLoading(false)
}
}
loadPhotoMarkersData()
}, [setMarkers])
// Initial view state calculation
const initialViewState = useMemo(
() => getInitialViewStateForMarkers(markers),
[markers],
)
// Show loading state
if (isLoading) {
return <MapLoadingState />
}
// Show error state
if (error) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="mb-4 text-4xl"></div>
<div className="text-lg font-medium text-red-900 dark:text-red-100">
{t('explory.map.error.title')}
</div>
<p className="text-sm text-red-600 dark:text-red-400">
{t('explory.map.error.description')}
</p>
</div>
</div>
)
}
return (
<div className="relative h-full w-full">
{/* Back button */}
<MapBackButton />
{/* Map info panel */}
<MapInfoPanel markersCount={markers.length} bounds={bounds} />
{/* Generic Map component */}
<m.div
initial={{ opacity: 0, scale: 1.02 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="h-full w-full"
>
<GenericMap
markers={markers}
initialViewState={initialViewState}
className="h-full w-full"
/>
</m.div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { photoLoader } from '@afilmory/data'
import { siteConfig } from '@config'
import { useAtomValue, useSetAtom } from 'jotai'
// import { AnimatePresence } from 'motion/react'
import { useEffect, useRef } from 'react'
import {
Outlet,
@@ -21,24 +21,11 @@ export const Component = () => {
useStateRestoreFromUrl()
useSyncStateToUrl()
// const location = useLocation()
const isMobile = useMobile()
return (
<>
{siteConfig.accentColor && (
<style
dangerouslySetInnerHTML={{
__html: `
:root:has(input.theme-controller[value=dark]:checked), [data-theme="dark"] {
--color-primary: ${siteConfig.accentColor};
--color-accent: ${siteConfig.accentColor};
--color-secondary: ${siteConfig.accentColor};
}
`,
}}
/>
)}
{isMobile ? (
<ScrollElementContext value={document.body}>
<MasonryRoot />
@@ -102,15 +89,19 @@ const useSyncStateToUrl = () => {
if (!isRestored) return
if (!isOpen) {
const timer = setTimeout(() => {
navigate('/')
}, 500)
return () => clearTimeout(timer)
}
const photos = photoLoader.getPhotos()
const targetPathname = `/${photos[currentIndex].id}`
if (location.pathname !== targetPathname) {
navigate(targetPathname)
const isExploryPath = location.pathname === '/explory'
if (!isExploryPath) {
const timer = setTimeout(() => {
navigate('/')
}, 500)
return () => clearTimeout(timer)
}
} else {
const photos = photoLoader.getPhotos()
const targetPathname = `/${photos[currentIndex].id}`
if (location.pathname !== targetPathname) {
navigate(targetPathname)
}
}
}, [currentIndex, isOpen, location.pathname, navigate])

View File

@@ -0,0 +1,96 @@
import { m } from 'motion/react'
import { lazy, Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
const MapSection = lazy(() =>
import('~/modules/map/MapSection').then((m) => ({ default: m.MapSection })),
)
export const Component = () => {
return (
<Suspense fallback={<ExploryPageSkeleton />}>
<ErrorBoundary fallback={<ExploryPageError />}>
<MapSection />
</ErrorBoundary>
</Suspense>
)
}
const ExploryPageSkeleton = () => {
const { t } = useTranslation()
return (
<m.div
className="flex h-full w-full items-center justify-center"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<div className="text-center">
<m.div
className="mb-4 text-4xl"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
📍
</m.div>
<m.div
className="text-lg font-medium text-gray-900 dark:text-gray-100"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
{t('explory.loading.map')}
</m.div>
<m.p
className="text-sm text-gray-600 dark:text-gray-400"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
{t('explory.parsing.location')}
</m.p>
</div>
</m.div>
)
}
const ExploryPageError = () => {
return (
<m.div
className="flex h-full w-full items-center justify-center"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="text-center">
<m.div
className="mb-4 text-4xl"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
</m.div>
<m.div
className="text-lg font-medium text-red-900 dark:text-red-100"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
</m.div>
<m.p
className="text-sm text-red-600 dark:text-red-400"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
</m.p>
</div>
</m.div>
)
}

View File

@@ -0,0 +1,23 @@
import siteConfig from '@config'
import { Outlet } from 'react-router'
export const Component = () => {
return (
<>
{siteConfig.accentColor && (
<style
dangerouslySetInnerHTML={{
__html: `
:root:has(input.theme-controller[value=dark]:checked), [data-theme="dark"] {
--color-primary: ${siteConfig.accentColor};
--color-accent: ${siteConfig.accentColor};
--color-secondary: ${siteConfig.accentColor};
}
`,
}}
/>
)}
<Outlet />
</>
)
}

View File

@@ -8,12 +8,6 @@ body {
@apply grow h-0 shrink-0 overflow-auto relative;
}
@layer utilities {
* {
user-select: none;
}
}
@media screen and (min-width: 1024px) {
::-webkit-scrollbar-thumb {
background-color: transparent;

View File

@@ -0,0 +1,62 @@
import type { PhotoManifestItem } from '@afilmory/builder'
export * from './provider'
/**
* GPS Cardinal directions enum
*/
export enum GPSDirection {
North = 'N',
South = 'S',
East = 'E',
West = 'W',
}
/**
* Enhanced GPS coordinates interface with altitude and direction
*/
export interface GPSCoordinates {
latitude: number
longitude: number
altitude?: number
latitudeRef?: GPSDirection.North | GPSDirection.South
longitudeRef?: GPSDirection.East | GPSDirection.West
altitudeRef?: 'Above Sea Level' | 'Below Sea Level'
}
/**
* Photo marker interface for map display
*/
export interface PhotoMarker {
id: string
longitude: number
latitude: number
altitude?: number
latitudeRef?: GPSDirection.North | GPSDirection.South
longitudeRef?: GPSDirection.East | GPSDirection.West
altitudeRef?: 'Above Sea Level' | 'Below Sea Level'
photo: PhotoManifestItem
}
/**
* Map bounds interface
*/
export interface MapBounds {
minLat: number
maxLat: number
minLng: number
maxLng: number
centerLat: number
centerLng: number
bounds: [[number, number], [number, number]]
}
/**
* Map view state interface
*/
export interface MapViewState {
longitude: number
latitude: number
zoom: number
}

View File

@@ -0,0 +1,78 @@
import type { ReactNode } from 'react'
import type { MapBounds, MapViewState, PhotoMarker } from './index'
/**
* Generic map provider interface
*/
export interface MapProvider {
/** The display name of the map provider */
name: string
/** Whether the provider is available (e.g., has valid API keys) */
isAvailable: boolean
/** Initialize the provider */
initialize: () => Promise<void>
/** Clean up the provider */
cleanup?: () => void
}
/**
* Map interaction handlers
*/
export interface MapHandlers {
onMarkerClick?: (marker: PhotoMarker) => void
onGeoJsonClick?: (feature: GeoJSON.Feature) => void
onGeolocate?: (longitude: number, latitude: number) => void
onPopupClose?: () => void
}
/**
* Map component props that any provider implementation should support
*/
export interface BaseMapProps {
id?: string
initialViewState?: MapViewState
markers?: PhotoMarker[]
geoJsonData?: GeoJSON.FeatureCollection
className?: string
style?: React.CSSProperties
theme?: 'light' | 'dark'
showGeocoder?: boolean
handlers?: MapHandlers
autoFitBounds?: boolean
}
/**
* Context value for map provider
*/
export interface MapContextValue {
/** Current map provider */
provider: MapProvider | null
/** Available providers */
providers: MapProvider[]
/** Switch to a different provider */
switchProvider: (providerName: string) => Promise<void>
/** Current map bounds */
bounds: MapBounds | null
/** Selected marker */
selectedMarker: PhotoMarker | null
/** Set selected marker */
setSelectedMarker: (marker: PhotoMarker | null) => void
/** Fly to location */
flyToLocation: (longitude: number, latitude: number) => void
/** Show popup info */
popupInfo: {
marker: PhotoMarker
longitude: number
latitude: number
} | null
/** Set popup info */
setPopupInfo: (
info: { marker: PhotoMarker; longitude: number; latitude: number } | null,
) => void
}
/**
* Map component interface that providers must implement
*/
export type MapComponent = (props: BaseMapProps) => ReactNode

View File

@@ -8,5 +8,8 @@
"name": "Innei",
"url": "https://innei.in/",
"avatar": "https://cdn.jsdelivr.net/gh/Innei/static@master/avatar.png"
}
},
"map": [
"maplibre"
]
}

View File

@@ -1,6 +1,7 @@
{
"action.auto": "Auto",
"action.columns.setting": "Column Settings",
"action.map.explore": "Map Explore",
"action.sort.mode": "Sort Mode",
"action.tag.filter": "Tag Filter",
"action.view.github": "View GitHub Repository",
@@ -206,6 +207,16 @@
"exif.white.balance.shift.ab": "White Balance Shift (Amber-Blue)",
"exif.white.balance.shift.gm": "White Balance Shift (Green-Magenta)",
"exif.white.balance.title": "White Balance",
"explory.back.to.gallery": "Back to Gallery",
"explory.clear.selection": "Clear Selection",
"explory.explore.map": "Explore Map",
"explory.found.locations": "Found {{count}} shooting locations",
"explory.loading.map": "Loading Map...",
"explory.map.error.description": "Please check your network connection or refresh the page to try again",
"explory.map.error.title": "Map Loading Failed",
"explory.parsing.location": "Parsing photo location information",
"explory.range.separator": "to",
"explory.shooting.range": "Shooting Range:",
"gallery.built.at": "Built at ",
"gallery.photos_one": "{{count}} photo",
"gallery.photos_other": "{{count}} photos",

View File

@@ -1,6 +1,7 @@
{
"action.auto": "自動",
"action.columns.setting": "列設定",
"action.map.explore": "マップ探索",
"action.sort.mode": "ソートモード",
"action.tag.filter": "タグフィルター",
"action.view.github": "GitHub リポジトリを表示",
@@ -205,6 +206,16 @@
"exif.white.balance.shift.ab": "ホワイトバランス補正 (アンバー-ブルー)",
"exif.white.balance.shift.gm": "ホワイトバランス補正 (グリーン - マゼンタ)",
"exif.white.balance.title": "ホワイトバランス",
"explory.back.to.gallery": "ギャラリーに戻る",
"explory.clear.selection": "選択をクリア",
"explory.explore.map": "マップを探索",
"explory.found.locations": "{{count}} 個の撮影場所を発見",
"explory.loading.map": "マップを読み込み中...",
"explory.map.error.description": "ネットワーク接続を確認するか、ページを更新して再試行してください",
"explory.map.error.title": "マップの読み込みに失敗しました",
"explory.parsing.location": "写真の位置情報を解析中",
"explory.range.separator": "から",
"explory.shooting.range": "撮影範囲:",
"gallery.built.at": "ビルド日時 ",
"gallery.photos_one": "写真{{count}}枚",
"gallery.photos_other": "写真{{count}}枚",

View File

@@ -1,6 +1,7 @@
{
"action.auto": "자동",
"action.columns.setting": "열 설정",
"action.map.explore": "지도 탐색",
"action.sort.mode": "정렬 모드",
"action.tag.filter": "태그 필터",
"action.view.github": "GitHub 리포지토리 보기",
@@ -205,6 +206,16 @@
"exif.white.balance.shift.ab": "화이트 밸런스 보정 (앰버 - 블루)",
"exif.white.balance.shift.gm": "화이트 밸런스 보정 (그린 - 마젠타)",
"exif.white.balance.title": "화이트 밸런스",
"explory.back.to.gallery": "갤러리로 돌아가기",
"explory.clear.selection": "선택 해제",
"explory.explore.map": "지도 탐색",
"explory.found.locations": "{{count}}개의 촬영 위치 발견",
"explory.loading.map": "지도 로딩 중...",
"explory.map.error.description": "네트워크 연결을 확인하거나 페이지를 새로고침하여 다시 시도하십시오.",
"explory.map.error.title": "지도 로딩 실패",
"explory.parsing.location": "사진 위치 정보 분석 중",
"explory.range.separator": "~",
"explory.shooting.range": "촬영 범위:",
"gallery.built.at": "빌드 날짜 ",
"gallery.photos_one": "사진 {{count}}장",
"gallery.photos_other": "사진 {{count}}장",

View File

@@ -1,6 +1,7 @@
{
"action.auto": "自动",
"action.columns.setting": "列设置",
"action.map.explore": "地图探索",
"action.sort.mode": "排序模式",
"action.tag.filter": "标签筛选",
"action.view.github": "查看 GitHub 仓库",
@@ -206,6 +207,16 @@
"exif.white.balance.shift.ab": "白平衡偏移 (琥珀色 - 蓝色)",
"exif.white.balance.shift.gm": "白平衡偏移 (绿色 - 品红色)",
"exif.white.balance.title": "白平衡",
"explory.back.to.gallery": "返回相册",
"explory.clear.selection": "清除选择",
"explory.explore.map": "探索地图",
"explory.found.locations": "发现了 {{count}} 个拍摄位置",
"explory.loading.map": "加载地图中...",
"explory.map.error.description": "请检查网络连接或刷新页面重试",
"explory.map.error.title": "地图加载失败",
"explory.parsing.location": "正在解析照片位置信息",
"explory.range.separator": "至",
"explory.shooting.range": "拍摄范围:",
"gallery.built.at": "构建于 ",
"gallery.photos_one": "{{count}} 张照片",
"gallery.photos_other": "{{count}} 张照片",

View File

@@ -1,6 +1,7 @@
{
"action.auto": "自動",
"action.columns.setting": "欄位設定",
"action.map.explore": "地圖探索",
"action.sort.mode": "排序模式",
"action.tag.filter": "標籤篩選",
"action.view.github": "查看 GitHub 倉庫",
@@ -205,6 +206,16 @@
"exif.white.balance.shift.ab": "白平衡偏移 (琥珀色 - 藍色)",
"exif.white.balance.shift.gm": "白平衡偏移 (綠色 - 洋紅色)",
"exif.white.balance.title": "白平衡",
"explory.back.to.gallery": "返回相簿",
"explory.clear.selection": "清除選擇",
"explory.explore.map": "探索地圖",
"explory.found.locations": "發現了 {{count}} 個拍攝位置",
"explory.loading.map": "載入地圖中...",
"explory.map.error.description": "請檢查網絡連接或刷新頁面重試",
"explory.map.error.title": "地圖載入失敗",
"explory.parsing.location": "正在解析照片位置信息",
"explory.range.separator": "至",
"explory.shooting.range": "拍攝範圍:",
"gallery.built.at": "建置於 ",
"gallery.photos_one": "{{count}} 張照片",
"gallery.photos_other": "{{count}} 張照片",

View File

@@ -1,6 +1,7 @@
{
"action.auto": "自動",
"action.columns.setting": "欄位設定",
"action.map.explore": "地圖探索",
"action.sort.mode": "排序模式",
"action.tag.filter": "標籤篩選",
"action.view.github": "檢視 GitHub 存放庫",
@@ -205,6 +206,16 @@
"exif.white.balance.shift.ab": "白平衡偏移 (琥珀色 - 藍色)",
"exif.white.balance.shift.gm": "白平衡偏移 (綠色 - 洋紅色)",
"exif.white.balance.title": "白平衡",
"explory.back.to.gallery": "返回相簿",
"explory.clear.selection": "清除選擇",
"explory.explore.map": "探索地圖",
"explory.found.locations": "發現了 {{count}} 個拍攝位置",
"explory.loading.map": "載入地圖中...",
"explory.map.error.description": "請檢查網路連線或重新整理頁面重試",
"explory.map.error.title": "地圖載入失敗",
"explory.parsing.location": "正在解析照片位置資訊",
"explory.range.separator": "至",
"explory.shooting.range": "拍攝範圍:",
"gallery.built.at": "建置於 ",
"gallery.photos_one": "{{count}} 張照片",
"gallery.photos_other": "{{count}} 張照片",

830
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,16 @@ export interface SiteConfig {
author: Author
social?: Social
feed?: Feed
map?: MapConfig
}
/**
* Map configuration - can be either:
* - A string for a single provider: 'maplibre'
* - An array for multiple providers in priority order: ['maplibre']
*/
type MapConfig = 'maplibre'[]
interface Feed {
folo?: {
challenge?: {