This commit is contained in:
tsuki 2025-07-21 21:27:35 +08:00
parent 9321b88df1
commit 37f07671d2
14 changed files with 726 additions and 309 deletions

View File

@ -67,6 +67,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SelectValue placeholder={isMapReady ? "Select a location" : "Loading map..."} />
</SelectTrigger>
<SelectContent>
<SelectItem value="usa">USA</SelectItem>
<SelectItem value="singapore">Singapore</SelectItem>
<SelectItem value="malaysia">Malaysia</SelectItem>
<SelectItem value="china">China</SelectItem>

View File

@ -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`}
>
<ThemeProvider defaultTheme="dark" storageKey="mosaic-theme">
{children}
<TwentyFirstToolbar
config={{
plugins: [ReactPlugin],
}}
/>
<MapProvider>
{children}
<TwentyFirstToolbar
config={{
plugins: [ReactPlugin],
}}
/>
</MapProvider>
</ThemeProvider>
</body>
</html>

View File

@ -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<Map | null>
layers: React.RefObject<any[]>
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<any[]>([])
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,

View File

@ -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<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(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 (
<MapProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="flex items-center gap-2 justify-between w-full">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>October 2024</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<ThemeToggle />
</div>
</header>
<div className="relative h-full w-full flex flex-col">
<MapComponent />
<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" /> */}
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="flex items-center gap-2 justify-between w-full">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>October 2024</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<ThemeToggle />
</div>
</SidebarInset>
</SidebarProvider>
</MapProvider>
</header>
<div className="relative h-full w-full flex flex-col">
<MapComponent />
<Timeline
className={
cn(
"backdrop-blur-lg border shadow-lg",
"bg-background/90 border-border",
"z-10"
)
}
startDate={startDate}
endDate={endDate}
onDateChange={(date) => {
console.log('Selected date:', date);
setTime(date)
}}
/>
</div>
</SidebarInset>
</SidebarProvider>
)
}

View File

@ -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<Props> = ({
onPause,
boxSize = [4, 8],
minZoom = 0.5,
maxZoom = 2,
maxZoom = 8,
initialZoom = 1,
vesicaData,
dateFormat,
@ -148,23 +149,30 @@ export const Timeline: React.FC<Props> = ({
const canvasRef = useRef<HTMLCanvasElement>(null);
const ticksCanvasRef = useRef<HTMLCanvasElement>(null);
const { isPlaying, togglePlay } = useTimeline({
initialDate: currentDate ?? new Date(),
onDateChange(date) {
onDateChange?.(date);
}
})
const [state, setState] = useState<Status>({
isDragging: false,
isLongPress: false,
isPanningTimeline: false,
customLineTimestamp: null,
customLineTimestamp: currentDate?.getTime() ?? new Date().getTime(),
panOffset: 0,
zoomLevel: initialZoom,
});
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null); // 长按计时器
const dragTempXRef = useRef<number | null>(null); // 拖拽时的临时x坐标
const animationFrameRef = useRef<number | null>(null); // 动画帧ID
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
const dragTempXRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
const panStartXRef = useRef<number>(0); // 拖拽开始的x坐标
const panStartOffsetRef = useRef<number>(0); // 拖拽开始时的panOffset
const panTempOffsetRef = useRef<number>(0); // 拖拽时的临时偏移
const hasDraggedTimelineRef = useRef<boolean>(false); // 拖拽标记使用ref避免闪烁
const panStartXRef = useRef<number>(0);
const panStartOffsetRef = useRef<number>(0);
const panTempOffsetRef = useRef<number>(0);
const hasDraggedTimelineRef = useRef<boolean>(false);
const actualVesicaData = vesicaData ?? [];
@ -176,9 +184,16 @@ export const Timeline: React.FC<Props> = ({
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<Props> = ({
// 如果缩放级别没有变化,直接返回
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<Props> = ({
// 计算平移边界限制
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<Props> = ({
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<Uniforms>({
startTimestamp: 0,
@ -437,35 +488,29 @@ export const Timeline: React.FC<Props> = ({
}
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<Props> = ({
// 绑定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<Props> = ({
<ChevronLeft size={10} />
</Button>
<Button variant="secondary" size="icon" className="size-5">
<Play size={10} />
<Button variant="secondary" size="icon" className="size-5" onClick={() => togglePlay()}>
{isPlaying ? <Pause size={10} /> : <Play size={10} />}
</Button>
<Button variant="secondary" size="icon" className="size-5">
<Button variant="secondary" size="icon" className="size-5" onClick={() => forward()}>
<ChevronRight size={10} />
</Button>
@ -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();
}

View File

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

View File

@ -10,7 +10,7 @@ export function MapExample() {
<MapProvider>
<div className="relative w-full h-screen">
<MapComponent
center={[116.4074, 39.9042]} // 北京坐标
center={[103.851959, 1.290270]} // 北京坐标
zoom={10}
/>
<MapControls />

View File

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

View File

@ -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<LocationKey>('singapore')
const [currentLocation, setCurrentLocation] = useState<LocationKey>('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,

121
hooks/use-map-time.ts Normal file
View File

@ -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<NodeJS.Timeout | null>(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
}
}

View File

@ -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<NodeJS.Timeout | null>(null)
const { setTime } = useMap()
const speedIntervals = {
slow: 2000,
normal: 1000,
@ -116,6 +119,7 @@ export function useTimeline({
skipBackward,
changeSpeed,
jumpToDate,
updateDate
updateDate,
setTime
}
}

227
package-lock.json generated
View File

@ -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=="
}
}
}

View File

@ -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",

0
wind.js Normal file
View File