diff --git a/app/app-sidebar.tsx b/app/app-sidebar.tsx index 6043402..23ff3ba 100644 --- a/app/app-sidebar.tsx +++ b/app/app-sidebar.tsx @@ -67,6 +67,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { + USA Singapore Malaysia China diff --git a/app/layout.tsx b/app/layout.tsx index 5ebca74..26df4f7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { TwentyFirstToolbar } from '@21st-extension/toolbar-next'; import { ReactPlugin } from '@21st-extension/react'; import { ThemeProvider } from '@/components/theme-provider'; +import { MapProvider } from "./map-context"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -31,12 +32,14 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > - {children} - + + {children} + + diff --git a/app/map-context.tsx b/app/map-context.tsx index 43df0af..29a0934 100644 --- a/app/map-context.tsx +++ b/app/map-context.tsx @@ -1,18 +1,21 @@ 'use client' import React, { createContext, useContext, useRef, useState, ReactNode } from 'react' -import type { Map } from 'maplibre-gl' +import Map from 'ol/Map'; +import { fromLonLat } from 'ol/proj'; // 定义MapContext的类型 interface MapContextType { mapRef: React.RefObject + layers: React.RefObject mapState: MapState - setMap: (map: Map) => void + setMap: (map: Map, layers: any[]) => void flyTo: (options: { center: [number, number]; zoom: number; duration?: number }) => void zoomIn: () => void zoomOut: () => void zoomTo: (zoom: number) => void reset: () => void + setTime: (date: Date) => void isMapReady: boolean } @@ -36,57 +39,85 @@ export function MapProvider({ children }: MapProviderProps) { zoomLevel: 11 }); - mapRef.current?.on('zoom', () => { - setMapState(prevState => ({ - ...prevState, - zoomLevel: mapRef.current?.getZoom() || 11 - })); - }); + const layersRef = useRef([]) + + + const setMap = (map: Map, layers: any[]) => { + // 监听视图变化事件 + const view = map.getView(); + + // 监听视图的缩放变化 + view.on('change:resolution', () => { + setMapState(prevState => ({ + ...prevState, + zoomLevel: view.getZoom() || 11 + })); + }); + + // 监听视图的中心点变化 + view.on('change:center', () => { + const center = view.getCenter() + + }); - const setMap = (map: Map) => { mapRef.current = map; + layersRef.current = layers; setIsMapReady(true); } const flyTo = (options: { center: [number, number]; zoom: number; duration?: number }) => { if (mapRef.current) { - mapRef.current.flyTo({ - ...options, + mapRef.current.getView().animate({ + center: fromLonLat(options.center), + zoom: options.zoom, duration: options.duration || 1000 }) + } } const zoomIn = () => { if (mapRef.current) { - mapRef.current.zoomIn() + mapRef.current.getView().setZoom(mapRef.current.getView().getZoom()! + 1) } } const zoomOut = () => { if (mapRef.current) { - mapRef.current.zoomOut() + mapRef.current.getView().setZoom(mapRef.current.getView().getZoom()! - 1) } } const zoomTo = (zoom: number) => { if (mapRef.current) { - mapRef.current.zoomTo(zoom) + mapRef.current.getView().setZoom(zoom) + } + } + + const setTime = (date: Date) => { + if (mapRef.current) { + layersRef.current.forEach(layer => { + const source = layer.getSource() + if (source) { + source.updateParams({ + 'TIME': date.toISOString() + }) + } + }) } } const reset = () => { if (mapRef.current) { - mapRef.current.flyTo({ - center: [103.851959, 1.290270], - zoom: 11, - duration: 1000 - }) + mapRef.current.getView().setCenter([103.851959, 1.290270]) + mapRef.current.getView().setZoom(11) } } const value: MapContextType = { + setTime, mapRef, + layers: layersRef, mapState, setMap, flyTo, diff --git a/app/page.tsx b/app/page.tsx index 300a25e..a2b0889 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,4 @@ 'use client' -import 'maplibre-gl/dist/maplibre-gl.css'; -import type { Map, MapOptions } from 'maplibre-gl'; -import maplibregl from 'maplibre-gl'; import { AppSidebar } from '@/app/app-sidebar' import { Breadcrumb, @@ -15,26 +12,19 @@ import { SidebarProvider, SidebarTrigger, } from '@/components/ui/sidebar' -import { useEffect, useRef } from 'react'; -import { MapProvider } from './map-context'; import { MapComponent } from '@/components/map-component'; import { ThemeToggle } from '@/components/theme-toggle'; import { Timeline } from '@/app/timeline'; -import { Dock } from "@/app/dock" - import { Home, Search, - Music, - Heart, Settings, - Plus, User, Play, - Pause } from "lucide-react" import { cn } from '@/lib/utils'; +import { useTimeline } from '@/hooks/use-timeline'; export default function Page() { @@ -51,68 +41,45 @@ export default function Page() { 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); - - useEffect(() => { - if (!containerRef.current || mapRef.current) return; - - const options: MapOptions = { - container: containerRef.current, - style: 'https://demotiles.maplibre.org/style.json', - center: [103.851959, 1.290270], // 新加坡 - zoom: 11, - }; - - mapRef.current = new maplibregl.Map(options); - mapRef.current.addControl(new maplibregl.NavigationControl(), 'top-right'); - return () => mapRef.current?.remove(); - }, []); + const { setTime } = useTimeline() return ( - - - - -
- - -
- - - - October 2024 - - - - -
-
-
- - { - console.log('Selected date:', date); - }} - /> - {/* */} + + + +
+ + +
+ + + + October 2024 + + + +
- - - +
+
+ + { + console.log('Selected date:', date); + setTime(date) + }} + /> +
+
+
) } diff --git a/app/timeline.tsx b/app/timeline.tsx index b59cc3b..1ff5e9e 100644 --- a/app/timeline.tsx +++ b/app/timeline.tsx @@ -3,7 +3,7 @@ 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 { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"; import { Select, @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { useTimeline } from "@/hooks/use-timeline"; interface Uniforms { startTimestamp: number; // Unix 时间戳开始 @@ -138,7 +139,7 @@ export const Timeline: React.FC = ({ onPause, boxSize = [4, 8], minZoom = 0.5, - maxZoom = 2, + maxZoom = 8, initialZoom = 1, vesicaData, dateFormat, @@ -148,23 +149,30 @@ export const Timeline: React.FC = ({ const canvasRef = useRef(null); const ticksCanvasRef = useRef(null); + const { isPlaying, togglePlay } = useTimeline({ + initialDate: currentDate ?? new Date(), + onDateChange(date) { + onDateChange?.(date); + } + }) + const [state, setState] = useState({ isDragging: false, isLongPress: false, isPanningTimeline: false, - customLineTimestamp: null, + customLineTimestamp: currentDate?.getTime() ?? new Date().getTime(), panOffset: 0, zoomLevel: initialZoom, }); - const longPressTimerRef = useRef(null); // 长按计时器 - const dragTempXRef = useRef(null); // 拖拽时的临时x坐标 - const animationFrameRef = useRef(null); // 动画帧ID + const longPressTimerRef = useRef(null); + const dragTempXRef = useRef(null); + const animationFrameRef = useRef(null); - const panStartXRef = useRef(0); // 拖拽开始的x坐标 - const panStartOffsetRef = useRef(0); // 拖拽开始时的panOffset - const panTempOffsetRef = useRef(0); // 拖拽时的临时偏移 - const hasDraggedTimelineRef = useRef(false); // 拖拽标记,使用ref避免闪烁 + const panStartXRef = useRef(0); + const panStartOffsetRef = useRef(0); + const panTempOffsetRef = useRef(0); + const hasDraggedTimelineRef = useRef(false); const actualVesicaData = vesicaData ?? []; @@ -176,9 +184,16 @@ export const Timeline: React.FC = ({ stateRef.current = state; propsRef.current = { startDate, endDate, currentDate }; + const forward = useCallback(() => { + const newDate = new Date(state.customLineTimestamp! + 360000); + setState({ ...state, customLineTimestamp: newDate.getTime() }); + onDateChange?.(newDate); + }, [state.customLineTimestamp]) + // 缩放处理函数 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); @@ -186,14 +201,43 @@ export const Timeline: React.FC = ({ // 如果缩放级别没有变化,直接返回 if (newZoom === state.zoomLevel) return; - // 可选:基于鼠标位置的缩放(保持鼠标位置下的内容不变) + // 基于鼠标位置的缩放(保持鼠标位置下的内容不变) let newPanOffset = state.panOffset; - if (mouseX !== undefined && ticksCanvasRef.current) { + if (mouseX !== undefined && ticksCanvasRef.current && startDate && endDate) { 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); + const startX = 40; // 时间轴左边距,与drawTicks保持一致 + const endX = rect.width - 40; // 时间轴右边距 + const timelineWidth = endX - startX; + + // 计算总时间范围 + const timeRange = endDate.getTime() - startDate.getTime(); + + // 计算当前可见时间窗口(缩放前) + const currentVisibleTimeRange = timeRange / state.zoomLevel; + const currentTimePerPixel = currentVisibleTimeRange / timelineWidth; + const currentPanTimeOffset = -state.panOffset * currentTimePerPixel; + const originalCenterTime = startDate.getTime() + timeRange / 2; + const currentCenterTime = originalCenterTime + currentPanTimeOffset; + const currentVisibleStartTime = currentCenterTime - currentVisibleTimeRange / 2; + + // 计算鼠标位置对应的时间点 + const mouseRelativeX = mouseX - startX; + const mouseTimeProgress = mouseRelativeX / timelineWidth; + const mouseTime = currentVisibleStartTime + mouseTimeProgress * currentVisibleTimeRange; + + // 计算缩放后的可见时间窗口 + const newVisibleTimeRange = timeRange / newZoom; + const newTimePerPixel = newVisibleTimeRange / timelineWidth; + + // 计算新的中心时间,使鼠标位置对应的时间点保持不变 + const newVisibleStartTime = mouseTime - mouseTimeProgress * newVisibleTimeRange; + const newCenterTime = newVisibleStartTime + newVisibleTimeRange / 2; + const newPanTimeOffset = newCenterTime - originalCenterTime; + newPanOffset = -newPanTimeOffset / newTimePerPixel; + + // 应用边界限制 + const maxPanOffset = 100 * newZoom; + newPanOffset = Math.max(-maxPanOffset, Math.min(maxPanOffset, newPanOffset)); } setState({ ...state, zoomLevel: newZoom, panOffset: newPanOffset }); @@ -204,6 +248,7 @@ export const Timeline: React.FC = ({ // 计算平移边界限制 const maxPanOffset = 100 * state.zoomLevel; // 最大平移距离与缩放级别相关 const newOffset = Math.max(-maxPanOffset, Math.min(maxPanOffset, state.panOffset + deltaX)); + // const newOffset = state.panOffset + deltaX setState({ ...state, panOffset: newOffset }); }; @@ -251,11 +296,17 @@ export const Timeline: React.FC = ({ const currentState = stateRef.current; if ((currentState.isDragging && currentState.isLongPress) || currentState.isPanningTimeline) return; - redraw(); + // 使用requestAnimationFrame优化重绘性能 + const rafId = requestAnimationFrame(() => { + redraw(); + }); + + return () => { + cancelAnimationFrame(rafId); + }; }, [state.customLineTimestamp, state.zoomLevel, state.panOffset, startDate, endDate, currentDate, redraw]); const { radius, d } = calcVersicaUni(boxSize); - console.log(radius, d); const current_uniforms = useRef({ startTimestamp: 0, @@ -437,35 +488,29 @@ export const Timeline: React.FC = ({ } 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; + if (ticksCanvasRef.current && dragTempXRef.current !== null && startDate && endDate) { + 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 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; + const dragTimestamp = visibleStartTime + progress * visibleTimeRange; - redraw({ - customLineTimestamp: dragTimestamp, - isDragging: true - }); - } - } + redraw({ + customLineTimestamp: dragTimestamp, + isDragging: true + }); } }); return; @@ -720,7 +765,6 @@ export const Timeline: React.FC = ({ // 绑定uniform buffer gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, uniform_bfRef.current); - console.log(`绘制实例数量: ${instants_countRef.current}`); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instants_countRef.current); gl.bindVertexArray(null); } @@ -1008,11 +1052,11 @@ export const Timeline: React.FC = ({ - - @@ -1171,8 +1215,6 @@ function createVesicaInstances( }); } - console.log(`生成vesica实例 (DPR=${dpr}): 数量=${instants.length}`); - const instants_bf = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, instants_bf); @@ -1198,10 +1240,6 @@ function calcVersicaUni(box_size: [number, number]) { const radius = Math.max(w * 0.6, h * 0.6); // 确保能包含形状 const d = Math.min(w * 0.4, radius * 1.5); // 确保 d < 2*radius - console.log(`vesica参数: w=${w}, h=${h}, radius=${radius.toFixed(2)}, d=${d.toFixed(2)}`); - console.log(`验证: d < 2*radius? ${d} < ${2 * radius} = ${d < 2 * radius}`); - console.log(`验证: r²-d² = ${radius * radius} - ${d * d} = ${radius * radius - d * d}`); - return { radius, d, @@ -1223,6 +1261,48 @@ function setupTicksCanvas(canvas: HTMLCanvasElement, width: number, height: numb // 这里我们不需要手动触发,因为canvas尺寸变化会自然触发重绘 } + + +function levelInfo(level: number) { + // 根据缩放级别确定时间轴级别 + if (level >= 6.0) { + // 日视角:主刻度为小时,子刻度为分钟 + return { + level: 'day', + majorStep: 3600000, // 1小时 = 3600000ms + minorStep: 600000, // 10分钟 = 600000ms,在1小时内有6个子刻度 + majorTickHeight: 8, + minorTickHeight: 4, + showMinorTicks: true, + majorLabelFormat: (date: Date) => date.toLocaleTimeString('zh-CN', { + hour: '2-digit' + }), + minorLabelFormat: (date: Date) => date.toLocaleTimeString('zh-CN', { + minute: '2-digit' + }) + }; + } else { + // 月视角:主刻度为日期,子刻度为小时 + return { + level: 'month', + majorStep: 86400000, // 1天 = 86400000ms + minorStep: 7200000, // 2小时 = 7200000ms + majorTickHeight: 7, + minorTickHeight: 3, + showMinorTicks: level > 1.2, // 缩放级别大于1.2时显示子刻度 + majorLabelFormat: (date: Date) => date.toLocaleDateString('zh-CN', { + month: 'short', + day: 'numeric' + }), + minorLabelFormat: (date: Date) => date.toLocaleTimeString('zh-CN', { + hour: '2-digit' + }) + }; + } +} + + + function drawTicks( canvas: HTMLCanvasElement, width: number, @@ -1256,45 +1336,23 @@ function drawTicks( 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且间距足够大时显示子刻度 + // 获取当前级别信息 + const levelConfig = levelInfo(zoomLevel); + const { + level, + majorStep, + minorStep, + majorTickHeight, + minorTickHeight, + showMinorTicks, + majorLabelFormat, + minorLabelFormat + } = levelConfig; // 计算时间范围 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个月间隔 - } } // 计算可见时间窗口(考虑缩放和平移) @@ -1318,36 +1376,6 @@ function drawTicks( 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); @@ -1381,27 +1409,9 @@ function drawTicks( 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); + const label = majorLabelFormat(tickTime); + ctx.fillText(label, customLineX, centerY - 22); } } @@ -1410,29 +1420,48 @@ function drawTicks( ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; - const minorTimeStep = majorTimeStep / 5; - const startTickTime = Math.floor(visibleStartTime / minorTimeStep) * minorTimeStep; - const endTickTime = visibleEndTime + minorTimeStep; + // 更精确的子刻度时间范围计算 + const startTickTime = Math.floor(visibleStartTime / minorStep) * minorStep; + const endTickTime = Math.ceil(visibleEndTime / minorStep) * minorStep; - for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += minorTimeStep) { + // 根据缩放级别动态调整子刻度间距 + const minMinorTickSpacing = zoomLevel >= 8 ? 6 : zoomLevel >= 6 ? 8 : 12; + + let lastMinorTickX = -minMinorTickSpacing; + let minorTickCount = 0; // 添加计数器用于调试 + + for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += minorStep) { // 计算这个时间点在屏幕上的位置 const timeProgress = (tickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); const x = startX + timeProgress * timelineWidth; - // 只绘制在可见区域内的刻度 - if (x < startX - 10 || x > endX + 10) continue; + // 只绘制在可见区域内的刻度,扩大边界以避免边缘漏画 + if (x < startX - 20 || x > endX + 20) continue; - // 跳过主刻度位置 - const timeFromStart = tickTime - visibleStartTime; - if (Math.abs(timeFromStart % majorTimeStep) < minorTimeStep * 0.1) continue; + // 跳过主刻度位置 - 使用容差来处理浮点数精度问题 + const timeDiffFromMajor = tickTime % majorStep; + const tolerance = 1000; // 减小容差到1秒,避免误判 + const isAtMajorTick = Math.abs(timeDiffFromMajor) < tolerance || + Math.abs(timeDiffFromMajor - majorStep) < tolerance; - // 绘制子刻度线 - 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 (isAtMajorTick) continue; + + // 检查子刻度间距,避免过于密集 + const spacingFromLast = x - lastMinorTickX; + if (spacingFromLast >= minMinorTickSpacing) { + // 绘制子刻度线 + ctx.beginPath(); + ctx.moveTo(x, 16 - minorTickHeight); + ctx.lineTo(x, 16); + ctx.stroke(); + lastMinorTickX = x; + minorTickCount++; + } + } + + // 仅在调试模式下输出错误信息 + if (minorTickCount === 0 && showMinorTicks && zoomLevel >= 6) { + console.warn(`子刻度绘制异常: 缩放级别${zoomLevel.toFixed(1)}, 未绘制任何子刻度`); } } @@ -1446,10 +1475,14 @@ function drawTicks( ctx.textBaseline = 'top'; // 基于可见时间窗口计算刻度 - const startTickTime = Math.floor(visibleStartTime / majorTimeStep) * majorTimeStep; - const endTickTime = visibleEndTime + majorTimeStep; + const startTickTime = Math.floor(visibleStartTime / majorStep) * majorStep; + const endTickTime = visibleEndTime + majorStep; - for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += majorTimeStep) { + // 计算文字间距,避免重叠 + const minLabelSpacing = 60; // 最小标签间距(像素) + let lastLabelX = -minLabelSpacing; // 上一个标签的位置 + + for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += majorStep) { // 计算这个时间点在屏幕上的位置 const timeProgress = (tickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); const x = startX + timeProgress * timelineWidth; @@ -1459,35 +1492,18 @@ function drawTicks( // 绘制主刻度线 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' - }); + // 检查文字间距,避免重叠 + if (x - lastLabelX >= minLabelSpacing) { + // 绘制时间标签 + const tickDate = new Date(tickTime); + const label = majorLabelFormat(tickDate); + ctx.fillText(label, x, 16 - majorTickHeight / 2 + 4); + lastLabelX = x; } - - ctx.fillText(label, x, 16 - majorTickHeight / 2 + 4); } } @@ -1522,24 +1538,10 @@ function drawTicks( 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' - }); + const currentLabel = majorLabelFormat(currentDate); 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 eb0ea74..e4c1c8e 100644 --- a/components/map-component.tsx +++ b/components/map-component.tsx @@ -2,9 +2,20 @@ import React, { useEffect, useRef } from 'react' import maplibregl from 'maplibre-gl' +import VectorTileLayer from 'ol/layer/VectorTile.js' import 'maplibre-gl/dist/maplibre-gl.css' import { useMap } from '@/app/map-context' +import { apply, applyStyle } from 'ol-mapbox-style'; import { useTheme } from '@/components/theme-provider' +import TileWMS from 'ol/source/TileWMS.js'; +import Map from 'ol/Map'; +import View from 'ol/View'; +import TileLayer from 'ol/layer/Tile'; +import { transformExtent, fromLonLat } from 'ol/proj.js'; +import StadiaMaps from 'ol/source/StadiaMaps.js'; +import XYZ from 'ol/source/XYZ'; +import 'ol/ol.css'; +import { useMapLocation } from '@/hooks/use-map-location' interface MapComponentProps { style?: string @@ -12,30 +23,52 @@ interface MapComponentProps { zoom?: number } +const interval = 3 * 60 * 60 * 1000; +const step = 15 * 60 * 1000; +const frameRate = 0.5; // frames per second +const extent = transformExtent([-126, 24, -66, 50], 'EPSG:4326', 'EPSG:3857'); + export function MapComponent({ style = 'https://api.maptiler.com/maps/019817f1-82a8-7f37-901d-4bedf68b27fb/style.json?key=hj3fxRdwF9KjEsBq8sYI', - center = [103.851959, 1.290270], - zoom = 11 + // center = [103.851959, 1.290270], + // zoom = 11 }: MapComponentProps) { const mapContainer = useRef(null) const { setMap } = useMap() + const { location } = useMapLocation() useEffect(() => { if (!mapContainer.current) return - debugger - const map = new maplibregl.Map({ - container: mapContainer.current, - style, - center, - zoom + const tileWmsLayer = new TileLayer({ + extent: extent, + source: new TileWMS({ + attributions: ['Iowa State University'], + url: 'https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r-t.cgi', + params: { 'LAYERS': 'nexrad-n0r-wmst' }, + }), + opacity: 0.7, + }); + + const map = new Map({ + target: mapContainer.current, + view: new View({ + center: fromLonLat(location.center), + zoom: location.zoom, + projection: 'EPSG:3857', + + showFullExtent: true, + enableRotation: true + + }), }) - setMap(map) + apply(map, style).then(() => { + map.addLayer(tileWmsLayer) + }) + + setMap(map, [tileWmsLayer]) - return () => { - map.remove() - } }, [mapContainer]) return ( diff --git a/components/map-example.tsx b/components/map-example.tsx index 8806211..56f431f 100644 --- a/components/map-example.tsx +++ b/components/map-example.tsx @@ -10,7 +10,7 @@ export function MapExample() {
diff --git a/components/timeline.tsx b/components/timeline.tsx index 5746d94..dc8ad44 100644 --- a/components/timeline.tsx +++ b/components/timeline.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useCallback } from 'react' +import React, { useCallback, useEffect } from 'react' import { Slider } from '@/components/ui/slider' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' @@ -14,6 +14,7 @@ import { } from 'lucide-react' import { format, addDays, subDays, startOfDay } from 'date-fns' import { cn } from '@/lib/utils' +import { useTimeline } from '@/hooks/use-timeline' interface TimelineProps { className?: string @@ -44,6 +45,24 @@ export function Timeline({ const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) const currentDays = Math.ceil((currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) const progress = Math.max(0, Math.min(100, (currentDays / totalDays) * 100)) + const timeline = useTimeline( + { + onDateChange(date) { + console.log(date) + } + } + ) + + const forward = useCallback(() => { + }, [timeline]) + + useEffect(() => { + const timer = setInterval(() => { + // setFrameIndex((prev) => (prev + 1) % 10); // 每隔3秒切换一帧 + }, 60000); + + return () => clearInterval(timer); // 清除定时器 + }, []); const handleSliderChange = useCallback((value: number[]) => { const newProgress = value[0] diff --git a/hooks/use-map-location.ts b/hooks/use-map-location.ts index 672b3dc..2f6e69f 100644 --- a/hooks/use-map-location.ts +++ b/hooks/use-map-location.ts @@ -2,6 +2,10 @@ import { useState, useCallback } from 'react' import { useMap } from '@/app/map-context' const LOCATIONS = { + usa: { + center: [-95.7129, 37.0902] as [number, number], + zoom: 4 + }, singapore: { center: [103.851959, 1.290270] as [number, number], zoom: 11 @@ -20,7 +24,7 @@ const LOCATIONS = { export type LocationKey = keyof typeof LOCATIONS export function useMapLocation() { - const [currentLocation, setCurrentLocation] = useState('singapore') + const [currentLocation, setCurrentLocation] = useState('usa') const { flyTo, isMapReady } = useMap() const flyToLocation = useCallback((location: LocationKey) => { @@ -42,8 +46,11 @@ export function useMapLocation() { }) }, [flyTo]) + const location = LOCATIONS[currentLocation] + return { currentLocation, + location, flyToLocation, flyToCustomLocation, locations: LOCATIONS, diff --git a/hooks/use-map-time.ts b/hooks/use-map-time.ts new file mode 100644 index 0000000..525d1b0 --- /dev/null +++ b/hooks/use-map-time.ts @@ -0,0 +1,121 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { addDays, subDays } from 'date-fns' + +interface UseTimelineOptions { + startDate?: Date + endDate?: Date + initialDate?: Date + onDateChange?: (date: Date) => void + autoPlay?: boolean +} + +export function useTimeline({ + startDate = subDays(new Date(), 30), + endDate = new Date(), + initialDate = new Date(), + onDateChange, + autoPlay = false +}: UseTimelineOptions = {}) { + const [currentDate, setCurrentDate] = useState(initialDate) + const [isPlaying, setIsPlaying] = useState(autoPlay) + const [speed, setSpeed] = useState<'slow' | 'normal' | 'fast'>('normal') + const intervalRef = useRef(null) + + const speedIntervals = { + slow: 2000, + normal: 1000, + fast: 500 + } + + const updateDate = useCallback((newDate: Date) => { + setCurrentDate(newDate) + onDateChange?.(newDate) + }, [onDateChange]) + + const play = useCallback(() => { + setIsPlaying(true) + }, []) + + const pause = useCallback(() => { + setIsPlaying(false) + }, []) + + const togglePlay = useCallback(() => { + setIsPlaying(prev => !prev) + }, []) + + const skipForward = useCallback(() => { + const newDate = addDays(currentDate, 1) + if (newDate <= endDate) { + updateDate(newDate) + } + }, [currentDate, endDate, updateDate]) + + const skipBackward = useCallback(() => { + const newDate = subDays(currentDate, 1) + if (newDate >= startDate) { + updateDate(newDate) + } + }, [currentDate, startDate, updateDate]) + + const changeSpeed = useCallback((newSpeed: 'slow' | 'normal' | 'fast') => { + setSpeed(newSpeed) + }, []) + + const jumpToDate = useCallback((date: Date) => { + if (date >= startDate && date <= endDate) { + updateDate(date) + } + }, [startDate, endDate, updateDate]) + + // 自动播放逻辑 + useEffect(() => { + if (isPlaying) { + intervalRef.current = setInterval(() => { + const nextDate = addDays(currentDate, 1) + if (nextDate <= endDate) { + updateDate(nextDate) + } else { + // 到达结束日期,停止播放 + setIsPlaying(false) + } + }, speedIntervals[speed]) + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + }, [isPlaying, currentDate, endDate, speed, updateDate]) + + // 清理定时器 + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + }, []) + + return { + currentDate, + isPlaying, + speed, + startDate, + endDate, + play, + pause, + togglePlay, + skipForward, + skipBackward, + changeSpeed, + jumpToDate, + updateDate + } +} \ No newline at end of file diff --git a/hooks/use-timeline.ts b/hooks/use-timeline.ts index 113ef64..faca144 100644 --- a/hooks/use-timeline.ts +++ b/hooks/use-timeline.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { addDays, subDays } from 'date-fns' +import { useMap } from '@/app/map-context' interface UseTimelineOptions { startDate?: Date @@ -21,6 +22,8 @@ export function useTimeline({ const [speed, setSpeed] = useState<'slow' | 'normal' | 'fast'>('normal') const intervalRef = useRef(null) + const { setTime } = useMap() + const speedIntervals = { slow: 2000, normal: 1000, @@ -116,6 +119,7 @@ export function useTimeline({ skipBackward, changeSpeed, jumpToDate, - updateDate + updateDate, + setTime } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c5c4267..6c88720 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,8 @@ "lucide-react": "^0.525.0", "maplibre-gl": "^5.6.1", "next": "15.4.1", + "ol": "^10.6.1", + "ol-mapbox-style": "^13.0.1", "react": "19.1.0", "react-day-picker": "^9.8.0", "react-dom": "19.1.0", @@ -812,6 +814,11 @@ "node": ">= 10" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", + "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2153,6 +2160,11 @@ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==" }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==" + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -2825,6 +2837,24 @@ "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==" }, + "node_modules/geotiff": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-2.1.3.tgz", + "integrity": "sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==", + "dependencies": { + "@petamoriken/float16": "^3.4.7", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.2.0", + "xml-utils": "^1.0.2", + "zstddec": "^0.1.0" + }, + "engines": { + "node": ">=10.19" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3007,6 +3037,11 @@ "node": ">=0.10.0" } }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==" + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -3276,6 +3311,11 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/mapbox-to-css-font": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-3.2.0.tgz", + "integrity": "sha512-kvsEfzvLik34BiFj+S19bv5d70l9qSdkUzrq99dvZ9d5POaLyB4vJMQmq3BoJ5D6lFG1GYnMM7o7cm5Jh8YEEg==" + }, "node_modules/maplibre-gl": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.1.tgz", @@ -3517,6 +3557,55 @@ "dev": true, "peer": true }, + "node_modules/ol": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/ol/-/ol-10.6.1.tgz", + "integrity": "sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg==", + "dependencies": { + "@types/rbush": "4.0.0", + "earcut": "^3.0.0", + "geotiff": "^2.1.3", + "pbf": "4.0.1", + "rbush": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/openlayers" + } + }, + "node_modules/ol-mapbox-style": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.0.1.tgz", + "integrity": "sha512-NEUT4rpsOCQz5y8qwJikU7UTdX/U8uGvW1we1urZ6NkONFjPRRxqg+PVit7m2AvBeIyrTL3iYd8mHZw8VJEOyw==", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^23.1.0", + "mapbox-to-css-font": "^3.2.0" + }, + "peerDependencies": { + "ol": "*" + } + }, + "node_modules/ol/node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==" + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -3589,6 +3678,17 @@ "node": ">=4" } }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", @@ -3625,6 +3725,14 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -4252,6 +4360,11 @@ "node": ">=10.13.0" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -4416,6 +4529,11 @@ } } }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -4424,6 +4542,11 @@ "engines": { "node": ">=18" } + }, + "node_modules/zstddec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz", + "integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==" } }, "dependencies": { @@ -4844,6 +4967,11 @@ "integrity": "sha512-y+wTBxelk2xiNofmDOVU7O5WxTHcvOoL3srOM0kxTzKDjQ57kPU0tpnPJ/BWrRnsOwXEv0+3QSbGR7hY4n9LkQ==", "optional": true }, + "@petamoriken/float16": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", + "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==" + }, "@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -5602,6 +5730,11 @@ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==" }, + "@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==" + }, "@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -6132,6 +6265,21 @@ "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==" }, + "geotiff": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-2.1.3.tgz", + "integrity": "sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==", + "requires": { + "@petamoriken/float16": "^3.4.7", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.2.0", + "xml-utils": "^1.0.2", + "zstddec": "^0.1.0" + } + }, "get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6256,6 +6404,11 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" }, + "lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==" + }, "lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -6378,6 +6531,11 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "mapbox-to-css-font": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-3.2.0.tgz", + "integrity": "sha512-kvsEfzvLik34BiFj+S19bv5d70l9qSdkUzrq99dvZ9d5POaLyB4vJMQmq3BoJ5D6lFG1GYnMM7o7cm5Jh8YEEg==" + }, "maplibre-gl": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.1.tgz", @@ -6531,6 +6689,47 @@ "dev": true, "peer": true }, + "ol": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/ol/-/ol-10.6.1.tgz", + "integrity": "sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg==", + "requires": { + "@types/rbush": "4.0.0", + "earcut": "^3.0.0", + "geotiff": "^2.1.3", + "pbf": "4.0.1", + "rbush": "^4.0.0" + }, + "dependencies": { + "pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "requires": { + "resolve-protobuf-schema": "^2.1.0" + } + } + } + }, + "ol-mapbox-style": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.0.1.tgz", + "integrity": "sha512-NEUT4rpsOCQz5y8qwJikU7UTdX/U8uGvW1we1urZ6NkONFjPRRxqg+PVit7m2AvBeIyrTL3iYd8mHZw8VJEOyw==", + "requires": { + "@maplibre/maplibre-gl-style-spec": "^23.1.0", + "mapbox-to-css-font": "^3.2.0" + } + }, + "pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, + "parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==" + }, "pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -6577,6 +6776,11 @@ "resolved": "https://registry.npmjs.org/qss/-/qss-3.0.0.tgz", "integrity": "sha512-ZHoCB3M/3Voev64zhLLUOKDtaEdJ/lymsJJ7R3KBusVZ2ovNiIB7XOq3Xh6V1a8O+Vho+g2B5YElq9zW7D8aQw==" }, + "quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==" + }, "quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", @@ -6602,6 +6806,14 @@ "schema-utils": "^3.0.0" } }, + "rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "requires": { + "quickselect": "^3.0.0" + } + }, "react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -7004,6 +7216,11 @@ "graceful-fs": "^4.1.2" } }, + "web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==" + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -7118,11 +7335,21 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "requires": {} }, + "xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==" + }, "yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true + }, + "zstddec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz", + "integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==" } } } diff --git a/package.json b/package.json index dd3ad3a..489afeb 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "lucide-react": "^0.525.0", "maplibre-gl": "^5.6.1", "next": "15.4.1", + "ol": "^10.6.1", + "ol-mapbox-style": "^13.0.1", "react": "19.1.0", "react-day-picker": "^9.8.0", "react-dom": "19.1.0", @@ -45,4 +47,4 @@ "tw-animate-css": "^1.3.5", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/wind.js b/wind.js new file mode 100644 index 0000000..e69de29