add: timeline
This commit is contained in:
parent
83683995eb
commit
9321b88df1
71
CLAUDE.md
Normal file
71
CLAUDE.md
Normal 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.
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
28
app/page.tsx
28
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<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>
|
||||
|
||||
1336
app/timeline.tsx
1336
app/timeline.tsx
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user