added maplibre-wind lib and reworked windvisualisation

This commit is contained in:
Vasilisk9812 2025-12-10 17:19:50 +09:00
parent 6359ccf9ee
commit 60fe848b0c
8 changed files with 756 additions and 396 deletions

View file

@ -1,128 +1,62 @@
<script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
export let map; // MapLibre map instance from parent component
export let windData;
// Props
let { map, windData }: { map: any; windData: any } = $props();
// State for layer toggles
let showHeatmap = false;
let showVectors = false;
// Note: This is a placeholder implementation
// MapLibre GL JS does not have direct equivalents for leaflet-velocity and leaflet.heat
// These features would need to be implemented using:
// 1. Custom WebGL layers for wind visualization
// 2. Heatmap layers using MapLibre's native heatmap style
// 3. Third-party libraries like deck.gl or mapbox-gl plugins
let showHeatmap = $state(false);
let showParticles = $state(false);
onMount(() => {
if (!map || !windData) return;
if (!map || !windData) {
console.warn('Map or wind data not available');
return;
}
console.log("WindVisualization mounted with MapLibre map");
console.log("WindVisualization component mounted");
console.log("Wind data available:", windData);
// TODO: Implement wind visualization using MapLibre GL JS
// Possible approaches:
// 1. Use MapLibre's native heatmap layer type for heat visualization
// 2. Use deck.gl ScreenGridLayer or HeatmapLayer for advanced heatmaps
// 3. Use custom WebGL shaders for wind particle animation
// 4. Use mapbox-gl-wind plugin (if compatible with MapLibre)
// NOTE: @sakitam-gis/maplibre-wind requires tile-based or image URL sources
// It does not support raw wind data arrays directly
//
// The library expects:
// - TileSource with URL template (e.g., 'https://tiles.example.com/{z}/{x}/{y}.png')
// - ImageSource with image URL and coordinates
//
// To use this library, we would need to:
// 1. Convert wind data to tiles or images
// 2. Serve them via a tile server
// 3. Use TileSource or ImageSource with the URLs
//
// Alternative approaches:
// 1. Use deck.gl with ParticleLayer for raw data visualization
// 2. Use MapLibre's native heatmap layers for color visualization
// 3. Create a custom WebGL layer for particle animation
// 4. Pre-process wind data into tiles/images server-side
});
onDestroy(() => {
// Clean up any layers or resources when component is destroyed
if (map) {
// Remove any added layers
console.log("WindVisualization destroyed");
}
console.log("WindVisualization component destroyed");
});
// Reactive statement for layer updates
$: if (map && windData) {
updateLayers();
}
const updateLayers = () => {
if (!map || !windData) return;
console.log("Updating wind layers:", { showHeatmap, showVectors });
// TODO: Implement layer toggling
// This would involve adding/removing MapLibre layers based on the toggle state
};
</script>
<!--
IMPORTANT: This is a simplified placeholder implementation.
The original Leaflet-based wind visualization used these plugins:
- leaflet-velocity: For wind vector visualization
- leaflet.heat: For heatmap visualization
- leaflet-timedimension: For time-based animation
To fully implement wind visualization in MapLibre GL JS, you would need to:
1. For Wind Vectors:
- Use a custom WebGL layer with particle animation
- Or use deck.gl's ParticleLayer or FlowmapLayer
- Or port/adapt the wind-gl-core library
2. For Heatmap:
- Use MapLibre's native 'heatmap' layer type
- Convert wind data to GeoJSON point features
- Style with appropriate color gradients
3. For Time Dimension:
- Implement custom time controls
- Update data sources based on selected time
- Use requestAnimationFrame for smooth animation
Example MapLibre heatmap implementation:
map.addSource('wind-heat', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: windPoints // Array of GeoJSON point features
}
});
map.addLayer({
id: 'wind-heatmap',
type: 'heatmap',
source: 'wind-heat',
paint: {
'heatmap-weight': ['get', 'intensity'],
'heatmap-intensity': 1,
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(0,0,255,0)',
0.2, 'rgb(0,0,255)',
0.4, 'rgb(0,255,255)',
0.6, 'rgb(0,255,0)',
0.8, 'rgb(255,255,0)',
1, 'rgb(255,0,0)'
],
'heatmap-radius': 20,
'heatmap-opacity': 0.7
}
});
-->
<div class="layer-controls">
<div class="control-group">
<label>
<input type="checkbox" bind:checked={showHeatmap} disabled />
Тепловая карта (TODO)
Тепловая карта
</label>
<label>
<input type="checkbox" bind:checked={showVectors} disabled />
Векторы ветра (TODO)
<input type="checkbox" bind:checked={showParticles} disabled />
Частицы ветра
</label>
</div>
<small style="color: #666; font-size: 11px; margin-top: 8px; display: block;">
Wind visualization requires MapLibre implementation
Wind visualization requires tile/image source
</small>
<small style="color: #999; font-size: 10px; margin-top: 4px; display: block;">
See WindVisualisation.svelte for implementation notes
</small>
</div>
@ -132,28 +66,37 @@
bottom: 30px;
left: 10px;
z-index: 1000;
background: rgba(255, 255, 255, 0.9);
padding: 10px;
background: rgba(255, 255, 255, 0.95);
padding: 10px 12px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
gap: 8px;
}
.control-group label {
display: flex;
align-items: center;
gap: 5px;
gap: 8px;
font-size: 14px;
cursor: pointer;
cursor: not-allowed;
user-select: none;
opacity: 0.5;
}
.control-group label:has(input:disabled) {
opacity: 0.5;
.control-group input[type="checkbox"] {
cursor: not-allowed;
width: 16px;
height: 16px;
}
small {
font-style: italic;
opacity: 0.7;
}
</style>

View file

@ -1,286 +0,0 @@
import * as L from "leaflet";
import { distHaversine, bearingHaversine } from "$lib/mathutil";
// Define an interface for the control's options for type safety.
export interface RulerOptions extends L.ControlOptions {
events?: {
onToggle?: (isActive: boolean) => void;
};
circleMarker?: L.CircleMarkerOptions;
lineStyle?: L.PolylineOptions;
lengthUnit?: {
display?: string;
decimal?: number;
factor?: number | null;
label?: string;
};
angleUnit?: {
display?: string;
decimal?: number;
factor?: number | null;
label?: string;
};
}
// Define an interface for the measurement result.
interface MeasurementResult {
Bearing: number;
Distance: number;
}
// Use a modern TypeScript class that extends L.Control.
export class Ruler extends L.Control {
// Override the default options with our custom ones.
public options: RulerOptions = {
position: "topright",
events: {
onToggle: () => {},
},
circleMarker: {
color: "red",
radius: 2,
},
lineStyle: {
color: "red",
dashArray: "1,6",
},
lengthUnit: {
display: "km",
decimal: 2,
factor: null,
label: "Distance:",
},
angleUnit: {
display: "&deg;",
decimal: 2,
factor: null,
label: "Bearing:",
},
};
// Declare class properties with types.
private _lastClickTime = 0;
private _map?: L.Map;
private _container?: HTMLElement;
private _choice = false;
private _defaultCursor = "";
private _allLayers: L.LayerGroup = L.layerGroup();
private _clickedLatLong: L.LatLng | null = null;
private _clickedPoints: L.LatLng[] = [];
private _totalLength = 0;
private _clickCount = 0;
private _tempLine: L.FeatureGroup = L.featureGroup();
private _tempPoint: L.FeatureGroup = L.featureGroup();
private _pointLayer: L.FeatureGroup = L.featureGroup();
private _polylineLayer: L.FeatureGroup = L.featureGroup();
private _movingLatLong: L.LatLng | null = null;
private _result: MeasurementResult = { Bearing: 0, Distance: 0 };
private _addedLength = 0;
constructor(options?: RulerOptions) {
super(options);
L.Util.setOptions(this, options);
}
public isActive(): boolean {
return this._choice;
}
public onAdd(map: L.Map): HTMLElement {
this._map = map;
this._container = L.DomUtil.create("div", "leaflet-bar leaflet-ruler");
L.DomEvent.disableClickPropagation(this._container);
L.DomEvent.on(this._container, "click", this._toggleMeasure, this);
this._defaultCursor = this._map.getContainer().style.cursor;
this._allLayers = L.layerGroup();
return this._container;
}
public onRemove(): void {
if (this._container) {
L.DomEvent.off(this._container, "click", this._toggleMeasure, this);
}
if (this._choice) {
this._toggleMeasure(); // Turn off measurements
}
}
private _toggleMeasure(): void {
this._choice = !this._choice;
this.options.events?.onToggle?.(this._choice);
this._clickedLatLong = null;
this._clickedPoints = [];
this._totalLength = 0;
if (!this._map || !this._container) return;
const mapContainer = this._map.getContainer();
if (this._choice) {
this._map.doubleClickZoom.disable();
L.DomEvent.on(mapContainer, "keydown", this._escape, this);
L.DomEvent.on(mapContainer, "dblclick", this._closePath, this);
this._container.classList.add("leaflet-ruler-clicked");
this._clickCount = 0;
this._tempLine = L.featureGroup().addTo(this._allLayers);
this._tempPoint = L.featureGroup().addTo(this._allLayers);
this._pointLayer = L.featureGroup().addTo(this._allLayers);
this._polylineLayer = L.featureGroup().addTo(this._allLayers);
this._allLayers.addTo(this._map);
mapContainer.style.cursor = "crosshair";
this._map.on("click", this._clicked, this);
this._map.on("mousemove", this._moving, this);
} else {
this._map.doubleClickZoom.enable();
L.DomEvent.off(mapContainer, "keydown", this._escape, this);
L.DomEvent.off(mapContainer, "dblclick", this._closePath, this);
this._container.classList.remove("leaflet-ruler-clicked");
this._map.removeLayer(this._allLayers);
this._allLayers = L.layerGroup();
mapContainer.style.cursor = this._defaultCursor;
this._map.off("click", this._clicked, this);
this._map.off("mousemove", this._moving, this);
}
}
private _clicked(e: L.LeafletMouseEvent): void {
// hack to prevent adding the same point twice on double click
let clickTime = Date.now();
if (clickTime - this._lastClickTime < 200) {
this._closePath();
return;
}
this._lastClickTime = clickTime;
this._clickedLatLong = e.latlng;
this._clickedPoints.push(this._clickedLatLong);
L.circleMarker(this._clickedLatLong, this.options.circleMarker).addTo(this._pointLayer);
if (this._clickCount > 0 && !e.latlng.equals(this._clickedPoints[this._clickedPoints.length - 2], 0.0001)) {
if (this._movingLatLong) {
L.polyline(
[this._clickedPoints[this._clickCount - 1], this._movingLatLong],
this.options.lineStyle
).addTo(this._polylineLayer);
}
let text: string;
this._totalLength += this._result.Distance;
const angleUnit = this.options.angleUnit!;
const lengthUnit = this.options.lengthUnit!;
if (this._clickCount > 1) {
text = `<b>${angleUnit.label}</b>&nbsp;${this._result.Bearing.toFixed(angleUnit.decimal)}&nbsp;${
angleUnit.display
}<br><b>${lengthUnit.label}</b>&nbsp;${this._totalLength.toFixed(lengthUnit.decimal)}&nbsp;${
lengthUnit.display
}`;
} else {
text = `<b>${angleUnit.label}</b>&nbsp;${this._result.Bearing.toFixed(angleUnit.decimal)}&nbsp;${
angleUnit.display
}<br><b>${lengthUnit.label}</b>&nbsp;${this._result.Distance.toFixed(lengthUnit.decimal)}&nbsp;${
lengthUnit.display
}`;
}
L.circleMarker(this._clickedLatLong, this.options.circleMarker)
.bindTooltip(text, { permanent: true, className: "result-tooltip" })
.addTo(this._pointLayer)
.openTooltip();
}
this._clickCount++;
}
private _moving(e: L.LeafletMouseEvent): void {
if (this._clickedLatLong && this._map) {
this._movingLatLong = e.latlng;
this._tempLine.clearLayers();
this._tempPoint.clearLayers();
this._calculateBearingAndDistance();
this._addedLength = this._result.Distance + this._totalLength;
L.polyline([this._clickedLatLong, this._movingLatLong], this.options.lineStyle).addTo(this._tempLine);
const angleUnit = this.options.angleUnit!;
const lengthUnit = this.options.lengthUnit!;
let text: string;
if (this._clickCount > 1) {
text = `<b>${angleUnit.label}</b>&nbsp;${this._result.Bearing.toFixed(angleUnit.decimal)}&nbsp;${
angleUnit.display
}<br><b>${lengthUnit.label}</b>&nbsp;${this._addedLength.toFixed(lengthUnit.decimal)}&nbsp;${
lengthUnit.display
}<br><div class="plus-length">(+${this._result.Distance.toFixed(lengthUnit.decimal)})</div>`;
} else {
text = `<b>${angleUnit.label}</b>&nbsp;${this._result.Bearing.toFixed(angleUnit.decimal)}&nbsp;${
angleUnit.display
}<br><b>${lengthUnit.label}</b>&nbsp;${this._result.Distance.toFixed(lengthUnit.decimal)}&nbsp;${
lengthUnit.display
}`;
}
L.circleMarker(this._movingLatLong, this.options.circleMarker)
.bindTooltip(text, { sticky: true, offset: L.point(0, -40), className: "moving-tooltip" })
.addTo(this._tempPoint)
.openTooltip();
}
}
private _escape(e: Event): void {
if ((e as KeyboardEvent).key === "Escape") {
if (this._clickCount > 0) {
this._closePath();
} else {
this._toggleMeasure();
}
}
}
private _calculateBearingAndDistance(): void {
if (!this._clickedLatLong || !this._movingLatLong) return;
const f1 = this._clickedLatLong.lat;
const l1 = this._clickedLatLong.lng;
const f2 = this._movingLatLong.lat;
const l2 = this._movingLatLong.lng;
const angleUnit = this.options.angleUnit!;
const lengthUnit = this.options.lengthUnit!;
const brng = bearingHaversine({ lat: f1, lng: l1 }, { lat: f2, lng: l2 });
const distance = distHaversine({ lat: f1, lng: l1 }, { lat: f2, lng: l2 });
if (angleUnit.factor) {
this._result.Bearing = brng * angleUnit.factor;
} else {
this._result.Bearing = brng;
}
if (lengthUnit.factor) {
this._result.Distance = distance * lengthUnit.factor;
} else {
this._result.Distance = distance;
}
this._result = {
Bearing: brng,
Distance: distance,
};
}
private _closePath(): void {
if (!this._map || !this._container) return;
this._map.removeLayer(this._tempLine);
this._map.removeLayer(this._tempPoint);
this._choice = false;
this._toggleMeasure();
}
}
// Factory function for creating the control, maintaining the Leaflet convention.
export const ruler = (options?: RulerOptions) => {
return new Ruler(options);
};

View file

@ -1,8 +1,9 @@
// Define coordinate types (previously from Leaflet)
export type LatLngTuple = [number, number];
export type LatLngTuple = [number, number] | [number, number, number]; // Support 2D and 3D coordinates
export interface LatLngLiteral {
lat: number;
lng: number;
alt?: number; // Optional altitude
}
export type LatLngExpression = LatLngTuple | LatLngLiteral;