mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ apps/web/assets-git
|
||||
apps/web/public/thumbnails
|
||||
apps/web/src/data/photos-manifest.json
|
||||
.vercel
|
||||
apps/ssr/.next
|
||||
|
||||
@@ -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 后自动跳转到首页
|
||||
|
||||
所以关闭图片浏览器时自动回到首页的逻辑就在这里。如果你不希望这个行为,可以修改或移除这部分代码。
|
||||
|
||||
---
|
||||
|
||||
@@ -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. **定位计算**:确保三角形的位置计算正确
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
47
apps/web/src/components/ui/button/GlassButton.tsx
Normal file
47
apps/web/src/components/ui/button/GlassButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
apps/web/src/components/ui/hover-card/index.tsx
Normal file
53
apps/web/src/components/ui/hover-card/index.tsx
Normal 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 }
|
||||
91
apps/web/src/components/ui/lazy-image/index.tsx
Normal file
91
apps/web/src/components/ui/lazy-image/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
BIN
apps/web/src/components/ui/map/1.png
Normal file
BIN
apps/web/src/components/ui/map/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
166
apps/web/src/components/ui/map/ClusterPhotoGrid.tsx
Normal file
166
apps/web/src/components/ui/map/ClusterPhotoGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
apps/web/src/components/ui/map/GenericMap.tsx
Normal file
69
apps/web/src/components/ui/map/GenericMap.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
26
apps/web/src/components/ui/map/MapBackButton.tsx
Normal file
26
apps/web/src/components/ui/map/MapBackButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
apps/web/src/components/ui/map/MapInfoPanel.tsx
Normal file
160
apps/web/src/components/ui/map/MapInfoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
apps/web/src/components/ui/map/MapLibre.tsx
Normal file
248
apps/web/src/components/ui/map/MapLibre.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2837
apps/web/src/components/ui/map/MapLibreStyle.json
Normal file
2837
apps/web/src/components/ui/map/MapLibreStyle.json
Normal file
File diff suppressed because it is too large
Load Diff
42
apps/web/src/components/ui/map/MapLoadingState.tsx
Normal file
42
apps/web/src/components/ui/map/MapLoadingState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
apps/web/src/components/ui/map/index.ts
Normal file
5
apps/web/src/components/ui/map/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './GenericMap'
|
||||
export * from './MapBackButton'
|
||||
export * from './MapInfoPanel'
|
||||
export * from './MapLoadingState'
|
||||
export * from './shared'
|
||||
132
apps/web/src/components/ui/map/shared/ClusterMarker.tsx
Normal file
132
apps/web/src/components/ui/map/shared/ClusterMarker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/web/src/components/ui/map/shared/GeoJsonLayer.tsx
Normal file
23
apps/web/src/components/ui/map/shared/GeoJsonLayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
apps/web/src/components/ui/map/shared/MapControls.tsx
Normal file
113
apps/web/src/components/ui/map/shared/MapControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
286
apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx
Normal file
286
apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
apps/web/src/components/ui/map/shared/clustering.ts
Normal file
91
apps/web/src/components/ui/map/shared/clustering.ts
Normal 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
|
||||
}
|
||||
6
apps/web/src/components/ui/map/shared/index.ts
Normal file
6
apps/web/src/components/ui/map/shared/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './clustering'
|
||||
export * from './ClusterMarker'
|
||||
export * from './GeoJsonLayer'
|
||||
export * from './MapControls'
|
||||
export * from './PhotoMarkerPin'
|
||||
export * from './types'
|
||||
71
apps/web/src/components/ui/map/shared/types.ts
Normal file
71
apps/web/src/components/ui/map/shared/types.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
206
apps/web/src/lib/map-utils.ts
Normal file
206
apps/web/src/lib/map-utils.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
120
apps/web/src/modules/map/MapLibreAdapter.tsx
Normal file
120
apps/web/src/modules/map/MapLibreAdapter.tsx
Normal 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()
|
||||
}
|
||||
152
apps/web/src/modules/map/MapProvider.tsx
Normal file
152
apps/web/src/modules/map/MapProvider.tsx
Normal 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
|
||||
118
apps/web/src/modules/map/MapSection.tsx
Normal file
118
apps/web/src/modules/map/MapSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
|
||||
96
apps/web/src/pages/explory/index.tsx
Normal file
96
apps/web/src/pages/explory/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/web/src/pages/layout.tsx
Normal file
23
apps/web/src/pages/layout.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
62
apps/web/src/types/map/index.ts
Normal file
62
apps/web/src/types/map/index.ts
Normal 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
|
||||
}
|
||||
78
apps/web/src/types/map/provider.ts
Normal file
78
apps/web/src/types/map/provider.ts
Normal 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
|
||||
@@ -8,5 +8,8 @@
|
||||
"name": "Innei",
|
||||
"url": "https://innei.in/",
|
||||
"avatar": "https://cdn.jsdelivr.net/gh/Innei/static@master/avatar.png"
|
||||
}
|
||||
},
|
||||
"map": [
|
||||
"maplibre"
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}枚",
|
||||
|
||||
@@ -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}}장",
|
||||
|
||||
@@ -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}} 张照片",
|
||||
|
||||
@@ -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}} 張照片",
|
||||
|
||||
@@ -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
830
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user