Compare commits
2 Commits
a88dfe1914
...
bc81aeec1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc81aeec1d | ||
|
|
12fad1711c |
46
app/glsl/radar/frag.glsl
Normal file
46
app/glsl/radar/frag.glsl
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform sampler2D u_lut;
|
||||||
|
|
||||||
|
in vec2 lonlat;
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
const float PI = 3.141592653589793;
|
||||||
|
|
||||||
|
const vec4 bound = vec4(65.24686922, 13.12169419,
|
||||||
|
138.85867996, 55.34323806);
|
||||||
|
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = vec2(
|
||||||
|
(lonlat.x - bound.x) / (bound.z - bound.x), // 经度映射到 u
|
||||||
|
1.0 - (lonlat.y - bound.y) / (bound.w - bound.y) // 纬度映射到 v
|
||||||
|
);
|
||||||
|
|
||||||
|
// 调试:可以取消注释下面的行来可视化坐标
|
||||||
|
// fragColor = vec4(uv.x, 0.0, 0.0, 1.0); return; // UV坐标可视化
|
||||||
|
// 显示原始经纬度值(归一化到0-1范围用于可视化)
|
||||||
|
// fragColor = vec4((lonlat.x - 60.0)/100.0, (lonlat.y + 10.0)/70.0, 0.0, 1.0);
|
||||||
|
// return; // 经纬度可视化
|
||||||
|
|
||||||
|
if(any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec4 texColor = texture(u_tex, uv);
|
||||||
|
|
||||||
|
if (texColor.r <= 0.0196 || texColor.r > 0.29411) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
float value = texColor.r * 3.4;
|
||||||
|
value = clamp(value, 0.0, 1.0);
|
||||||
|
|
||||||
|
float alpha = smoothstep(0.07, 0.12, value) * 0.9;
|
||||||
|
if (alpha <= 0.001) discard;
|
||||||
|
|
||||||
|
vec4 lutColor = texture(u_lut, vec2(value, 0.5));
|
||||||
|
fragColor = vec4(lutColor.rgb, alpha);
|
||||||
|
}
|
||||||
128
app/glsl/radar/verx.glsl
Normal file
128
app/glsl/radar/verx.glsl
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
#version 300 es
|
||||||
|
|
||||||
|
layout(location = 0) in vec2 a_pos;
|
||||||
|
layout(location = 1) in vec2 a_tex_coord;
|
||||||
|
|
||||||
|
const float PI = 3.141592653589793;
|
||||||
|
uniform mat4 u_projection_matrix;
|
||||||
|
#define GLOBE_RADIUS 6371008.8
|
||||||
|
uniform highp vec4 u_projection_tile_mercator_coords;
|
||||||
|
uniform highp vec4 u_projection_clipping_plane;
|
||||||
|
uniform highp float u_projection_transition;
|
||||||
|
uniform mat4 u_projection_fallback_matrix;
|
||||||
|
|
||||||
|
vec3 globeRotateVector(vec3 vec,vec2 angles) {
|
||||||
|
vec3 axisRight=vec3(vec.z,0.0,-vec.x);
|
||||||
|
vec3 axisUp=cross(axisRight,vec);
|
||||||
|
axisRight=normalize(axisRight);
|
||||||
|
axisUp=normalize(axisUp);
|
||||||
|
vec2 t=tan(angles);
|
||||||
|
return normalize(vec+axisRight*t.x+axisUp*t.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
mat3 globeGetRotationMatrix(vec3 spherePos) {
|
||||||
|
vec3 axisRight=vec3(spherePos.z,0.0,-spherePos.x);
|
||||||
|
vec3 axisDown=cross(axisRight,spherePos);
|
||||||
|
axisRight=normalize(axisRight);
|
||||||
|
axisDown=normalize(axisDown);
|
||||||
|
return mat3(axisRight,axisDown,spherePos);
|
||||||
|
}
|
||||||
|
|
||||||
|
float circumferenceRatioAtTileY(float tileY) {
|
||||||
|
float mercator_pos_y = u_projection_tile_mercator_coords.y + u_projection_tile_mercator_coords.w*tileY;
|
||||||
|
float spherical_y = 2.0*atan(exp(PI-(mercator_pos_y*PI*2.0))) - PI*0.5;return cos(spherical_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
float projectLineThickness(float tileY) {
|
||||||
|
float thickness=1.0/circumferenceRatioAtTileY(tileY);
|
||||||
|
if (u_projection_transition < 0.999) {
|
||||||
|
return mix(1.0,thickness,u_projection_transition);
|
||||||
|
} else {
|
||||||
|
return thickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 projectToSphere(vec2 translatedPos,vec2 rawPos) {
|
||||||
|
vec2 mercator_pos = u_projection_tile_mercator_coords.xy+u_projection_tile_mercator_coords.zw*translatedPos;
|
||||||
|
vec2 spherical;
|
||||||
|
spherical.x=mercator_pos.x*PI*2.0+PI;
|
||||||
|
spherical.y=2.0*atan(exp(PI-(mercator_pos.y*PI*2.0)))-PI*0.5;
|
||||||
|
float len=cos(spherical.y);
|
||||||
|
vec3 pos=vec3(sin(spherical.x)*len,sin(spherical.y),cos(spherical.x)*len);
|
||||||
|
if (rawPos.y <-32767.5) {
|
||||||
|
pos = vec3(0.0,1.0,0.0);
|
||||||
|
}
|
||||||
|
if (rawPos.y > 32766.5) {
|
||||||
|
pos=vec3(0.0,-1.0,0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 projectToSphere(vec2 posInTile) {
|
||||||
|
return projectToSphere(posInTile,vec2(0.0,0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
vec2 computeLatLon(vec2 posInTile) {
|
||||||
|
vec2 mercator_pos = u_projection_tile_mercator_coords.xy + u_projection_tile_mercator_coords.zw * posInTile;
|
||||||
|
vec2 spherical;
|
||||||
|
spherical.x = mercator_pos.x * PI * 2.0 + PI; // 经度(弧度)
|
||||||
|
spherical.y = 2.0 * atan(exp(PI - (mercator_pos.y * PI * 2.0))) - PI * 0.5; // 纬度(弧度)
|
||||||
|
|
||||||
|
vec2 lonlat_degrees;
|
||||||
|
lonlat_degrees.x = spherical.x * 180.0 / PI; // 经度(角度)
|
||||||
|
lonlat_degrees.y = spherical.y * 180.0 / PI; // 纬度(角度)
|
||||||
|
|
||||||
|
// 确保经度在0-360范围内
|
||||||
|
lonlat_degrees.x = mod(lonlat_degrees.x + 360.0, 360.0);
|
||||||
|
|
||||||
|
return lonlat_degrees;
|
||||||
|
}
|
||||||
|
|
||||||
|
float globeComputeClippingZ(vec3 spherePos) {
|
||||||
|
return (1.0-(dot(spherePos,u_projection_clipping_plane.xyz)+u_projection_clipping_plane.w));
|
||||||
|
}
|
||||||
|
|
||||||
|
vec4 interpolateProjection(vec2 posInTile,vec3 spherePos,float elevation) {
|
||||||
|
vec3 elevatedPos=spherePos*(1.0+elevation/GLOBE_RADIUS);
|
||||||
|
vec4 globePosition=u_projection_matrix*vec4(elevatedPos,1.0);
|
||||||
|
globePosition.z=globeComputeClippingZ(elevatedPos)*globePosition.w;
|
||||||
|
|
||||||
|
if (u_projection_transition > 0.999) {return globePosition;}
|
||||||
|
|
||||||
|
vec4 flatPosition=u_projection_fallback_matrix*vec4(posInTile,elevation,1.0);
|
||||||
|
const float z_globeness_threshold=0.2;
|
||||||
|
|
||||||
|
vec4 result=globePosition;result.z=mix(0.0,globePosition.z,clamp((u_projection_transition-z_globeness_threshold)/(1.0-z_globeness_threshold),0.0,1.0));result.xyw=mix(flatPosition.xyw,globePosition.xyw,u_projection_transition);if ((posInTile.y <-32767.5) || (posInTile.y > 32766.5)) {result=globePosition;const float poles_hidden_anim_percentage=0.02;result.z=mix(globePosition.z,100.0,pow(max((1.0-u_projection_transition)/poles_hidden_anim_percentage,0.0),8.0));}return result;}vec4 interpolateProjectionFor3D(vec2 posInTile,vec3 spherePos,float elevation) {vec3 elevatedPos=spherePos*(1.0+elevation/GLOBE_RADIUS);vec4 globePosition=u_projection_matrix*vec4(elevatedPos,1.0);
|
||||||
|
|
||||||
|
if (u_projection_transition > 0.999) {return globePosition;}
|
||||||
|
|
||||||
|
vec4 fallbackPosition=u_projection_fallback_matrix*vec4(posInTile,elevation,1.0);
|
||||||
|
return mix(fallbackPosition,globePosition,u_projection_transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec4 projectTile(vec2 posInTile) {
|
||||||
|
return interpolateProjection(posInTile,projectToSphere(posInTile),0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec4 projectTile(vec2 posInTile,vec2 rawPos) {
|
||||||
|
return interpolateProjection(posInTile,projectToSphere(posInTile,rawPos),0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec4 projectTileWithElevation(vec2 posInTile,float elevation) {
|
||||||
|
return interpolateProjection(posInTile,projectToSphere(posInTile),elevation);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec4 projectTileFor3D(vec2 posInTile,float elevation) {
|
||||||
|
vec3 spherePos=projectToSphere(posInTile,posInTile);
|
||||||
|
return interpolateProjectionFor3D(posInTile,spherePos,elevation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#define GLOBE
|
||||||
|
|
||||||
|
out vec2 lonlat;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = projectTile(a_pos);
|
||||||
|
lonlat = computeLatLon(a_pos);
|
||||||
|
}
|
||||||
20
app/page.tsx
20
app/page.tsx
@ -22,6 +22,8 @@ import { useMap } from './map-context'
|
|||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Navigation } from './nav'
|
import { Navigation } from './nav'
|
||||||
|
import { WSProvider } from './ws-context'
|
||||||
|
import StatusBar from './status-bar'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -30,14 +32,18 @@ export default function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-row h-full">
|
<div className="flex flex-row h-full">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<WSProvider>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<MapComponent />
|
<StatusBar />
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<MapComponent />
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Timeline />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
</WSProvider>
|
||||||
<Timeline />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
33
app/status-bar.tsx
Normal file
33
app/status-bar.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { useWS } from "./ws-context"
|
||||||
|
|
||||||
|
export default function StatusBar() {
|
||||||
|
|
||||||
|
const { wsStatus } = useWS()
|
||||||
|
|
||||||
|
// 根据WebSocket状态返回对应颜色的圆点
|
||||||
|
const getStatusDot = () => {
|
||||||
|
switch (wsStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return <div className="w-2 h-2 bg-green-500 rounded-full" title="已连接" />
|
||||||
|
case 'connecting':
|
||||||
|
return <div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" title="连接中..." />
|
||||||
|
case 'disconnected':
|
||||||
|
default:
|
||||||
|
return <div className="w-2 h-2 bg-red-500 rounded-full" title="连接断开" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-8 flex items-center justify-end px-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getStatusDot()}
|
||||||
|
<Label className="ml-2 text-xs font-bold text-muted-foreground">
|
||||||
|
{wsStatus}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
app/tl.tsx
18
app/tl.tsx
@ -10,19 +10,8 @@ import { parse } from "date-fns"
|
|||||||
import { useTimeline } from "@/hooks/use-timeline";
|
import { useTimeline } from "@/hooks/use-timeline";
|
||||||
import { Timeline as TimelineEngine, ZoomMode, TimelineConfig } from "@/lib/timeline";
|
import { Timeline as TimelineEngine, ZoomMode, TimelineConfig } from "@/lib/timeline";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { gql, useSubscription } from "@apollo/client";
|
import { useWS } from "./ws-context";
|
||||||
|
|
||||||
const SUBSCRIPTION_QUERY = gql`
|
|
||||||
subscription {
|
|
||||||
statusUpdates {
|
|
||||||
id
|
|
||||||
message
|
|
||||||
status
|
|
||||||
timestamp
|
|
||||||
newestDt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
interface Uniforms {
|
interface Uniforms {
|
||||||
startTimestamp: number; // Unix 时间戳开始
|
startTimestamp: number; // Unix 时间戳开始
|
||||||
@ -79,7 +68,7 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
timelineConfig,
|
timelineConfig,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { isPlaying, togglePlay, currentDatetime, setTime, setTimelineTime, timelineDatetime } = useTimeline({})
|
const { isPlaying, togglePlay, setTime, setTimelineTime, setWsStatus } = useTimeline({})
|
||||||
const [lock, setLock] = useState(false)
|
const [lock, setLock] = useState(false)
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const ticksCanvasRef = useRef<HTMLCanvasElement>(null);
|
const ticksCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
@ -92,8 +81,7 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
currentLevel: null as any
|
currentLevel: null as any
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data } = useWS()
|
||||||
const { data, loading, error } = useSubscription(SUBSCRIPTION_QUERY)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|||||||
85
app/ws-context.tsx
Normal file
85
app/ws-context.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useRef, useState, ReactNode, useEffect } from 'react'
|
||||||
|
import { Map } from 'maplibre-gl';
|
||||||
|
import { gql, useSubscription } from '@apollo/client';
|
||||||
|
|
||||||
|
// 定义MapContext的类型
|
||||||
|
interface WSContextType {
|
||||||
|
wsStatus: WsStatus
|
||||||
|
setWsStatus: (status: WsStatus) => void
|
||||||
|
data: any
|
||||||
|
loading: boolean
|
||||||
|
error: any
|
||||||
|
restart: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WsStatus {
|
||||||
|
CONNECTING = 'connecting',
|
||||||
|
CONNECTED = 'connected',
|
||||||
|
DISCONNECTED = 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建Context
|
||||||
|
const WSContext = createContext<WSContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
// Provider组件的Props类型
|
||||||
|
interface MapProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBSCRIPTION_QUERY = gql`
|
||||||
|
subscription {
|
||||||
|
statusUpdates {
|
||||||
|
id
|
||||||
|
message
|
||||||
|
status
|
||||||
|
timestamp
|
||||||
|
newestDt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
|
||||||
|
// Provider组件
|
||||||
|
export function WSProvider({ children }: MapProviderProps) {
|
||||||
|
|
||||||
|
const [wsStatus, setWsStatus] = useState<WsStatus>(WsStatus.DISCONNECTED)
|
||||||
|
const { data, loading, error, restart } = useSubscription(SUBSCRIPTION_QUERY)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
setWsStatus(WsStatus.CONNECTING)
|
||||||
|
} else if (error) {
|
||||||
|
setWsStatus(WsStatus.DISCONNECTED)
|
||||||
|
} else {
|
||||||
|
setWsStatus(WsStatus.CONNECTED)
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const value: WSContextType = {
|
||||||
|
wsStatus,
|
||||||
|
setWsStatus,
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
restart
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WSContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</WSContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义Hook用于使用MapContext
|
||||||
|
export function useWS() {
|
||||||
|
const context = useContext(WSContext)
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useWS must be used within a WSProvider')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ interface ColorbarProps {
|
|||||||
minValue?: number
|
minValue?: number
|
||||||
maxValue?: number
|
maxValue?: number
|
||||||
unit?: string
|
unit?: string
|
||||||
|
orientation?: 'horizontal' | 'vertical'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Colorbar({
|
export function Colorbar({
|
||||||
@ -20,7 +21,8 @@ export function Colorbar({
|
|||||||
showLabels = true,
|
showLabels = true,
|
||||||
minValue = 0,
|
minValue = 0,
|
||||||
maxValue = 100,
|
maxValue = 100,
|
||||||
unit = ''
|
unit = '',
|
||||||
|
orientation = 'horizontal'
|
||||||
}: ColorbarProps) {
|
}: ColorbarProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
@ -43,21 +45,43 @@ export function Colorbar({
|
|||||||
// 绘制色标
|
// 绘制色标
|
||||||
const imageData = ctx.createImageData(width, height)
|
const imageData = ctx.createImageData(width, height)
|
||||||
|
|
||||||
for (let x = 0; x < width; x++) {
|
if (orientation === 'horizontal') {
|
||||||
const t = x / (width - 1) // 归一化到 0-1
|
// 水平方向:从左到右
|
||||||
const colorIndex = Math.floor(t * 255) * 4
|
for (let x = 0; x < width; x++) {
|
||||||
|
const t = x / (width - 1) // 归一化到 0-1
|
||||||
|
const colorIndex = Math.floor(t * 255) * 4
|
||||||
|
|
||||||
const r = colorMap[colorIndex]
|
const r = colorMap[colorIndex]
|
||||||
const g = colorMap[colorIndex + 1]
|
const g = colorMap[colorIndex + 1]
|
||||||
const b = colorMap[colorIndex + 2]
|
const b = colorMap[colorIndex + 2]
|
||||||
const a = colorMap[colorIndex + 3]
|
const a = colorMap[colorIndex + 3]
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const pixelIndex = (y * width + x) * 4
|
||||||
|
imageData.data[pixelIndex] = r
|
||||||
|
imageData.data[pixelIndex + 1] = g
|
||||||
|
imageData.data[pixelIndex + 2] = b
|
||||||
|
imageData.data[pixelIndex + 3] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 垂直方向:从下到上
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
const pixelIndex = (y * width + x) * 4
|
const t = (height - 1 - y) / (height - 1) // 归一化到 0-1,颠倒方向
|
||||||
imageData.data[pixelIndex] = r
|
const colorIndex = Math.floor(t * 255) * 4
|
||||||
imageData.data[pixelIndex + 1] = g
|
|
||||||
imageData.data[pixelIndex + 2] = b
|
const r = colorMap[colorIndex]
|
||||||
imageData.data[pixelIndex + 3] = a
|
const g = colorMap[colorIndex + 1]
|
||||||
|
const b = colorMap[colorIndex + 2]
|
||||||
|
const a = colorMap[colorIndex + 3]
|
||||||
|
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const pixelIndex = (y * width + x) * 4
|
||||||
|
imageData.data[pixelIndex] = r
|
||||||
|
imageData.data[pixelIndex + 1] = g
|
||||||
|
imageData.data[pixelIndex + 2] = b
|
||||||
|
imageData.data[pixelIndex + 3] = a
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,15 +92,23 @@ export function Colorbar({
|
|||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
ctx.strokeRect(0, 0, width, height)
|
ctx.strokeRect(0, 0, width, height)
|
||||||
|
|
||||||
}, [colorMapType, width, height])
|
}, [colorMapType, width, height, orientation])
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
const x = e.clientX - rect.left
|
let t: number
|
||||||
const t = x / width
|
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
t = x / width
|
||||||
|
} else {
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
t = (height - y) / height // 垂直方向:从下到上
|
||||||
|
}
|
||||||
|
|
||||||
const value = minValue + t * (maxValue - minValue)
|
const value = minValue + t * (maxValue - minValue)
|
||||||
setHoverValue(value)
|
setHoverValue(value)
|
||||||
setIsHovered(true)
|
setIsHovered(true)
|
||||||
@ -100,7 +132,7 @@ export function Colorbar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className={orientation === 'horizontal' ? 'relative' : 'flex items-center'}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="cursor-crosshair"
|
className="cursor-crosshair"
|
||||||
@ -110,20 +142,33 @@ export function Colorbar({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{showLabels && (
|
{showLabels && (
|
||||||
<div className="flex justify-between text-xs text-gray-600 mt-1">
|
orientation === 'horizontal' ? (
|
||||||
<span>{formatValue(minValue)}</span>
|
<div className="flex justify-between text-xs text-gray-600 mt-1">
|
||||||
<span>{formatValue(maxValue)}</span>
|
<span>{formatValue(minValue)}</span>
|
||||||
</div>
|
<span>{formatValue(maxValue)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col justify-between text-xs text-gray-600 ml-2 h-full" style={{ height: `${height}px` }}>
|
||||||
|
<span>{formatValue(maxValue)}</span>
|
||||||
|
<span>{formatValue(minValue)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isHovered && hoverValue !== null && (
|
{isHovered && hoverValue !== null && (
|
||||||
<div
|
<div
|
||||||
className="absolute bg-black text-white text-xs px-2 py-1 rounded pointer-events-none z-10"
|
className="absolute bg-black text-white text-xs px-2 py-1 pointer-events-none z-10"
|
||||||
style={{
|
style={
|
||||||
left: `${(hoverValue - minValue) / (maxValue - minValue) * width}px`,
|
orientation === 'horizontal' ? {
|
||||||
top: '-30px',
|
left: `${(hoverValue - minValue) / (maxValue - minValue) * width}px`,
|
||||||
transform: 'translateX(-50%)'
|
top: '-30px',
|
||||||
}}
|
transform: 'translateX(-50%)'
|
||||||
|
} : {
|
||||||
|
left: `${width + 10}px`,
|
||||||
|
top: `${((maxValue - hoverValue) / (maxValue - minValue)) * height}px`,
|
||||||
|
transform: 'translateY(-50%)'
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{formatValue(hoverValue)}
|
{formatValue(hoverValue)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import maplibregl, { CustomLayerInterface, CustomRenderMethodInput, } from 'maplibre-gl'
|
import maplibregl, { CustomLayerInterface, CustomRenderMethodInput, LogoControl, } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { useMap } from '@/app/map-context'
|
import { useMap } from '@/app/map-context'
|
||||||
import { useMapLocation } from '@/hooks/use-map-location'
|
import { useMapLocation } from '@/hooks/use-map-location'
|
||||||
@ -9,6 +9,8 @@ import { createColorMap, ColorMapType, } from '@/lib/color-maps'
|
|||||||
import { Colorbar } from './colorbar'
|
import { Colorbar } from './colorbar'
|
||||||
import { useRadarTile } from '@/hooks/use-radartile'
|
import { useRadarTile } from '@/hooks/use-radartile'
|
||||||
import { format, formatInTimeZone } from 'date-fns-tz'
|
import { format, formatInTimeZone } from 'date-fns-tz'
|
||||||
|
import vertexSource from '@/app/glsl/radar/verx.glsl'
|
||||||
|
import fragmentSource from '@/app/glsl/radar/frag.glsl'
|
||||||
|
|
||||||
interface MapComponentProps {
|
interface MapComponentProps {
|
||||||
style?: string
|
style?: string
|
||||||
@ -41,6 +43,18 @@ export function MapComponent({
|
|||||||
const [isReady, setIsReady] = useState<boolean>(false)
|
const [isReady, setIsReady] = useState<boolean>(false)
|
||||||
const [currentColorMapType, setCurrentColorMapType] = useState<ColorMapType>(colorMapType)
|
const [currentColorMapType, setCurrentColorMapType] = useState<ColorMapType>(colorMapType)
|
||||||
|
|
||||||
|
// 拖动状态
|
||||||
|
const [colorbarPosition, setColorbarPosition] = useState({ x: 16, y: 16 }) // 从右边和下边的距离
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
// 使用ref来避免频繁的状态更新
|
||||||
|
const dragRef = useRef({
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
startPositionX: 0,
|
||||||
|
startPositionY: 0
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMapReady || !currentDatetime) return;
|
if (!isMapReady || !currentDatetime) return;
|
||||||
const utc_time_str = formatInTimeZone(currentDatetime, 'UTC', 'yyyyMMddHHmmss')
|
const utc_time_str = formatInTimeZone(currentDatetime, 'UTC', 'yyyyMMddHHmmss')
|
||||||
@ -56,12 +70,15 @@ export function MapComponent({
|
|||||||
style: style,
|
style: style,
|
||||||
center: location.center,
|
center: location.center,
|
||||||
zoom: location.zoom,
|
zoom: location.zoom,
|
||||||
|
attributionControl: false, // 禁用默认的版权控制
|
||||||
canvasContextAttributes: {
|
canvasContextAttributes: {
|
||||||
contextType: 'webgl2', // 请求 WebGL2
|
contextType: 'webgl2', // 请求 WebGL2
|
||||||
antialias: true // 打开多重采样抗锯齿
|
antialias: true // 打开多重采样抗锯齿
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
map.on('style.load', () => {
|
map.on('style.load', () => {
|
||||||
map.setProjection({
|
map.setProjection({
|
||||||
type: 'globe'
|
type: 'globe'
|
||||||
@ -82,87 +99,6 @@ export function MapComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vertexSource = `#version 300 es
|
|
||||||
layout(location = 0) in vec2 a_pos;
|
|
||||||
layout(location = 1) in vec2 a_tex_coord;
|
|
||||||
|
|
||||||
${shaderData.vertexShaderPrelude}
|
|
||||||
${shaderData.define}
|
|
||||||
|
|
||||||
out vec2 v_merc; // 归一化墨卡托坐标 (0..1)
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
gl_Position = projectTile(a_pos);
|
|
||||||
|
|
||||||
// 用 MapLibre 注入的 u_projection_tile_mercator_coords 把 tile 坐标变为 mercator 归一坐标
|
|
||||||
// merc = offset.xy + scale.zw * a_pos
|
|
||||||
v_merc = u_projection_tile_mercator_coords.xy +
|
|
||||||
u_projection_tile_mercator_coords.zw * a_pos;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// WebGL2 fragment shader
|
|
||||||
const fragmentSource = `#version 300 es
|
|
||||||
precision highp float;
|
|
||||||
|
|
||||||
uniform sampler2D u_tex;
|
|
||||||
uniform sampler2D u_lut;
|
|
||||||
|
|
||||||
in vec2 v_merc; // 来自 VS 的归一化墨卡托
|
|
||||||
out vec4 fragColor;
|
|
||||||
|
|
||||||
const float PI = 3.141592653589793;
|
|
||||||
// 假设 bound = vec4(west, south, east, north) in degrees
|
|
||||||
const vec4 bound = vec4(65.24686921730095, 11.90274236858339,
|
|
||||||
138.85323419021077, 55.34323805611308);
|
|
||||||
|
|
||||||
// mercator -> (lon, lat) in radians
|
|
||||||
vec2 mercatorToLonLat(vec2 merc) {
|
|
||||||
float lon = merc.x * 2.0 * PI - PI; // 修正这里:-PI
|
|
||||||
float lat = 2.0 * atan(exp(PI - merc.y * 2.0 * PI)) - 0.5 * PI;
|
|
||||||
return vec2(lon, lat);
|
|
||||||
}
|
|
||||||
|
|
||||||
vec2 rad2deg(vec2 r) { return r * (180.0 / PI); }
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// 归一化墨卡托 -> 经纬度(度)
|
|
||||||
vec2 lonlat = rad2deg(mercatorToLonLat(v_merc));
|
|
||||||
|
|
||||||
// 经纬度 -> 纹理UV(按 bound 线性映射)
|
|
||||||
vec2 uv = vec2(
|
|
||||||
(lonlat.x - bound.x) / (bound.z - bound.x),
|
|
||||||
1.0-(lonlat.y - bound.y) / (bound.w - bound.y)
|
|
||||||
);
|
|
||||||
// 如果超出范围直接丢弃,避免采到边界垃圾
|
|
||||||
if(any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) {
|
|
||||||
discard;
|
|
||||||
}
|
|
||||||
|
|
||||||
vec4 texColor = texture(u_tex, uv);
|
|
||||||
|
|
||||||
if (texColor.r <= 0.0196 || texColor.r > 0.29411) {
|
|
||||||
discard;
|
|
||||||
}
|
|
||||||
|
|
||||||
float value = texColor.r * 3.4;
|
|
||||||
|
|
||||||
|
|
||||||
value = clamp(value, 0.0, 1.0);
|
|
||||||
|
|
||||||
// 软阈值,避免全场被 return
|
|
||||||
float alpha = smoothstep(0.07, 0.12, value) * 0.9;
|
|
||||||
if (alpha <= 0.001) discard;
|
|
||||||
|
|
||||||
vec4 lutColor = texture(u_lut, vec2(value, 0.5));
|
|
||||||
fragColor = vec4(lutColor.rgb, alpha);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log(vertexSource, fragmentSource)
|
|
||||||
|
|
||||||
// Helper function to compile shader
|
// Helper function to compile shader
|
||||||
const compileShader = (source: string, type: number): WebGLShader | null => {
|
const compileShader = (source: string, type: number): WebGLShader | null => {
|
||||||
const shader = gl.createShader(type);
|
const shader = gl.createShader(type);
|
||||||
@ -542,6 +478,73 @@ export function MapComponent({
|
|||||||
}
|
}
|
||||||
}, [currentColorMapType, isReady])
|
}, [currentColorMapType, isReady])
|
||||||
|
|
||||||
|
// 拖动事件处理函数
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(true)
|
||||||
|
|
||||||
|
// 记录拖动开始时的鼠标位置和colorbar位置
|
||||||
|
dragRef.current = {
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
startPositionX: colorbarPosition.x,
|
||||||
|
startPositionY: colorbarPosition.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局鼠标事件监听
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) return
|
||||||
|
|
||||||
|
let animationFrameId: number
|
||||||
|
|
||||||
|
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||||
|
// 使用requestAnimationFrame来优化性能
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(() => {
|
||||||
|
const mapContainer = document.querySelector('.h-full.w-full.relative') as HTMLElement
|
||||||
|
if (!mapContainer) return
|
||||||
|
|
||||||
|
const containerRect = mapContainer.getBoundingClientRect()
|
||||||
|
|
||||||
|
// 计算鼠标移动的总距离
|
||||||
|
const deltaX = dragRef.current.startX - e.clientX
|
||||||
|
const deltaY = dragRef.current.startY - e.clientY
|
||||||
|
|
||||||
|
// 计算新位置(相对于容器右下角的距离)
|
||||||
|
const newX = dragRef.current.startPositionX + deltaX
|
||||||
|
const newY = dragRef.current.startPositionY + deltaY
|
||||||
|
|
||||||
|
// 限制在容器范围内
|
||||||
|
const clampedX = Math.max(0, Math.min(newX, containerRect.width - 50)) // 预留colorbar宽度
|
||||||
|
const clampedY = Math.max(0, Math.min(newY, containerRect.height - 220)) // 预留colorbar高度
|
||||||
|
|
||||||
|
setColorbarPosition({ x: clampedX, y: clampedY })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGlobalMouseUp = () => {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleGlobalMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleGlobalMouseUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
|
document.removeEventListener('mousemove', handleGlobalMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleGlobalMouseUp)
|
||||||
|
}
|
||||||
|
}, [isDragging]) // 只依赖isDragging,避免频繁重新创建事件监听器
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative">
|
<div className="h-full w-full relative">
|
||||||
<div
|
<div
|
||||||
@ -550,16 +553,27 @@ export function MapComponent({
|
|||||||
style={{ minHeight: '400px' }}
|
style={{ minHeight: '400px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Colorbar 在右下角 */}
|
{/* 可拖动的 Colorbar */}
|
||||||
<div className="absolute bottom-4 right-4 bg-white/90 backdrop-blur-sm rounded-lg p-3 shadow-lg border">
|
<div
|
||||||
<div className="text-xs text-gray-600 mb-2 font-medium">色标</div>
|
data-colorbar
|
||||||
|
className={`absolute select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
|
style={{
|
||||||
|
right: `${colorbarPosition.x}px`,
|
||||||
|
bottom: `${colorbarPosition.y}px`,
|
||||||
|
transform: isDragging ? 'scale(1.02)' : 'scale(1)',
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.1s ease-out',
|
||||||
|
willChange: isDragging ? 'transform' : 'auto'
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
<Colorbar
|
<Colorbar
|
||||||
colorMapType={currentColorMapType}
|
colorMapType={currentColorMapType}
|
||||||
width={180}
|
width={16}
|
||||||
height={16}
|
height={220}
|
||||||
minValue={0}
|
minValue={0}
|
||||||
maxValue={75}
|
maxValue={75}
|
||||||
unit="dBZ"
|
unit="dBZ"
|
||||||
|
orientation="vertical"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,6 +24,12 @@ interface UseTimelineOptions {
|
|||||||
autoUpdate?: boolean
|
autoUpdate?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WsStatus {
|
||||||
|
CONNECTING = 'connecting',
|
||||||
|
CONNECTED = 'connected',
|
||||||
|
DISCONNECTED = 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
export function useTimeline({
|
export function useTimeline({
|
||||||
startDate = subDays(new Date(), 30),
|
startDate = subDays(new Date(), 30),
|
||||||
endDate = new Date(),
|
endDate = new Date(),
|
||||||
@ -35,6 +41,7 @@ export function useTimeline({
|
|||||||
const [speed, setSpeed] = useState<'slow' | 'normal' | 'fast'>('normal')
|
const [speed, setSpeed] = useState<'slow' | 'normal' | 'fast'>('normal')
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const { setTime, currentDatetime, setTimelineTime, timelineDatetime } = useMap()
|
const { setTime, currentDatetime, setTimelineTime, timelineDatetime } = useMap()
|
||||||
|
const [wsStatus, setWsStatus] = useState<WsStatus>(WsStatus.DISCONNECTED)
|
||||||
|
|
||||||
const updateDate = useCallback((newDate: Date) => {
|
const updateDate = useCallback((newDate: Date) => {
|
||||||
setTime(newDate)
|
setTime(newDate)
|
||||||
@ -132,6 +139,8 @@ export function useTimeline({
|
|||||||
changeSpeed,
|
changeSpeed,
|
||||||
jumpToDate,
|
jumpToDate,
|
||||||
updateDate,
|
updateDate,
|
||||||
setTime
|
setTime,
|
||||||
|
wsStatus,
|
||||||
|
setWsStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -266,7 +266,7 @@ export function createMeteorologicalColorMap(): Uint8Array {
|
|||||||
for (let j = 0; j < colorRanges.length; j++) {
|
for (let j = 0; j < colorRanges.length; j++) {
|
||||||
const range = colorRanges[j];
|
const range = colorRanges[j];
|
||||||
if (value >= range.range[0] && value <= range.range[1]) {
|
if (value >= range.range[0] && value <= range.range[1]) {
|
||||||
// 使用区间的固定颜色,不进行插值
|
// 使用固定颜色,不进行插值
|
||||||
r = range.color[0];
|
r = range.color[0];
|
||||||
g = range.color[1];
|
g = range.color[1];
|
||||||
b = range.color[2];
|
b = range.color[2];
|
||||||
|
|||||||
22
wind.glsl
22
wind.glsl
@ -63,6 +63,24 @@ vec3 projectToSphere(vec2 posInTile) {
|
|||||||
return projectToSphere(posInTile,vec2(0.0,0.0));
|
return projectToSphere(posInTile,vec2(0.0,0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算纬度和经度(以角度为单位)
|
||||||
|
vec2 computeLatLon(vec2 posInTile) {
|
||||||
|
vec2 mercator_pos = u_projection_tile_mercator_coords.xy + u_projection_tile_mercator_coords.zw * posInTile;
|
||||||
|
vec2 spherical;
|
||||||
|
spherical.x = mercator_pos.x * PI * 2.0 + PI; // 经度(弧度)
|
||||||
|
spherical.y = 2.0 * atan(exp(PI - (mercator_pos.y * PI * 2.0))) - PI * 0.5; // 纬度(弧度)
|
||||||
|
|
||||||
|
// 转换为角度
|
||||||
|
vec2 lonlat_degrees;
|
||||||
|
lonlat_degrees.x = spherical.x * 180.0 / PI; // 经度(角度)
|
||||||
|
lonlat_degrees.y = spherical.y * 180.0 / PI; // 纬度(角度)
|
||||||
|
|
||||||
|
// 确保经度在0-360范围内
|
||||||
|
lonlat_degrees.x = mod(lonlat_degrees.x + 360.0, 360.0);
|
||||||
|
|
||||||
|
return lonlat_degrees;
|
||||||
|
}
|
||||||
|
|
||||||
float globeComputeClippingZ(vec3 spherePos) {
|
float globeComputeClippingZ(vec3 spherePos) {
|
||||||
return (1.0-(dot(spherePos,u_projection_clipping_plane.xyz)+u_projection_clipping_plane.w));
|
return (1.0-(dot(spherePos,u_projection_clipping_plane.xyz)+u_projection_clipping_plane.w));
|
||||||
}
|
}
|
||||||
@ -104,9 +122,9 @@ vec4 projectTileFor3D(vec2 posInTile,float elevation) {
|
|||||||
|
|
||||||
#define GLOBE
|
#define GLOBE
|
||||||
|
|
||||||
out vec2 v_tex_coord;
|
out vec2 lonlat;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
gl_Position = projectTile(a_pos);
|
gl_Position = projectTile(a_pos);
|
||||||
v_tex_coord = a_tex_coord;
|
lonlat = computeLatLon(a_pos);
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user