add: timeline

This commit is contained in:
Tsuki 2025-07-20 02:11:17 +08:00
parent 83683995eb
commit 9321b88df1
9 changed files with 1432 additions and 125 deletions

71
CLAUDE.md Normal file
View File

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

View File

@ -47,7 +47,7 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { currentLocation, flyToLocation, isMapReady } = useMapLocation();
const { currentZoom, zoomToLocation, zoomIn, zoomOut } = useMapZoom();
const { zoomToLocation, zoomIn, zoomOut, mapState } = useMapZoom();
return (
<Sidebar {...props}>
@ -79,8 +79,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarGroup className="p-2 gap-2 px-4">
<Label htmlFor="zoom">Zoom</Label>
<div className="flex items-center gap-2">
<Slider value={[currentZoom]} min={5} max={23} step={1} onValueChange={value => zoomToLocation(value[0])} />
<span className="text-sm text-muted-foreground bg-muted rounded-md px-1 py-1 text-center w-10 inline-block">{currentZoom}</span>
<Slider value={[mapState.zoomLevel]} min={5} max={23} step={0.1} onValueChange={value => zoomToLocation(value[0])} />
<span className="text-sm text-muted-foreground bg-muted rounded-md px-1 py-1 text-center w-10 inline-block">{mapState.zoomLevel.toFixed(1)}</span>
</div>
</SidebarGroup>

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import type { Map } from 'maplibre-gl'
// 定义MapContext的类型
interface MapContextType {
mapRef: React.RefObject<Map | null>
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<Map | null>(null)
const [isMapReady, setIsMapReady] = useState(false)
const [mapState, setMapState] = useState<MapState>({
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,

View File

@ -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<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
@ -84,10 +91,25 @@ export default function Page() {
<ThemeToggle />
</div>
</header>
<div className="relative h-full w-full">
<Timeline className="absolute bottom-0 left-1/2 -translate-x-1/2 bg-red-500 z-10 w-full h-10 rounded-t-lg" />
<div className="relative h-full w-full flex flex-col">
<MapComponent />
<Dock items={items} className="absolute top-1/2 right-4 -translate-y-1/2" />
<Timeline
className={
cn(
"backdrop-blur-lg border shadow-lg",
"bg-background/90 border-border",
// "absolute bottom-0 left-1/2 -translate-x-1/2 ",
"z-10"
)
}
startDate={startDate}
endDate={endDate}
// currentDate={currentDate}
onDateChange={(date) => {
console.log('Selected date:', date);
}}
/>
{/* <Dock items={items} className="absolute top-1/2 right-4 -translate-y-1/2" /> */}
</div>
</SidebarInset>
</SidebarProvider>

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -2,12 +2,10 @@ import { useState, useCallback } from 'react'
import { useMap } from '@/app/map-context'
export function useMapZoom() {
const [currentZoom, setCurrentZoom] = useState(11)
const { zoomTo, zoomIn, zoomOut, isMapReady } = useMap()
const { zoomTo, zoomIn, zoomOut, isMapReady, mapState } = useMap()
const zoomToLocation = useCallback((zoom: number) => {
zoomTo(zoom)
setCurrentZoom(zoom)
}, [zoomTo])
const _zoomIn = useCallback(() => {
@ -19,7 +17,7 @@ export function useMapZoom() {
}, [zoomOut])
return {
currentZoom,
mapState,
zoomToLocation,
zoomIn: _zoomIn,
zoomOut: _zoomOut,