From 9321b88df1539356a2a27da91b605d819239fa68 Mon Sep 17 00:00:00 2001 From: Tsuki Date: Sun, 20 Jul 2025 02:11:17 +0800 Subject: [PATCH] add: timeline --- CLAUDE.md | 71 ++ app/app-sidebar.tsx | 6 +- app/glsl/timeline/frag.glsl | 73 +- app/glsl/timeline/vert.glsl | 18 +- app/map-context.tsx | 16 + app/page.tsx | 28 +- app/timeline.tsx | 1336 +++++++++++++++++++++++++++++++--- components/map-component.tsx | 3 +- hooks/use-map-zoom.ts | 6 +- 9 files changed, 1432 insertions(+), 125 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fa4a8f8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- **Development server**: `npm run dev` - Starts Next.js development server on http://localhost:3000 +- **Build**: `npm run build` - Creates production build +- **Production server**: `npm run start` - Starts production server (requires build first) +- **Lint**: `npm run lint` - Runs Next.js ESLint checks + +## High-Level Architecture + +This is a Next.js 15 application built with React 19 that creates an interactive map visualization with a custom WebGL timeline component. + +### Core Architecture Components + +**Map System**: +- Uses MapLibre GL JS for interactive mapping +- `app/map-context.tsx` provides global map state management via React Context +- `components/map-component.tsx` handles map rendering and initialization +- Default map center: Singapore (103.851959, 1.290270) at zoom level 11 +- Custom map style from MapTiler API + +**Timeline Visualization**: +- `app/timeline.tsx` is a complex WebGL-powered timeline component using custom shaders +- Uses vesica piscis (lens-shaped) geometry rendered via WebGL2 +- Custom GLSL shaders in `app/glsl/timeline/` for vertex and fragment processing +- Supports interactive features: dragging, zooming, panning, custom time markers +- Dual-canvas architecture: WebGL canvas for vesica shapes, 2D canvas overlay for UI elements + +**UI Framework**: +- Tailwind CSS with Radix UI components +- Theme system with dark/light mode support via `components/theme-provider.tsx` +- shadcn/ui component library in `components/ui/` +- Sidebar layout using `components/ui/sidebar.tsx` + +### Key Technical Details + +**WebGL Timeline**: +- Renders vesica piscis shapes as timeline markers +- Supports high-DPI displays with proper pixel ratio handling +- Interactive controls for zoom (mouse wheel), pan (drag), and custom time selection +- Real-time shader uniform updates for responsive interactions + +**State Management**: +- Map state centralized in `MapProvider` context +- Timeline uses internal React state with refs for performance-critical interactions +- Custom hooks in `hooks/` for map location, zoom, timeline, and mobile detection + +**Styling**: +- Tailwind CSS v4 with PostCSS +- Custom animations via `tw-animate-css` +- Component styling via `class-variance-authority` and `clsx` + +**Build Configuration**: +- Next.js 15 with App Router +- Custom webpack config for GLSL file loading via `raw-loader` +- TypeScript with strict mode enabled +- Absolute imports using `@/*` path mapping + +### File Structure Notes + +- `app/` - Next.js App Router pages and components +- `components/` - Reusable React components +- `hooks/` - Custom React hooks +- `lib/` - Utility functions +- `types/` - TypeScript type definitions +- `public/` - Static assets + +The application combines modern web mapping with custom WebGL visualization to create an interactive timeline-driven map interface. \ No newline at end of file diff --git a/app/app-sidebar.tsx b/app/app-sidebar.tsx index 19625dc..6043402 100644 --- a/app/app-sidebar.tsx +++ b/app/app-sidebar.tsx @@ -47,7 +47,7 @@ const data = { export function AppSidebar({ ...props }: React.ComponentProps) { const { currentLocation, flyToLocation, isMapReady } = useMapLocation(); - const { currentZoom, zoomToLocation, zoomIn, zoomOut } = useMapZoom(); + const { zoomToLocation, zoomIn, zoomOut, mapState } = useMapZoom(); return ( @@ -79,8 +79,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
- zoomToLocation(value[0])} /> - {currentZoom} + zoomToLocation(value[0])} /> + {mapState.zoomLevel.toFixed(1)}
diff --git a/app/glsl/timeline/frag.glsl b/app/glsl/timeline/frag.glsl index cf20560..b97e5c3 100644 --- a/app/glsl/timeline/frag.glsl +++ b/app/glsl/timeline/frag.glsl @@ -2,17 +2,21 @@ precision mediump float; -layout(std140) uniform Uniforms { - float startDate; - float endDate; - float currentDate; +layout(std140)uniform Uniforms{ + float startTimestamp; // Unix 时间戳开始 + float endTimestamp; // Unix 时间戳结束 + float currentTimestamp; // 当前时间戳 float radius; float d; - float padding1; // 填充以对齐到8字节边界 + float timelineStartX; // 时间轴在屏幕上的开始X坐标 + float timelineEndX; // 时间轴在屏幕上的结束X坐标 + float padding1; // 填充以对齐到8字节边界 vec2 viewportSize; + float zoomLevel; // 当前缩放级别 + float panOffset; // 当前平移偏移 }; -struct Instant { +struct Instant{ vec2 position; vec4 color; }; @@ -21,23 +25,52 @@ in Instant i_instant; out vec4 FragColor; -float sdVesica(vec2 p, float r, float d) +float sdVesica(vec2 p,float r,float d) { - p = abs(p); - float b = sqrt(r*r-d*d); // can delay this sqrt by rewriting the comparison - return ((p.y-b)*d > p.x*b) ? length(p-vec2(0.0,b))*sign(d) - : length(p-vec2(-d,0.0))-r; + p=abs(p); + float b=sqrt(r*r-d*d);// can delay this sqrt by rewriting the comparison + return((p.y-b)*d>p.x*b)?length(p-vec2(0.,b))*sign(d) + :length(p-vec2(-d,0.))-r; } -void main() { - vec2 p = gl_FragCoord.xy - i_instant.position; - float sdf = sdVesica(p, radius, d); - - // 简化逻辑:内部完全不透明,外部丢弃 - if (sdf > 0.0) { +void main(){ + // 从实例数据中获取时间戳(存储在position.x中)和Y坐标(存储在position.y中) + float timestamp = i_instant.position.x; + float centerY = i_instant.position.y; + + // 计算时间戳在时间轴范围内的相对位置(0-1) + float timeProgress = (timestamp - startTimestamp) / (endTimestamp - startTimestamp); + + // 考虑缩放和平移,计算屏幕X坐标 + float timelineWidth = timelineEndX - timelineStartX; + float scaledTimelineWidth = timelineWidth * zoomLevel; + float screenX = timelineStartX + panOffset + timeProgress * scaledTimelineWidth; + + // 如果vesica在屏幕外,提前丢弃 + if (screenX < -radius*2.0 || screenX > viewportSize.x + radius*2.0) { discard; } - - // 测试:固定颜色确保能看到 - FragColor = vec4(1.0, 1.0, 1.0, 1.0); // 纯白色 + + // 计算vesica在屏幕上的实际位置 + vec2 vesicaCenter = vec2(screenX, centerY); + vec2 p = gl_FragCoord.xy - vesicaCenter; + float sdf = sdVesica(p, radius, d); + + // 使用fwidth计算像素梯度,实现自适应反锯齿 + float fw = fwidth(sdf); + + // 在高分屏下增强反锯齿效果 (可根据需要调整系数) + float aaWidth = max(fw, 0.5); // 确保最小反锯齿宽度 + + // 使用smoothstep创建平滑边缘,aaWidth控制反锯齿宽度 + float alpha = 1.0 - smoothstep(-aaWidth, aaWidth, sdf); + + // 如果完全透明就丢弃片段以提高性能 + if (alpha < 0.001) { + discard; + } + + // 使用实例颜色并应用计算出的alpha + vec3 color = i_instant.color.rgb; + FragColor = vec4(color, alpha * i_instant.color.a); } diff --git a/app/glsl/timeline/vert.glsl b/app/glsl/timeline/vert.glsl index cd4ae46..fa9b316 100644 --- a/app/glsl/timeline/vert.glsl +++ b/app/glsl/timeline/vert.glsl @@ -1,19 +1,19 @@ #version 300 es -layout(location = 0) in vec2 a_position; -layout(location = 1) in vec2 i_position; -layout(location = 2) in vec4 i_color; +layout(location=0)in vec2 a_position; +layout(location=1)in vec2 i_position; +layout(location=2)in vec4 i_color; -struct Instant { +struct Instant{ vec2 position; vec4 color; }; out Instant i_instant; -void main() { - i_instant.position = i_position; - i_instant.color = i_color; - - gl_Position = vec4(a_position, 0.0, 1.0); +void main(){ + i_instant.position=i_position; + i_instant.color=i_color; + + gl_Position=vec4(a_position,0.,1.); } diff --git a/app/map-context.tsx b/app/map-context.tsx index cd0313d..43df0af 100644 --- a/app/map-context.tsx +++ b/app/map-context.tsx @@ -6,6 +6,7 @@ import type { Map } from 'maplibre-gl' // 定义MapContext的类型 interface MapContextType { mapRef: React.RefObject + mapState: MapState setMap: (map: Map) => void flyTo: (options: { center: [number, number]; zoom: number; duration?: number }) => void zoomIn: () => void @@ -23,10 +24,24 @@ interface MapProviderProps { children: ReactNode } +interface MapState { + zoomLevel: number +} + // MapProvider组件 export function MapProvider({ children }: MapProviderProps) { const mapRef = useRef(null) const [isMapReady, setIsMapReady] = useState(false) + const [mapState, setMapState] = useState({ + zoomLevel: 11 + }); + + mapRef.current?.on('zoom', () => { + setMapState(prevState => ({ + ...prevState, + zoomLevel: mapRef.current?.getZoom() || 11 + })); + }); const setMap = (map: Map) => { mapRef.current = map; @@ -72,6 +87,7 @@ export function MapProvider({ children }: MapProviderProps) { const value: MapContextType = { mapRef, + mapState, setMap, flyTo, zoomIn, diff --git a/app/page.tsx b/app/page.tsx index 1b3e7cf..300a25e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -34,6 +34,7 @@ import { Play, Pause } from "lucide-react" +import { cn } from '@/lib/utils'; export default function Page() { @@ -46,6 +47,12 @@ export default function Page() { { icon: Settings, label: "Settings" } ] + // 创建默认时间范围(过去7天到未来3天) + const now = new Date(); + const startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7天前 + const endDate = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000); // 3天后 + const currentDate = now; + const containerRef = useRef(null); const mapRef = useRef(null); @@ -84,10 +91,25 @@ export default function Page() { -
- +
- + { + console.log('Selected date:', date); + }} + /> + {/* */}
diff --git a/app/timeline.tsx b/app/timeline.tsx index c298d56..b59cc3b 100644 --- a/app/timeline.tsx +++ b/app/timeline.tsx @@ -1,14 +1,31 @@ -import React, { useRef, useEffect, useState } from "react"; +import React, { useRef, useEffect, useState, useCallback } from "react"; import vsSource from './glsl/timeline/vert.glsl'; import fsSource from './glsl/timeline/frag.glsl'; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { ChevronLeft, ChevronRight, Play } from "lucide-react"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" interface Uniforms { - startDate: number; - endDate: number; - currentDate: number; + startTimestamp: number; // Unix 时间戳开始 + endTimestamp: number; // Unix 时间戳结束 + currentTimestamp: number; // 当前时间戳 radius: number; d: number; + timelineStartX: number; // 时间轴在屏幕上的开始X坐标 + timelineEndX: number; // 时间轴在屏幕上的结束X坐标 viewportSize: [number, number]; + zoomLevel: number; // 当前缩放级别 + panOffset: number; // 当前平移偏移 } interface Instants { @@ -16,6 +33,11 @@ interface Instants { color: Float32Array; } +interface VesicaDataPoint { + timestamp: number; // Unix 时间戳 + color?: [number, number, number, number]; // RGBA 颜色,默认为白色 +} + interface Props extends React.HTMLAttributes { boxSize?: [number, number]; startDate?: Date; @@ -24,11 +46,171 @@ interface Props extends React.HTMLAttributes { onDateChange?: (date: Date) => void; onPlay?: () => void; onPause?: () => void; + minZoom?: number; // 最小缩放级别 + maxZoom?: number; // 最大缩放级别 + initialZoom?: number; // 初始缩放级别 + vesicaData?: VesicaDataPoint[]; // vesica 实例数据 + dateFormat?: (timestamp: number) => string; // 自定义时间格式化函数 } -export const Timeline: React.FC = ({ startDate, endDate, currentDate, onDateChange, onPlay, onPause, boxSize = [4, 8], ...props }) => { +interface Status { + isDragging: boolean; + isLongPress: boolean; + isPanningTimeline: boolean; + customLineTimestamp: number | null; // 改为存储时间戳而不是屏幕坐标 + panOffset: number; + zoomLevel: number; +} + +interface RedrawOverrides { + startDate?: Date; + endDate?: Date; + currentDate?: Date; + zoomLevel?: number; + panOffset?: number; + customLineTimestamp?: number | null; + isDragging?: boolean; +} + +// 默认的vesica数据示例 +const getDefaultVesicaData = (startDate?: Date, endDate?: Date): VesicaDataPoint[] => { + if (!startDate || !endDate) return []; + + const start = startDate.getTime(); + const end = endDate.getTime(); + const timeRange = end - start; + + // 创建一些示例数据点 + return [ + { + timestamp: start + timeRange * 0.1, + color: [1.0, 0.2, 0.2, 0.8] // 红色 + }, + { + timestamp: start + timeRange * 0.25, + color: [0.2, 1.0, 0.2, 0.8] // 绿色 + }, + { + timestamp: start + timeRange * 0.4, + color: [0.2, 0.2, 1.0, 0.8] // 蓝色 + }, + { + timestamp: start + timeRange * 0.6, + color: [1.0, 1.0, 0.2, 0.8] // 黄色 + }, + { + timestamp: start + timeRange * 0.75, + color: [1.0, 0.2, 1.0, 0.8] // 紫色 + }, + { + timestamp: start + timeRange * 0.9, + color: [0.2, 1.0, 1.0, 0.8] // 青色 + } + ]; +}; + +function SelectDemo() { + return ( + + ) +} + +export const Timeline: React.FC = ({ + startDate, + endDate, + currentDate, + onDateChange, + onPlay, + onPause, + boxSize = [4, 8], + minZoom = 0.5, + maxZoom = 2, + initialZoom = 1, + vesicaData, + dateFormat, + ...props +}) => { const canvasRef = useRef(null); + const ticksCanvasRef = useRef(null); + + const [state, setState] = useState({ + isDragging: false, + isLongPress: false, + isPanningTimeline: false, + customLineTimestamp: null, + panOffset: 0, + zoomLevel: initialZoom, + }); + + const longPressTimerRef = useRef(null); // 长按计时器 + const dragTempXRef = useRef(null); // 拖拽时的临时x坐标 + const animationFrameRef = useRef(null); // 动画帧ID + + const panStartXRef = useRef(0); // 拖拽开始的x坐标 + const panStartOffsetRef = useRef(0); // 拖拽开始时的panOffset + const panTempOffsetRef = useRef(0); // 拖拽时的临时偏移 + const hasDraggedTimelineRef = useRef(false); // 拖拽标记,使用ref避免闪烁 + + const actualVesicaData = vesicaData ?? []; + + // 使用ref存储最新的状态值,避免闭包陷阱 + const stateRef = useRef(state); + const propsRef = useRef({ startDate, endDate, currentDate }); + + // 每次渲染时更新ref的值 + stateRef.current = state; + propsRef.current = { startDate, endDate, currentDate }; + + // 缩放处理函数 + const handleZoom = (delta: number, mouseX?: number) => { + const zoomFactor = 1.15; // 调整缩放因子,使缩放更平滑 + const newZoom = delta > 0 + ? Math.min(state.zoomLevel * zoomFactor, maxZoom) + : Math.max(state.zoomLevel / zoomFactor, minZoom); + + // 如果缩放级别没有变化,直接返回 + if (newZoom === state.zoomLevel) return; + + // 可选:基于鼠标位置的缩放(保持鼠标位置下的内容不变) + let newPanOffset = state.panOffset; + if (mouseX !== undefined && ticksCanvasRef.current) { + const rect = ticksCanvasRef.current.getBoundingClientRect(); + const centerX = rect.width / 2; + const mouseOffsetFromCenter = mouseX - centerX; + const zoomChange = newZoom / state.zoomLevel; + newPanOffset = state.panOffset + mouseOffsetFromCenter * (1 - zoomChange); + } + + setState({ ...state, zoomLevel: newZoom, panOffset: newPanOffset }); + }; + + // 平移处理函数 + const handlePan = (deltaX: number) => { + // 计算平移边界限制 + const maxPanOffset = 100 * state.zoomLevel; // 最大平移距离与缩放级别相关 + const newOffset = Math.max(-maxPanOffset, Math.min(maxPanOffset, state.panOffset + deltaX)); + setState({ ...state, panOffset: newOffset }); + }; + + // 重置缩放和平移 + const resetView = () => { + setState({ ...state, zoomLevel: initialZoom, panOffset: 0 }); + }; const vaoRef = useRef(null); const vertex_bfRef = useRef(null); @@ -37,36 +219,457 @@ export const Timeline: React.FC = ({ startDate, endDate, currentDate, onD const programRef = useRef(null); const instants_countRef = useRef(0); + // 统一的重绘函数,支持临时状态覆盖 + const redraw = useCallback((overrides: RedrawOverrides = {}) => { + if (!canvasRef.current || !ticksCanvasRef.current) return; + + const rect = ticksCanvasRef.current.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + const dpr = window.devicePixelRatio || 1; + const currentState = stateRef.current; + const currentProps = propsRef.current; + + drawTicks( + ticksCanvasRef.current, + rect.width, + rect.height, + dpr, + overrides.startDate ?? currentProps.startDate, + overrides.endDate ?? currentProps.endDate, + overrides.currentDate ?? currentProps.currentDate, + overrides.zoomLevel ?? currentState.zoomLevel, + overrides.panOffset ?? currentState.panOffset, + overrides.customLineTimestamp ?? currentState.customLineTimestamp, + overrides.isDragging ?? currentState.isDragging + ); + }, []); + + // 只在特定情况下监听重绘 + useEffect(() => { + // 避免拖拽时的重复重绘 + const currentState = stateRef.current; + if ((currentState.isDragging && currentState.isLongPress) || currentState.isPanningTimeline) return; + + redraw(); + }, [state.customLineTimestamp, state.zoomLevel, state.panOffset, startDate, endDate, currentDate, redraw]); + const { radius, d } = calcVersicaUni(boxSize); console.log(radius, d); const current_uniforms = useRef({ - startDate: 0, - endDate: 0, - currentDate: 0, + startTimestamp: 0, + endTimestamp: 0, + currentTimestamp: 0, radius: radius, d: d, + timelineStartX: 0, + timelineEndX: 0, viewportSize: [0, 0], + zoomLevel: initialZoom, + panOffset: 0, }) useEffect(() => { - if (!canvasRef.current) return; + if (!canvasRef.current || !ticksCanvasRef.current) return; const { radius, d } = calcVersicaUni(boxSize); - const width = canvasRef.current.clientWidth; - const height = canvasRef.current.clientHeight; - // 设置canvas的实际像素尺寸 - canvasRef.current.width = width; - canvasRef.current.height = height; + const handleMouseClick = (e: MouseEvent) => { + const rect = ticksCanvasRef.current?.getBoundingClientRect(); + if (!rect) return; + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const centerY = rect.height / 2; - console.log(`Canvas初始化: ${width}x${height}, vesica: r=${radius.toFixed(2)}, d=${d.toFixed(2)}`); + const currentState = stateRef.current; - current_uniforms.current.viewportSize = [width, height]; - current_uniforms.current.radius = radius; - current_uniforms.current.d = d; + // 如果刚刚完成了时间轴拖拽,则不处理点击事件 + // 注意:现在只检查是否已经实际拖拽过,而不是检查isPanningTimeline状态 + if (hasDraggedTimelineRef.current) { + return; + } + + const timelineStartX = 40; + const timelineEndX = rect.width - 40; + const timelineRange = 20; + + // 检查是否在时间轴区域内点击 + const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX && + Math.abs(mouseY - centerY) <= timelineRange; + + // 检查是否点击了现有的自定义竖线附近 + let isNearCustomLine = false; + if (currentState.customLineTimestamp !== null && startDate && endDate) { + // 根据时间戳计算当前屏幕位置 + const timeRange = endDate.getTime() - startDate.getTime(); + const visibleTimeRange = timeRange / currentState.zoomLevel; + const timelineWidth = timelineEndX - timelineStartX; + const timePerPixel = visibleTimeRange / timelineWidth; + const panTimeOffset = -currentState.panOffset * timePerPixel; + const originalCenterTime = startDate.getTime() + timeRange / 2; + const newCenterTime = originalCenterTime + panTimeOffset; + const visibleStartTime = newCenterTime - visibleTimeRange / 2; + + const timeProgress = (currentState.customLineTimestamp - visibleStartTime) / visibleTimeRange; + const customLineScreenX = timelineStartX + timeProgress * timelineWidth; + + isNearCustomLine = Math.abs(mouseX - customLineScreenX) <= 10; + } + + // 只在时间轴区域内点击且不是竖线附近时创建指示器 + if (isInTimelineArea && !isNearCustomLine) { + // 根据当前的缩放和平移状态计算点击位置对应的时间戳 + if (startDate && endDate) { + const timelineWidth = timelineEndX - timelineStartX; + const relativeX = mouseX - timelineStartX; + const progress = relativeX / timelineWidth; + + // 基于可见时间窗口计算时间戳 + const timeRange = endDate.getTime() - startDate.getTime(); + const visibleTimeRange = timeRange / currentState.zoomLevel; + const timePerPixel = visibleTimeRange / timelineWidth; + const panTimeOffset = -currentState.panOffset * timePerPixel; + const originalCenterTime = startDate.getTime() + timeRange / 2; + const newCenterTime = originalCenterTime + panTimeOffset; + const visibleStartTime = newCenterTime - visibleTimeRange / 2; + + const selectedTimestamp = visibleStartTime + progress * visibleTimeRange; + + setState(prevState => ({ + ...prevState, + customLineTimestamp: selectedTimestamp, + isLongPress: false, + isDragging: false + })); + + // 通知父组件 + if (onDateChange) { + onDateChange(new Date(selectedTimestamp)); + } + } + } + } - const gl = (canvasRef.current.getContext('webgl2') as WebGL2RenderingContext); + // 在useEffect内部定义事件处理函数,确保正确的作用域和清理 + // 鼠标按下处理 - 检测是否点击了自定义竖线,或创建新竖线,或开始拖拽时间轴 + const handleMouseDown = (e: MouseEvent) => { + const rect = ticksCanvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const centerY = rect.height / 2; + + const currentState = stateRef.current; + + // 定义时间轴区域 - 与drawTicks中的参数保持一致 + const timelineStartX = 40; + const timelineEndX = rect.width - 40; + const timelineRange = 20; // 减小范围,让判断更精确 + + + // 检查是否在时间轴的主要区域内(优先级最高) + const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX && + Math.abs(mouseY - centerY) <= timelineRange; + + // 检查是否点击了现有的自定义竖线附近 + let isNearCustomLine = false; + if (currentState.customLineTimestamp !== null && startDate && endDate) { + // 根据时间戳计算当前屏幕位置 + const timeRange = endDate.getTime() - startDate.getTime(); + const visibleTimeRange = timeRange / currentState.zoomLevel; + const timelineWidth = timelineEndX - timelineStartX; + const timePerPixel = visibleTimeRange / timelineWidth; + const panTimeOffset = -currentState.panOffset * timePerPixel; + const originalCenterTime = startDate.getTime() + timeRange / 2; + const newCenterTime = originalCenterTime + panTimeOffset; + const visibleStartTime = newCenterTime - visibleTimeRange / 2; + + const timeProgress = (currentState.customLineTimestamp - visibleStartTime) / visibleTimeRange; + const customLineScreenX = timelineStartX + timeProgress * timelineWidth; + + isNearCustomLine = Math.abs(mouseX - customLineScreenX) <= 10; + } + + if (isNearCustomLine) { + // 处理自定义竖线拖拽(长按激活) + longPressTimerRef.current = setTimeout(() => { + setState(prevState => ({ ...prevState, isLongPress: true, isDragging: true })); + redraw({ isDragging: true }); + }, 100); + } else if (isInTimelineArea) { + // 记录鼠标按下位置,但不立即开启拖拽模式 + // 等待鼠标移动超过阈值时再开启拖拽 + panStartXRef.current = mouseX; + panStartOffsetRef.current = currentState.panOffset; + panTempOffsetRef.current = currentState.panOffset; + hasDraggedTimelineRef.current = false; // 重置拖拽标记 + } else { + // 不在任何特殊区域,不执行任何操作 + // 移除在时间轴区域外创建竖线的逻辑,因为现在通过click事件处理 + } + }; + + // 鼠标移动处理 - 拖拽自定义竖线或拖拽时间轴 + const handleMouseMove = (e: MouseEvent) => { + const rect = ticksCanvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const centerY = rect.height / 2; + const currentState = stateRef.current; + + // 处理指示器拖拽 + if (currentState.isDragging && currentState.isLongPress) { + // 限制竖线在时间轴范围内 + const startX = 40; + const endX = rect.width - 40; + const clampedX = Math.max(startX, Math.min(endX, mouseX)); + + // 使用ref存储临时位置,避免频繁状态更新 + dragTempXRef.current = clampedX; + + // 使用requestAnimationFrame优化重绘 + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + animationFrameRef.current = requestAnimationFrame(() => { + if (ticksCanvasRef.current && dragTempXRef.current !== null) { + // 直接重绘,使用临时位置 + // 将屏幕坐标转换为时间戳 + if (startDate && endDate && dragTempXRef.current !== null) { + const rect = ticksCanvasRef.current?.getBoundingClientRect(); + if (rect) { + const timelineStartX = 40; + const timelineEndX = rect.width - 40; + const timelineWidth = timelineEndX - timelineStartX; + const relativeX = dragTempXRef.current - timelineStartX; + const progress = relativeX / timelineWidth; + + // 基于可见时间窗口计算时间戳 + const timeRange = endDate.getTime() - startDate.getTime(); + const visibleTimeRange = timeRange / currentState.zoomLevel; + const timePerPixel = visibleTimeRange / timelineWidth; + const panTimeOffset = -currentState.panOffset * timePerPixel; + const originalCenterTime = startDate.getTime() + timeRange / 2; + const newCenterTime = originalCenterTime + panTimeOffset; + const visibleStartTime = newCenterTime - visibleTimeRange / 2; + + const dragTimestamp = visibleStartTime + progress * visibleTimeRange; + + redraw({ + customLineTimestamp: dragTimestamp, + isDragging: true + }); + } + } + } + }); + return; + } + + // 处理时间轴拖拽 + if (currentState.isPanningTimeline) { + const deltaX = mouseX - panStartXRef.current; + const newOffset = panStartOffsetRef.current + deltaX; + + // 如果移动距离超过阈值,标记为已拖拽(使用ref避免频繁状态更新) + if (Math.abs(deltaX) > 3) { + hasDraggedTimelineRef.current = true; + } + + // 应用边界限制 + const maxPanOffset = 100 * currentState.zoomLevel; + const clampedOffset = Math.max(-maxPanOffset, Math.min(maxPanOffset, newOffset)); + + // 存储临时偏移 + panTempOffsetRef.current = clampedOffset; + + // 直接重绘,不使用requestAnimationFrame以减少延迟 + if (ticksCanvasRef.current) { + redraw({ + panOffset: panTempOffsetRef.current + }); + } + return; + } + + // 检查是否应该开始时间轴拖拽(移动距离阈值检查) + if (!currentState.isPanningTimeline && !currentState.isDragging && panStartXRef.current !== 0) { + const deltaX = mouseX - panStartXRef.current; + const dragThreshold = 8; // 拖拽阈值:8像素 + + // 检查是否在时间轴区域内 + const timelineStartX = 40; + const timelineEndX = rect.width - 40; + const timelineRange = 20; + const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX && + Math.abs(mouseY - centerY) <= timelineRange; + + // 只有在时间轴区域内且移动距离超过阈值时才开启拖拽 + if (isInTimelineArea && Math.abs(deltaX) > dragThreshold) { + setState(prevState => ({ ...prevState, isPanningTimeline: true })); + } + } + + updateCursorStyle(); + }; + + // 鼠标抬起处理 - 结束拖拽 + const handleMouseUp = () => { + const currentState = stateRef.current; + + // 清除长按计时器 + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + + // 取消待处理的动画帧 + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + + // 处理指示器拖拽结束 + if (currentState.isDragging && currentState.isLongPress && dragTempXRef.current !== null) { + // 将最终的屏幕坐标转换为时间戳并保存 + if (startDate && endDate && ticksCanvasRef.current) { + const rect = ticksCanvasRef.current.getBoundingClientRect(); + const timelineStartX = 40; + const timelineEndX = rect.width - 40; + const timelineWidth = timelineEndX - timelineStartX; + const relativeX = dragTempXRef.current - timelineStartX; + const progress = relativeX / timelineWidth; + + // 基于可见时间窗口计算时间戳 + const timeRange = endDate.getTime() - startDate.getTime(); + const visibleTimeRange = timeRange / currentState.zoomLevel; + const timePerPixel = visibleTimeRange / timelineWidth; + const panTimeOffset = -currentState.panOffset * timePerPixel; + const originalCenterTime = startDate.getTime() + timeRange / 2; + const newCenterTime = originalCenterTime + panTimeOffset; + const visibleStartTime = newCenterTime - visibleTimeRange / 2; + + const finalTimestamp = visibleStartTime + progress * visibleTimeRange; + + setState(prevState => ({ + ...prevState, + customLineTimestamp: finalTimestamp, + isDragging: false, + isLongPress: false + })); + } else { + setState(prevState => ({ ...prevState, isDragging: false, isLongPress: false })); + } + dragTempXRef.current = null; + } else if (currentState.isPanningTimeline) { + // 处理时间轴拖拽结束 + // 提交临时偏移到真实状态并重置时间轴拖拽状态 + const finalOffset = panTempOffsetRef.current; + setState(prevState => ({ + ...prevState, + panOffset: finalOffset, + isPanningTimeline: false + })); + + // 立即用最终偏移重绘,确保显示正确 + redraw({ + panOffset: finalOffset + }); + + // 光标样式由updateCursorStyle统一管理 + + // 延迟重置拖拽标记和临时偏移 + setTimeout(() => { + hasDraggedTimelineRef.current = false; + panTempOffsetRef.current = 0; // 现在可以安全重置了 + }, 50); + } else { + // 只重置拖拽状态 + setState(prevState => ({ ...prevState, isDragging: false, isLongPress: false })); + } + + // 重置拖拽起始位置(重要:避免后续误判) + panStartXRef.current = 0; + }; + + // 更新光标样式的通用函数 + const updateCursorStyle = () => { + const currentState = stateRef.current; + if (!ticksCanvasRef.current) return; + + if (currentState.isPanningTimeline) { + // 当且仅当正在拖拽时间轴时显示抓取中光标 + ticksCanvasRef.current.style.cursor = 'grabbing'; + } else { + // 其余所有情况均显示十字光标 + ticksCanvasRef.current.style.cursor = 'crosshair'; + } + }; + + // 鼠标悬停处理 - 动态改变鼠标样式 + const handleMouseOver = (e: MouseEvent) => { + updateCursorStyle(); + }; + + // 鼠标移出处理 + const handleMouseOut = () => { + // 光标样式由updateCursorStyle统一管理,不需要特殊处理 + }; + + // 获取设备像素比例以支持高分屏 + const dpr = window.devicePixelRatio || 1; + const displayWidth = canvasRef.current.clientWidth; + const displayHeight = canvasRef.current.clientHeight; + + // 设置WebGL canvas的实际像素尺寸(考虑高分屏) + const actualWidth = Math.floor(displayWidth * dpr); + const actualHeight = Math.floor(displayHeight * dpr); + + canvasRef.current.width = actualWidth; + canvasRef.current.height = actualHeight; + + // 设置CSS显示尺寸 + canvasRef.current.style.width = displayWidth + 'px'; + canvasRef.current.style.height = displayHeight + 'px'; + + // 设置刻度线canvas的尺寸 + setupTicksCanvas(ticksCanvasRef.current, displayWidth, displayHeight, dpr); + + // 初始绘制刻度线,确保canvas设置完成后立即显示 + redraw(); + + // 更新uniform数据 + current_uniforms.current.viewportSize = [actualWidth, actualHeight]; + current_uniforms.current.radius = radius * dpr; // 调整radius以适应像素密度 + current_uniforms.current.d = d * dpr; // 调整d以适应像素密度 + current_uniforms.current.timelineStartX = 40 * dpr; // 时间轴开始坐标 + current_uniforms.current.timelineEndX = (displayWidth - 40) * dpr; // 时间轴结束坐标 + current_uniforms.current.zoomLevel = state.zoomLevel; + current_uniforms.current.panOffset = state.panOffset * dpr; + + // 设置时间戳范围 + if (startDate && endDate) { + current_uniforms.current.startTimestamp = startDate.getTime(); + current_uniforms.current.endTimestamp = endDate.getTime(); + } + if (currentDate) { + current_uniforms.current.currentTimestamp = currentDate.getTime(); + } + + + const gl = (canvasRef.current.getContext('webgl2', { + antialias: true, // 启用抗锯齿 + alpha: true, // 启用alpha通道以支持透明度 + premultipliedAlpha: false, // 不使用预乘alpha + depth: false, // 不需要深度缓冲 + stencil: false, // 不需要模板缓冲 + preserveDrawingBuffer: false // 不保留绘制缓冲区 + }) as WebGL2RenderingContext); if (!gl) { console.error('WebGL2 not supported'); return; @@ -93,7 +696,7 @@ export const Timeline: React.FC = ({ startDate, endDate, currentDate, onD gl.bindVertexArray(vao); const vertex_bf = defaultVb(gl); - const instants_bf = defaultIb(gl); + const { buffer: instants_bf, count: actualInstanceCount } = createVesicaInstances(gl, actualVesicaData, actualWidth, actualHeight, dpr); const uniform_bf = defaultUb(gl, current_uniforms.current); gl.bindVertexArray(null); @@ -105,10 +708,10 @@ export const Timeline: React.FC = ({ startDate, endDate, currentDate, onD uniform_bfRef.current = uniform_bf; instants_bfRef.current = instants_bf; programRef.current = program; - instants_countRef.current = 15; // 设置实例数量(时间轴刻度) + instants_countRef.current = actualInstanceCount; // 使用实际生成的实例数量 function render() { - gl.clearColor(0.1, 0.1, 0.1, 1); // 深灰背景,便于看到刻度 + gl.clearColor(0, 0, 0, 0); // 深灰背景,便于看到刻度 gl.clear(gl.COLOR_BUFFER_BIT); gl.useProgram(program); @@ -122,76 +725,181 @@ export const Timeline: React.FC = ({ startDate, endDate, currentDate, onD gl.bindVertexArray(null); } - function updateUniforms(gl: WebGL2RenderingContext, uniforms: Uniforms) { + function updateUniforms(uniforms: Uniforms) { gl.bindBuffer(gl.UNIFORM_BUFFER, uniform_bfRef.current!); const uniformData = new Float32Array([ - uniforms.startDate, - uniforms.endDate, - uniforms.currentDate, + uniforms.startTimestamp, + uniforms.endTimestamp, + uniforms.currentTimestamp, uniforms.radius, uniforms.d, - 0.0, // padding1 - 填充以对齐vec2到8字节边界 + uniforms.timelineStartX, + uniforms.timelineEndX, + 0.0, // padding - 填充以对齐到8字节边界 uniforms.viewportSize[0], - uniforms.viewportSize[1] + uniforms.viewportSize[1], + uniforms.zoomLevel, + uniforms.panOffset ]); gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.DYNAMIC_DRAW); } - function updateInstants(instants: Instants[]) { - gl.bindBuffer(gl.ARRAY_BUFFER, instants_bfRef.current!); - - // 使用明确的数组顺序确保内存布局正确 - const instantsData = new Float32Array(instants.length * 6); // 每个实例6个float (2个position + 4个color) - - for (let i = 0; i < instants.length; i++) { - const offset = i * 6; - const instant = instants[i]; - - // position (2个float) - instantsData[offset + 0] = instant.position[0]; - instantsData[offset + 1] = instant.position[1]; - - // color (4个float) - instantsData[offset + 2] = instant.color[0]; - instantsData[offset + 3] = instant.color[1]; - instantsData[offset + 4] = instant.color[2]; - instantsData[offset + 5] = instant.color[3]; - } - - gl.bufferData(gl.ARRAY_BUFFER, instantsData, gl.DYNAMIC_DRAW); - instants_countRef.current = instants.length; - render(); // 更新后重新渲染 - } // TODO: 可以通过props传入自定义的时间轴刻度数据 // 或使用useImperativeHandle暴露更新方法 - gl.viewport(0, 0, width, height); + // 初始化uniform数据并渲染 + updateUniforms(current_uniforms.current); + gl.viewport(0, 0, actualWidth, actualHeight); render(); - const handleMove = (e: MouseEvent) => { - const rect = canvasRef.current?.getBoundingClientRect(); + // 鼠标滚轮缩放和平移 + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = ticksCanvasRef.current?.getBoundingClientRect(); if (!rect) return; - const px = e.clientX - rect.left; - const py = e.clientY - rect.top; - console.log(px, py); - } - canvasRef.current?.addEventListener('mousemove', handleMove); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const centerY = rect.height / 2; + + // 只在时间轴区域内滚动时才处理 + const timelineStartX = 40; + const timelineEndX = rect.width - 40; + const timelineRange = 30; // 扩大一点范围 + + const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX && + Math.abs(mouseY - centerY) <= timelineRange; + + if (isInTimelineArea) { + // 设置阈值,避免微小滚动触发操作 + const deltaThreshold = 3; + const deltaX = e.deltaX; + const deltaY = e.deltaY; + + // 判断主要滚动方向和是否超过阈值 + if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > deltaThreshold) { + // 水平滚动:平移操作 + // 反转方向并增加速度,使滚动更跟手 + handlePan(-deltaX * 2); // 反转方向,增加速度倍数 + } else if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > deltaThreshold) { + // 垂直滚动:缩放操作 + handleZoom(-deltaY, mouseX); + } + } + }; + + // 触摸缩放和平移 + let lastTouchDistance = 0; + let lastTouchX = 0; + let isPanning = false; + + const handleTouchStart = (e: TouchEvent) => { + if (e.touches.length === 2) { + // 双指缩放 + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + lastTouchDistance = Math.sqrt(dx * dx + dy * dy); + } else if (e.touches.length === 1) { + // 单指平移 + lastTouchX = e.touches[0].clientX; + isPanning = true; + } + }; + + const handleTouchMove = (e: TouchEvent) => { + e.preventDefault(); + if (e.touches.length === 2) { + // 双指缩放 + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (lastTouchDistance > 0) { + const scale = distance / lastTouchDistance; + const delta = scale > 1 ? 1 : -1; + handleZoom(delta * 50); + } + lastTouchDistance = distance; + } else if (e.touches.length === 1 && isPanning) { + // 单指平移 + const deltaX = e.touches[0].clientX - lastTouchX; + handlePan(deltaX); + lastTouchX = e.touches[0].clientX; + } + }; + + const handleTouchEnd = () => { + lastTouchDistance = 0; + isPanning = false; + }; + + // 双击重置 + const handleDoubleClick = () => { + resetView(); + }; + + ticksCanvasRef.current?.addEventListener('wheel', handleWheel, { passive: false }); + ticksCanvasRef.current?.addEventListener('touchstart', handleTouchStart); + ticksCanvasRef.current?.addEventListener('touchmove', handleTouchMove, { passive: false }); + ticksCanvasRef.current?.addEventListener('touchend', handleTouchEnd); + ticksCanvasRef.current?.addEventListener('dblclick', handleDoubleClick); + ticksCanvasRef.current?.addEventListener('mouseover', handleMouseOver); + ticksCanvasRef.current?.addEventListener('mouseout', handleMouseOut); + + // 添加自定义竖线相关的事件监听器 + ticksCanvasRef.current?.addEventListener('mousedown', handleMouseDown); + ticksCanvasRef.current?.addEventListener('mousemove', handleMouseMove); + ticksCanvasRef.current?.addEventListener('click', handleMouseClick); + ticksCanvasRef.current?.addEventListener('mouseup', handleMouseUp); + // 添加全局鼠标抬起事件,防止鼠标移出canvas后拖拽卡住 + document.addEventListener('mouseup', handleMouseUp); // 使用ResizeObserver监听canvas尺寸变化 const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { width: new_width, height: new_height } = entry.contentRect; + // 获取设备像素比例以支持高分屏 + const dpr = window.devicePixelRatio || 1; + const actualWidth = Math.floor(new_width * dpr); + const actualHeight = Math.floor(new_height * dpr); + // 更新canvas的实际像素尺寸 - canvasRef.current!.width = new_width; - canvasRef.current!.height = new_height; + canvasRef.current!.width = actualWidth; + canvasRef.current!.height = actualHeight; - current_uniforms.current.viewportSize = [new_width, new_height]; + // 设置CSS显示尺寸 + canvasRef.current!.style.width = new_width + 'px'; + canvasRef.current!.style.height = new_height + 'px'; - updateUniforms(gl, current_uniforms.current); - gl.viewport(0, 0, new_width, new_height); + // 更新刻度线canvas的尺寸 + setupTicksCanvas(ticksCanvasRef.current!, new_width, new_height, dpr); + + // ResizeObserver中需要立即重绘,因为setupTicksCanvas会清空canvas + redraw(); + + // 更新uniform数据 + current_uniforms.current.viewportSize = [actualWidth, actualHeight]; + current_uniforms.current.radius = radius * dpr; // 调整radius以适应像素密度 + current_uniforms.current.d = d * dpr; // 调整d以适应像素密度 + current_uniforms.current.timelineStartX = 40 * dpr; // 时间轴开始坐标 + current_uniforms.current.timelineEndX = (new_width - 40) * dpr; // 时间轴结束坐标 + current_uniforms.current.zoomLevel = stateRef.current.zoomLevel; + current_uniforms.current.panOffset = stateRef.current.panOffset * dpr; + + // 重新生成实例数据以适应新的canvas尺寸 + const { buffer: new_instants_bf, count: new_count } = createVesicaInstances(gl, actualVesicaData, actualWidth, actualHeight, dpr); + + // 更新实例buffer引用和数量 + if (instants_bfRef.current) { + gl.deleteBuffer(instants_bfRef.current); + } + instants_bfRef.current = new_instants_bf; + instants_countRef.current = new_count; + + updateUniforms(current_uniforms.current); + gl.viewport(0, 0, actualWidth, actualHeight); render(); } }); @@ -200,7 +908,34 @@ export const Timeline: React.FC = ({ startDate, endDate, currentDate, onD return () => { // 移除事件监听器 - canvasRef.current?.removeEventListener('mousemove', handleMove); + ticksCanvasRef.current?.removeEventListener('wheel', handleWheel); + ticksCanvasRef.current?.removeEventListener('touchstart', handleTouchStart); + ticksCanvasRef.current?.removeEventListener('touchmove', handleTouchMove); + ticksCanvasRef.current?.removeEventListener('touchend', handleTouchEnd); + ticksCanvasRef.current?.removeEventListener('dblclick', handleDoubleClick); + ticksCanvasRef.current?.removeEventListener('mouseover', handleMouseOver); + ticksCanvasRef.current?.removeEventListener('mouseout', handleMouseOut); + + // 移除自定义竖线相关的事件监听器 + ticksCanvasRef.current?.removeEventListener('mousedown', handleMouseDown); + ticksCanvasRef.current?.removeEventListener('mousemove', handleMouseMove); + ticksCanvasRef.current?.removeEventListener('click', handleMouseClick); + ticksCanvasRef.current?.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('mouseup', handleMouseUp); + + // 清理长按计时器 + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + + // 清理动画帧 + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + // 重置临时状态(不需要调用setState,因为组件即将卸载或重新初始化) + panTempOffsetRef.current = 0; + resizeObserver.disconnect(); // 清理WebGL资源 @@ -210,12 +945,90 @@ export const Timeline: React.FC = ({ startDate, endDate, currentDate, onD if (instants_bfRef.current) gl.deleteBuffer(instants_bfRef.current); if (programRef.current) gl.deleteProgram(programRef.current); } - }, [boxSize]) + }, [boxSize, actualVesicaData]); + + // 监听状态变化,更新uniform数据并重新渲染 + useEffect(() => { + if (!programRef.current || !uniform_bfRef.current) return; + + // 更新缩放和平移相关的uniform + current_uniforms.current.zoomLevel = state.zoomLevel; + current_uniforms.current.panOffset = state.panOffset * (window.devicePixelRatio || 1); + + // 更新时间戳数据 + if (startDate && endDate) { + current_uniforms.current.startTimestamp = startDate.getTime(); + current_uniforms.current.endTimestamp = endDate.getTime(); + } + if (currentDate) { + current_uniforms.current.currentTimestamp = currentDate.getTime(); + } + + // 获取WebGL上下文并更新uniform + const canvas = canvasRef.current; + if (canvas) { + const gl = canvas.getContext('webgl2') as WebGL2RenderingContext; + if (gl) { + const program = programRef.current; + gl.useProgram(program); + + // 更新uniform数据 + gl.bindBuffer(gl.UNIFORM_BUFFER, uniform_bfRef.current); + const uniformData = new Float32Array([ + current_uniforms.current.startTimestamp, + current_uniforms.current.endTimestamp, + current_uniforms.current.currentTimestamp, + current_uniforms.current.radius, + current_uniforms.current.d, + current_uniforms.current.timelineStartX, + current_uniforms.current.timelineEndX, + 0.0, // padding + current_uniforms.current.viewportSize[0], + current_uniforms.current.viewportSize[1], + current_uniforms.current.zoomLevel, + current_uniforms.current.panOffset + ]); + gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.DYNAMIC_DRAW); + + // 重新渲染 + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.bindVertexArray(vaoRef.current); + gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, uniform_bfRef.current); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instants_countRef.current); + gl.bindVertexArray(null); + } + } + }, [state.zoomLevel, state.panOffset, startDate, endDate, currentDate]); return ( -
- +
+
+ + + + + + + {/* */} + +
+
+ + +
+ ); } @@ -303,16 +1116,20 @@ function defaultUb(gl: WebGL2RenderingContext, uniforms: Uniforms) { const ub = gl.createBuffer()!; gl.bindBuffer(gl.UNIFORM_BUFFER, ub); - // std140布局:需要添加填充以正确对齐vec2 + // std140布局:需要添加填充以正确对齐 const uniformData = new Float32Array([ - uniforms.startDate, - uniforms.endDate, - uniforms.currentDate, + uniforms.startTimestamp, + uniforms.endTimestamp, + uniforms.currentTimestamp, uniforms.radius, uniforms.d, - 0.0, // padding1 - 填充以对齐vec2到8字节边界 + uniforms.timelineStartX, + uniforms.timelineEndX, + 0.0, // padding - 填充以对齐到8字节边界 uniforms.viewportSize[0], - uniforms.viewportSize[1] + uniforms.viewportSize[1], + uniforms.zoomLevel, + uniforms.panOffset ]); gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.DYNAMIC_DRAW); @@ -331,17 +1148,30 @@ function generateTimelineMarks(startX: number, y: number, interval: number, coun return marks; } -function defaultIb(gl: WebGL2RenderingContext, canvasWidth: number, canvasHeight: number) { +function createVesicaInstances( + gl: WebGL2RenderingContext, + vesicaData: VesicaDataPoint[], + canvasWidth: number, + canvasHeight: number, + dpr: number +): { buffer: WebGLBuffer, count: number } { - // 时间轴刻度:根据canvas尺寸调整位置 - const startX = 20; // 左边距 const centerY = Math.floor(canvasHeight * 0.5); // 垂直居中 - const interval = 12; // 刻度间距(比vesica宽度大) - const maxCount = Math.floor((canvasWidth - startX - 20) / interval); // 根据宽度计算数量 - const count = Math.min(maxCount, 20); // 最多20个刻度 + const instants: Instants[] = []; - console.log(`生成刻度: 起始=${startX}, Y=${centerY}, 间距=${interval}, 数量=${count}`); - const instants: Instants[] = generateTimelineMarks(startX, centerY, interval, count); + // 为每个时间戳数据点创建一个 vesica 实例 + // 位置信息将在着色器中根据时间戳计算,这里只存储时间戳作为position.x + for (const dataPoint of vesicaData) { + const defaultColor = [1.0, 1.0, 1.0, 1.0]; // 默认白色 + const color = dataPoint.color || defaultColor; + + instants.push({ + position: new Float32Array([dataPoint.timestamp, centerY]), // x存储时间戳,y存储屏幕Y坐标 + color: new Float32Array(color) + }); + } + + console.log(`生成vesica实例 (DPR=${dpr}): 数量=${instants.length}`); const instants_bf = gl.createBuffer()!; @@ -356,7 +1186,7 @@ function defaultIb(gl: WebGL2RenderingContext, canvasWidth: number, canvasHeight gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 24, 8); // 2 floats * 4 bytes = 8 bytes offset gl.vertexAttribDivisor(2, 1); - return instants_bf; + return { buffer: instants_bf, count: instants.length }; } function calcVersicaUni(box_size: [number, number]) { @@ -376,4 +1206,340 @@ function calcVersicaUni(box_size: [number, number]) { radius, d, } +} + +function setupTicksCanvas(canvas: HTMLCanvasElement, width: number, height: number, dpr: number) { + // 设置canvas的实际像素尺寸(考虑高分屏) + const actualWidth = Math.floor(width * dpr); + const actualHeight = Math.floor(height * dpr); + canvas.width = actualWidth; + canvas.height = actualHeight; + + // 设置CSS显示尺寸 + canvas.style.width = width + 'px'; + canvas.style.height = height + 'px'; + + // 触发重绘(通过更新一个状态来触发useEffect) + // 这里我们不需要手动触发,因为canvas尺寸变化会自然触发重绘 +} + +function drawTicks( + canvas: HTMLCanvasElement, + width: number, + height: number, + dpr: number, + startDate?: Date, + endDate?: Date, + currentDate?: Date, + zoomLevel: number = 1, + panOffset: number = 0, + customLineTimestamp?: number | null, + isDragging?: boolean +) { + const ctx = canvas.getContext('2d', { + alpha: true, + antialias: true, + }) as CanvasRenderingContext2D; + + // 保存当前context状态 + ctx.save(); + + // 重置变换矩阵并缩放以适应高分屏 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // 清除canvas + ctx.clearRect(0, 0, width, height); + + // 时间轴参数 + const centerY = height / 2; + const startX = 40; // 左边距 + const endX = width - 40; // 右边距 + const timelineWidth = endX - startX; + + // 根据缩放级别计算刻度间距 + const baseTickInterval = 60; // 基础刻度间距 + const scaledTickInterval = baseTickInterval * zoomLevel; + + // 主刻度参数 + const majorTickHeight = 7; + const majorTickInterval = scaledTickInterval; + + // 子刻度参数 - 只在缩放级别足够大时显示 + const minorTickHeight = 3; + const minorTickInterval = scaledTickInterval / 5; // 每个主刻度之间5个子刻度 + const showMinorTicks = zoomLevel > 1.0 && minorTickInterval > 8; // 缩放级别大于1.0且间距足够大时显示子刻度 + + // 计算时间范围 + let timeRange = 0; + let majorTimeStep = 0; + + if (startDate && endDate) { + timeRange = endDate.getTime() - startDate.getTime(); + + // 根据当前可见时间范围确定合适的时间步长 + // 考虑缩放级别,但时间步长本身保持离散的固定值 + const visibleTimeRange = timeRange / zoomLevel; + + if (visibleTimeRange < 1800000) { // 可见范围小于30分钟 + majorTimeStep = 300000; // 5分钟间隔 + } else if (visibleTimeRange < 7200000) { // 可见范围小于2小时 + majorTimeStep = 900000; // 15分钟间隔 + } else if (visibleTimeRange < 43200000) { // 可见范围小于12小时 + majorTimeStep = 3600000; // 1小时间隔 + } else if (visibleTimeRange < 259200000) { // 可见范围小于3天 + majorTimeStep = 21600000; // 6小时间隔 + } else if (visibleTimeRange < 1209600000) { // 可见范围小于2周 + majorTimeStep = 86400000; // 1天间隔 + } else if (visibleTimeRange < 5184000000) { // 可见范围小于2个月 + majorTimeStep = 604800000; // 1周间隔 + } else { // 可见范围超过2个月 + majorTimeStep = 2592000000; // 1个月间隔 + } + } + + // 计算可见时间窗口(考虑缩放和平移) + let visibleStartTime = startDate ? startDate.getTime() : 0; + let visibleEndTime = endDate ? endDate.getTime() : 0; + + if (startDate && endDate) { + // 缩放改变可见时间范围 + const visibleTimeRange = timeRange / zoomLevel; + + // 平移改变可见时间窗口的中心点 + const timePerPixel = visibleTimeRange / timelineWidth; + const panTimeOffset = -panOffset * timePerPixel; + + // 计算可见窗口的中心时间 + const originalCenterTime = startDate.getTime() + timeRange / 2; + const newCenterTime = originalCenterTime + panTimeOffset; + + // 基于缩放后的时间范围和新的中心点计算可见窗口 + visibleStartTime = newCenterTime - visibleTimeRange / 2; + visibleEndTime = newCenterTime + visibleTimeRange / 2; + } + + // 绘制主时间轴线 - 根据实际刻度范围动态调整长度 + // ctx.strokeStyle = '#888'; + // ctx.lineWidth = 2; + // ctx.beginPath(); + + // 计算实际需要覆盖的范围 + // let lineStartX = startX; + // let lineEndX = endX; + + // // 如果有时间数据,根据可见刻度范围扩展线条 + // if (startDate && endDate) { + // const startTickTime = Math.floor(visibleStartTime / majorTimeStep) * majorTimeStep; + // const endTickTime = visibleEndTime + majorTimeStep; + + // // 计算第一个和最后一个刻度的屏幕位置 + // const firstTickProgress = (startTickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); + // const lastTickProgress = (endTickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); + + // const firstTickX = startX + firstTickProgress * timelineWidth; + // const lastTickX = startX + lastTickProgress * timelineWidth; + + // // 扩展线条以覆盖所有刻度,但限制在合理范围内 + // lineStartX = Math.max(0, Math.min(startX, firstTickX - 20)); + // lineEndX = Math.min(width, Math.max(endX, lastTickX + 20)); + // } + + // ctx.moveTo(lineStartX, centerY); + // ctx.lineTo(lineEndX, centerY); + // ctx.stroke(); + + ctx.fillStyle = '#111'; + ctx.fillRect(0, 0, width, 16); + + // 绘制自定义竖线(如果存在) + if (customLineTimestamp !== null && customLineTimestamp !== undefined && startDate && endDate) { + // 根据时间戳计算屏幕位置 + const timeProgress = (customLineTimestamp - visibleStartTime) / (visibleEndTime - visibleStartTime); + const customLineX = startX + timeProgress * timelineWidth; + + // 只在可见区域内绘制 + if (customLineX >= startX - 20 && customLineX <= endX + 20) { + ctx.strokeStyle = isDragging ? '#ff6666' : '#ff4444'; // 拖拽时加粗颜色 + ctx.lineWidth = isDragging ? 4 : 2; // 拖拽时加粗线条 + ctx.beginPath(); + ctx.moveTo(customLineX, centerY - 20); + ctx.lineTo(customLineX, centerY + 20); + ctx.stroke(); + + // 绘制上方的三角形指示器 + ctx.fillStyle = isDragging ? '#ff6666' : '#ff4444'; + ctx.beginPath(); + const triangleSize = 6; + const triangleY = 7; // 在时间轴顶部 + ctx.moveTo(customLineX, triangleY); + ctx.lineTo(customLineX - triangleSize, triangleY - triangleSize); + ctx.lineTo(customLineX + triangleSize, triangleY - triangleSize); + ctx.closePath(); + ctx.fill(); + + // 绘制自定义竖线标签 + ctx.fillStyle = isDragging ? '#ff6666' : '#ff4444'; + ctx.font = 'bold 10px Arial'; + ctx.textBaseline = 'bottom'; + let label = ''; + const tickTime = new Date(customLineTimestamp); + + if (majorTimeStep < 86400000) { // 小于1天,显示时间 + label = tickTime.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit' + }); + } else if (majorTimeStep < 2592000000) { // 小于30天,显示日期 + label = tickTime.toLocaleDateString('zh-CN', { + month: 'short', + day: 'numeric' + }); + } else { // 大于30天,显示月份 + label = tickTime.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'short' + }); + } + + // ctx.fillText(label || '自定义位置', customLineX, centerY + 22); + } + } + + // 绘制子刻度(如果启用) + if (showMinorTicks && startDate && endDate) { + ctx.strokeStyle = '#ccc'; + ctx.lineWidth = 1; + + const minorTimeStep = majorTimeStep / 5; + const startTickTime = Math.floor(visibleStartTime / minorTimeStep) * minorTimeStep; + const endTickTime = visibleEndTime + minorTimeStep; + + for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += minorTimeStep) { + // 计算这个时间点在屏幕上的位置 + const timeProgress = (tickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); + const x = startX + timeProgress * timelineWidth; + + // 只绘制在可见区域内的刻度 + if (x < startX - 10 || x > endX + 10) continue; + + // 跳过主刻度位置 + const timeFromStart = tickTime - visibleStartTime; + if (Math.abs(timeFromStart % majorTimeStep) < minorTimeStep * 0.1) continue; + + // 绘制子刻度线 + ctx.beginPath(); + // ctx.moveTo(x, centerY - minorTickHeight / 2); + // ctx.lineTo(x, centerY + minorTickHeight / 2); + ctx.moveTo(x, 16 - minorTickHeight); + ctx.lineTo(x, 16); + ctx.stroke(); + } + } + + // 绘制主刻度 + if (startDate && endDate) { + ctx.strokeStyle = '#666'; + ctx.lineWidth = 1.5; + ctx.font = '10px Arial'; + ctx.fillStyle = '#666'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + // 基于可见时间窗口计算刻度 + const startTickTime = Math.floor(visibleStartTime / majorTimeStep) * majorTimeStep; + const endTickTime = visibleEndTime + majorTimeStep; + + for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += majorTimeStep) { + // 计算这个时间点在屏幕上的位置 + const timeProgress = (tickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); + const x = startX + timeProgress * timelineWidth; + + // 只绘制在可见区域内的刻度 + if (x < startX - 50 || x > endX + 50) continue; + + // 绘制主刻度线 + ctx.beginPath(); + // ctx.moveTo(x, centerY - majorTickHeight / 2); + // ctx.lineTo(x, centerY + majorTickHeight / 2); + ctx.moveTo(x, 16 - majorTickHeight); + ctx.lineTo(x, 16); + ctx.stroke(); + + // 绘制时间标签 + let label = ''; + const tickDate = new Date(tickTime); + + // 根据时间间隔选择合适的显示格式 + if (majorTimeStep < 86400000) { // 小于1天,显示时间 + label = tickDate.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit' + }); + } else if (majorTimeStep < 2592000000) { // 小于30天,显示日期 + label = tickDate.toLocaleDateString('zh-CN', { + month: 'short', + day: 'numeric' + }); + } else { // 大于30天,显示月份 + label = tickDate.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'short' + }); + } + + ctx.fillText(label, x, 16 - majorTickHeight / 2 + 4); + } + } + + // 绘制当前时间指示器 + if (currentDate && startDate && endDate) { + // 计算当前时间在可见时间窗口中的位置 + const currentTime = currentDate.getTime(); + const timeProgress = (currentTime - visibleStartTime) / (visibleEndTime - visibleStartTime); + const currentX = startX + timeProgress * timelineWidth; + + if (currentX >= startX - 20 && currentX <= endX + 20) { + // 绘制当前时间线 + ctx.strokeStyle = '#ff4444'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(currentX, centerY - 20); + ctx.lineTo(currentX, centerY + 20); + ctx.stroke(); + + // 绘制上方的三角形指示器 + ctx.fillStyle = '#ff4444'; + ctx.beginPath(); + const triangleSize = 6; + const triangleY = 16; // 在时间轴顶部 + ctx.moveTo(currentX, triangleY); + ctx.lineTo(currentX - triangleSize, triangleY - triangleSize); + ctx.lineTo(currentX + triangleSize, triangleY - triangleSize); + ctx.closePath(); + ctx.fill(); + + // 绘制当前时间标签 + ctx.fillStyle = '#ff4444'; + ctx.font = 'bold 10px Arial'; + ctx.textBaseline = 'bottom'; + const currentLabel = currentDate.toLocaleDateString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + ctx.fillText(currentLabel, currentX, centerY - 22); + } + } + + // 绘制缩放级别指示器(调试用) + // ctx.fillStyle = '#888'; + // ctx.font = '10px Arial'; + // ctx.textAlign = 'left'; + // ctx.textBaseline = 'top'; + // ctx.fillText(`缩放: ${zoomLevel.toFixed(2)}x`, 10, 10); + // ctx.fillText(`子刻度: ${showMinorTicks ? '显示' : '隐藏'} (间距: ${minorTickInterval.toFixed(1)})`, 10, 25); + + // 恢复context状态 + ctx.restore(); } \ No newline at end of file diff --git a/components/map-component.tsx b/components/map-component.tsx index 2ffdc24..eb0ea74 100644 --- a/components/map-component.tsx +++ b/components/map-component.tsx @@ -23,6 +23,7 @@ export function MapComponent({ useEffect(() => { if (!mapContainer.current) return + debugger const map = new maplibregl.Map({ container: mapContainer.current, style, @@ -35,7 +36,7 @@ export function MapComponent({ return () => { map.remove() } - }, [style, center, zoom, setMap]) + }, [mapContainer]) return (
{ zoomTo(zoom) - setCurrentZoom(zoom) }, [zoomTo]) const _zoomIn = useCallback(() => { @@ -19,7 +17,7 @@ export function useMapZoom() { }, [zoomOut]) return { - currentZoom, + mapState, zoomToLocation, zoomIn: _zoomIn, zoomOut: _zoomOut,