react-phone-apps/apps/property-survey/src/features/map/MapMarker.tsx
lanrtop eab08d8c96 feat: 新增物品分类管理与第四楼支持
- 新增「设置」Tab,支持物品分类的增删改(数据库持久化)
- 物品分类由硬编码枚举迁移为动态分类,通过 CategoriesContext 全局共享
- items 表新增 unit(单位)、spec(规格)字段,附带旧库迁移
- markers 表 CHECK 约束扩展支持第 4 楼(楼顶),附带重建表迁移
- SVG 地图渲染:overdraw 预渲染提升清晰度,嵌入图片改用原生 Image 渲染
- FoldableLayout 改为 showDetail prop 控制动画宽度

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:12:52 +09:00

180 lines
4.6 KiB
TypeScript

import { useEffect } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated'
import { Ionicons } from '@expo/vector-icons'
import type { Marker } from '@repo/types'
import type { MapSize } from './FloorMap'
const ICON_SIZE = 20
const BADGE_OFFSET = -5
const RING_SIZE = 38
interface MapMarkerProps {
marker: Marker
mapSize: MapSize
mapScale: SharedValue<number>
itemCount?: number
isSelected?: boolean
onPress: () => void
onDragEnd: (x: number, y: number) => void
}
export function MapMarker({ marker, mapSize, mapScale, itemCount = 0, isSelected = false, onPress, onDragEnd }: MapMarkerProps) {
const left = marker.x * mapSize.width - ICON_SIZE / 2
const top = marker.y * mapSize.height - ICON_SIZE
// 拖动状态
const offsetX = useSharedValue(0)
const offsetY = useSharedValue(0)
const dragScale = useSharedValue(1)
const isDragging = useSharedValue(false)
// 对焦脉冲动画
const pulseScale = useSharedValue(1)
const pulseOpacity = useSharedValue(0)
useEffect(() => {
if (isSelected) {
// 每次选中从初始状态重新开始脉冲
pulseScale.value = withRepeat(
withSequence(
withTiming(1, { duration: 0 }),
withTiming(2.4, { duration: 1100 }),
),
-1,
false,
)
pulseOpacity.value = withRepeat(
withSequence(
withTiming(0.7, { duration: 0 }),
withTiming(0, { duration: 1100 }),
),
-1,
false,
)
} else {
cancelAnimation(pulseScale)
cancelAnimation(pulseOpacity)
pulseOpacity.value = withTiming(0, { duration: 150 })
}
}, [isSelected])
const tap = Gesture.Tap().onEnd(() => {
runOnJS(onPress)()
})
const pan = Gesture.Pan()
.activateAfterLongPress(400)
.onStart(() => {
dragScale.value = withSpring(1.3)
isDragging.value = true
})
.onUpdate((e) => {
offsetX.value = e.translationX
offsetY.value = e.translationY
})
.onEnd((e) => {
const s = mapScale.value
const newX = Math.max(0, Math.min(1, marker.x + e.translationX / (mapSize.width * s)))
const newY = Math.max(0, Math.min(1, marker.y + e.translationY / (mapSize.height * s)))
runOnJS(onDragEnd)(newX, newY)
})
.onFinalize(() => {
dragScale.value = withSpring(1)
isDragging.value = false
offsetX.value = 0
offsetY.value = 0
})
const gesture = Gesture.Race(tap, pan)
const containerStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: offsetX.value },
{ translateY: offsetY.value },
{ scale: dragScale.value / mapScale.value },
],
zIndex: isDragging.value ? 999 : isSelected ? 10 : 1,
shadowColor: '#000',
shadowOpacity: isDragging.value ? 0.3 : 0,
shadowRadius: isDragging.value ? 8 : 0,
shadowOffset: { width: 0, height: 4 },
elevation: isDragging.value ? 8 : 0,
}))
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulseScale.value }],
opacity: pulseOpacity.value,
}))
const markerColor = marker.color ?? '#3B82F6'
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.container, { left, top }, containerStyle]}>
{/* 脉冲光环(选中时持续扩散) */}
<Animated.View
style={[
styles.pulseRing,
{ borderColor: markerColor },
pulseStyle,
]}
/>
<Ionicons name="location" size={ICON_SIZE} color={markerColor} />
{itemCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{itemCount}</Text>
</View>
)}
</Animated.View>
</GestureDetector>
)
}
const RING_OFFSET = -(RING_SIZE - ICON_SIZE) / 2 // 居中到图标中心
const styles = StyleSheet.create({
container: {
position: 'absolute',
},
pulseRing: {
position: 'absolute',
width: RING_SIZE,
height: RING_SIZE,
borderRadius: RING_SIZE / 2,
borderWidth: 2,
top: RING_OFFSET,
left: RING_OFFSET,
},
badge: {
position: 'absolute',
top: BADGE_OFFSET,
right: BADGE_OFFSET,
minWidth: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#EF4444',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 2,
},
badgeText: {
color: '#fff',
fontSize: 8,
fontWeight: '700',
},
})