Files
SMATEC-FRONTEND/src/pages/Slave/SGW/Map/components/BaseMap.ts

1201 lines
33 KiB
TypeScript

import { Feature, Map as OLMap, View } from 'ol';
import { defaults as defaultControls } from 'ol/control';
import { Coordinate } from 'ol/coordinate';
import { easeOut } from 'ol/easing';
import { Extent } from 'ol/extent';
import { Circle, LineString, Point, Polygon } from 'ol/geom';
import { Draw, Modify, Snap } from 'ol/interaction';
import BaseLayer from 'ol/layer/Base';
import TileLayer from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import { unByKey } from 'ol/Observable';
import Overlay from 'ol/Overlay';
import { fromLonLat, toLonLat } from 'ol/proj';
import { getVectorContext } from 'ol/render';
import { XYZ } from 'ol/source';
import VectorSource from 'ol/source/Vector';
import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style';
import { BASEMAP_ATTRIBUTIONS, BASEMAP_URL } from '../config/MapConfig';
import { GPSParseResult, PointData, ZoneData } from '../type';
import getCircleStyleFromData from './CircleStyle';
import getZoneStyleFromData from './PolygonStyle';
import getPolylineStyleFromData from './PolylineStyle';
import { getShipStyleFromData } from './ShipIconStyle';
/**
* BaseMap class - A reusable core map controller for OpenLayers
* This class provides essential map functionality without React dependencies
*/
export class BaseMap {
private map: OLMap | null = null;
private layers: Map<string, BaseLayer> = new Map();
private vectorLayers: Map<string, VectorLayer<VectorSource>> = new Map();
private overlays: Map<string, Overlay> = new Map();
private currentBaseLayerType: 'osm' | 'satellite' | 'dark' = 'osm';
private drawInteraction: Draw | null = null;
private modifyInteraction: Modify | null = null;
private snapInteraction: Snap | null = null;
private source: VectorSource | null = null;
/**
* Initialize the map with a target HTML element
* @param target - The HTML element to render the map in
* @returns The initialized OpenLayers Map instance
*/
initMap(target: HTMLElement): OLMap {
// Create base layers
const baseLayers = this.createBaseLayers();
// Set the initial base layer
const initialBaseLayer = baseLayers[this.currentBaseLayerType];
initialBaseLayer.set('isBaseLayer', true);
initialBaseLayer.set('id', `base-${this.currentBaseLayerType}`);
// Initialize the map
this.map = new OLMap({
target,
controls: defaultControls({
zoom: false,
rotate: false,
attribution: false,
}),
layers: [initialBaseLayer],
view: new View({
center: fromLonLat([0, 0]),
zoom: 2,
projection: 'EPSG:3857',
}),
});
// console.log(
// `Map initialized with base layer: ${this.currentBaseLayerType}`,
// );
return this.map;
}
/**
* Destroy the map and clean up resources
*/
destroyMap(): void {
if (this.map) {
this.map.setTarget(undefined);
this.map = null;
}
this.layers.clear();
this.vectorLayers.clear();
}
/**
* Get the current map instance
* @returns The OpenLayers Map instance or null if not initialized
*/
getMap(): OLMap | null {
return this.map;
}
calculateScale = (zoom: number) => {
// console.log(`calculateScale called with zoom: ${zoom}`); // Debug
const minZoom = 5; // Zoom tối thiểu
const maxZoom = 14; // Zoom tối đa
const minScale = 0.1; // Scale nhỏ nhất
const maxScale = 0.4; // Scale lớn nhất
const clampedZoom = Math.min(Math.max(zoom, minZoom), maxZoom);
const scale =
minScale +
((clampedZoom - minZoom) / (maxZoom - minZoom)) * (maxScale - minScale);
// console.log(`Calculated scale: ${scale}`); // Debug
return scale;
};
/**
* Set the view with center and zoom
* @param center - Center coordinate as [longitude, latitude]
* @param zoom - Zoom level
*/
setView(center: [number, number], zoom: number): void {
const view = this.map?.getView();
if (view) {
view.setCenter(fromLonLat(center));
view.setZoom(zoom);
}
}
/**
* Set the center of the map
* @param center - Center coordinate as [longitude, latitude]
*/
setCenter(center: [number, number]): void {
const view = this.map?.getView();
if (view) {
view.setCenter(fromLonLat(center));
}
}
/**
* Set the zoom level of the map
* @param zoom - Zoom level
*/
setZoom(zoom: number): void {
const view = this.map?.getView();
if (view) {
view.setZoom(zoom);
}
}
/**
* Fit the map to a given extent
* @param extent - Extent to fit to
* @param padding - Optional padding in pixels [top, right, bottom, left]
*/
fitExtent(extent: Extent, padding?: number[]): void {
const view = this.map?.getView();
if (view && this.map) {
view.fit(extent, {
padding: padding || [20, 20, 20, 20],
duration: 1000,
});
}
}
/**
* Get the current view instance
* @returns The OpenLayers View instance or null
*/
getView(): View | null {
return this.map?.getView() || null;
}
/**
* Debug method to list all layers
*/
debugLayers(): void {
if (!this.map) {
console.log('Map not initialized');
return;
}
console.log('=== All Layers ===');
const layers = this.map.getLayers().getArray();
layers.forEach((layer, index) => {
const baseLayer = layer as BaseLayer;
console.log(`Layer ${index}:`, {
id: baseLayer.get('id'),
name: baseLayer.get('name'),
type: baseLayer.get('type'),
visible: baseLayer.getVisible(),
isBaseLayer: baseLayer.get('isBaseLayer'),
});
});
console.log('==================');
}
/**
* Add a layer to the map
* @param layer - The base layer to add
*/
addLayer(layer: BaseLayer): void {
if (this.map) {
// Get the layer ID from properties or generate a unique ID
const layerId = layer.get('id') || `layer_${Date.now()}`;
// Ensure the layer has an ID property
if (!layer.get('id')) {
layer.set('id', layerId);
}
this.layers.set(layerId, layer);
// Track vector layers separately
if (layer instanceof VectorLayer) {
this.vectorLayers.set(layerId, layer);
}
this.map.addLayer(layer);
// console.log(`Added layer with ID: ${layerId}`);
}
}
/**
* Remove a layer by ID
* @param layerId - The ID of the layer to remove
*/
removeLayer(layerId: string): void {
const layer = this.layers.get(layerId);
if (layer && this.map) {
this.map.removeLayer(layer);
this.layers.delete(layerId);
this.vectorLayers.delete(layerId);
}
}
/**
* Get a layer by ID
* @param layerId - The ID of the layer to retrieve
* @returns The layer instance or undefined
*/
getLayerById(layerId: string): BaseLayer | undefined {
return this.layers.get(layerId);
}
/**
* Toggle layer visibility
* @param layerId - The ID of the layer
* @param visible - Whether the layer should be visible
*/
toggleLayer(layerId: string, visible: boolean): void {
const layer = this.layers.get(layerId);
if (layer) {
layer.setVisible(visible);
}
}
/**
* Set the base layer type
* @param type - The base layer type ('osm', 'satellite', or 'dark')
*/
setBaseLayer(type: 'osm' | 'satellite' | 'dark'): void {
if (!this.map) return;
const baseLayers = this.createBaseLayers();
const layers = this.map.getLayers().getArray();
// Remove existing base layer
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i] as BaseLayer;
if (layer instanceof TileLayer && layer.get('isBaseLayer')) {
this.map.removeLayer(layer);
break;
}
}
// Add new base layer
const newBaseLayer = baseLayers[type];
newBaseLayer.set('isBaseLayer', true);
newBaseLayer.set('id', `base-${type}`);
this.map.getLayers().insertAt(0, newBaseLayer);
this.map.render(); // Force re-render
this.currentBaseLayerType = type;
}
/**
* Get the current base layer
* @returns The current base layer or null
*/
getBaseLayer(): BaseLayer | null {
if (!this.map) return null;
const layers = this.map.getLayers().getArray();
for (let i = 0; i < layers.length; i++) {
const layer = layers[i] as BaseLayer;
if (layer instanceof TileLayer && layer.get('isBaseLayer')) {
return layer;
}
}
return null;
}
getZoom(): number {
return this.getView()?.getZoom() || 5;
}
zoomToFeaturesInLayer(layerId: string): void {
const vectorLayer = this.getLayerById(layerId);
if (vectorLayer && vectorLayer instanceof VectorLayer) {
const features = vectorLayer.getSource()?.getFeatures();
if (features && features.length > 0) {
this.flyToFeature(features);
}
}
}
/**
* Add a point feature to a vector layer
* @param layerId - The ID of the vector layer
* @param coordinate - The coordinate as [longitude, latitude]
* @returns The created feature
*/
addPoint(
layerId: string,
coordinate: [number, number],
data: PointData,
): Feature {
const vectorLayer = this.vectorLayers.get(layerId);
if (!vectorLayer) {
throw new Error(`Vector layer with ID '${layerId}' not found`);
}
// Ensure layer has style function for optimized rendering
this.setupLayerStyle(layerId);
const pointGeometry = new Point(fromLonLat(coordinate));
// const state_level = data.thing?.metadata?.state_level || 10;
// // Pre-parse GPS data to avoid parsing in render loop
const gpsData = data.thing?.metadata?.gps
? JSON.parse(data.thing.metadata.gps)
: {};
// const scale = this.calculateScale(this.getZoom());
const feature = new Feature({
geometry: pointGeometry,
gpsData, // Store parsed data
...data,
});
vectorLayer.getSource()?.addFeature(feature);
return feature;
}
private setupLayerStyle(layerId: string): void {
const layer = this.vectorLayers.get(layerId);
if (layer && !layer.get('hasShipStyle')) {
layer.setStyle((feature) => {
// Get data from the feature itself
const featureData = feature.getProperties() as PointData;
// Check if it's a ship feature (has 'thing')
if (!featureData.thing) {
return [];
}
const zoom = this.getZoom();
const scale = this.calculateScale(zoom);
const gpsData: GPSParseResult = feature.get('gpsData') || {};
const state_level = featureData.thing.metadata?.state_level;
return getShipStyleFromData({
state_level: state_level !== undefined ? state_level : 4,
gpsData,
scale,
pointData: featureData,
});
});
layer.set('hasShipStyle', true);
}
}
/**
* Add a polygon feature to a vector layer
* @param layerId - The ID of the vector layer
* @param coordinates - The polygon coordinates as array of rings
* @returns The created feature
*/
addPolygon(
layerId: string,
coordinates: number[][][],
data: ZoneData,
metadata?: any,
): Feature {
console.log('Metadata in BaseMap:', metadata);
const vectorLayer = this.vectorLayers.get(layerId);
if (!vectorLayer) {
throw new Error(`Vector layer with ID '${layerId}' not found`);
}
// Transform coordinates from lon/lat to map projection
const transformedCoordinates = coordinates.map((ring) =>
ring.map((coord) => fromLonLat(coord as [number, number])),
);
const polygonGeometry = new Polygon(transformedCoordinates);
const feature = new Feature({
geometry: polygonGeometry,
polygonId: metadata, // Set polygonId trực tiếp thay vì metadata object
});
// Apply default style
feature.setStyle(getZoneStyleFromData({ zoneData: data }));
vectorLayer.getSource()?.addFeature(feature);
return feature;
}
/**
* Add a polyline feature to a vector layer
* @param layerId - The ID of the vector layer
* @param coordinates - The polyline coordinates as array of points
* @param strokeColor - Stroke color (default: 'blue')
* @param strokeWidth - Stroke width (default: 2)
* @returns The created feature
*/
addPolyline(
layerId: string,
coordinates: number[][],
data?: ZoneData,
): Feature {
const vectorLayer = this.vectorLayers.get(layerId);
if (!vectorLayer) {
throw new Error(`Vector layer with ID '${layerId}' not found`);
}
// Transform coordinates from lon/lat to map projection
const transformedCoordinates = coordinates.map((coord) =>
fromLonLat(coord as [number, number]),
);
const lineStringGeometry = new LineString(transformedCoordinates);
const feature = new Feature({
geometry: lineStringGeometry,
});
// Apply default style
feature.setStyle(
data
? getPolylineStyleFromData({ zoneData: data })
: new Style({
stroke: new Stroke({
color: 'blue',
width: 2,
}),
}),
);
vectorLayer.getSource()?.addFeature(feature);
return feature;
}
/**
* Add a circle feature to a vector layer
* @param layerId - The ID of the vector layer
* @param center - The center coordinate as [longitude, latitude]
* @param radius - The radius in meters
* @param fillColor - Fill color (default: 'rgba(255, 0, 0, 0.3)')
* @param strokeColor - Stroke color (default: 'red')
* @param strokeWidth - Stroke width (default: 2)
* @returns The created feature
*/
addCircle(
layerId: string,
center: [number, number],
radius: number,
data?: ZoneData,
): Feature {
const vectorLayer = this.vectorLayers.get(layerId);
if (!vectorLayer) {
throw new Error(`Vector layer with ID '${layerId}' not found`);
}
console.log('Geom Radius: ', radius);
// Transform center from lon/lat to map projection
const transformedCenter = fromLonLat(center);
// Create Circle geometry - radius should be in map units (meters)
const circleGeometry = new Circle(transformedCenter, radius);
const feature = new Feature({
geometry: circleGeometry,
data: data, // Store data for potential later use
});
// Apply style based on ZoneData or default style
if (data) {
feature.setStyle(getCircleStyleFromData({ zoneData: data }));
} else {
// Default circle style when no ZoneData provided
// For Circle geometry, we use fill/stroke directly, not image
feature.setStyle(
new Style({
fill: new Fill({ color: 'rgba(255, 0, 0, 0.3)' }),
stroke: new Stroke({ color: 'red', width: 2 }),
}),
);
}
vectorLayer.getSource()?.addFeature(feature);
return feature;
}
/**
* Clear all features from a vector layer
* @param layerId - The ID of the vector layer
*/
clearFeatures(layerId: string): void {
const vectorLayer = this.vectorLayers.get(layerId);
if (vectorLayer) {
vectorLayer.getSource()?.clear();
}
}
/**
* Register a click event handler
* @param callback - Function called when map is clicked
*/
onClick(callback: (feature?: Feature) => void): void {
if (!this.map) return;
this.map.on('click', (event) => {
const features = this.map!.getFeaturesAtPixel(event.pixel, {
hitTolerance: 5, // Tolerance 5px để giảm hit detection calls
});
if (features.length > 1) return undefined;
const feature =
features.length === 1 ? (features[0] as Feature) : undefined;
callback(feature);
});
}
/**
* Register a click event handler that returns all features at the click location
* @param callback - Function called when map is clicked, returns array of features
*/
onClickMultiple(callback: (features: Feature[]) => void): void {
if (!this.map) return;
this.map.on('click', (event) => {
const features = this.map!.getFeaturesAtPixel(event.pixel, {
hitTolerance: 5, // Tolerance 5px để giảm hit detection calls
});
const featureArray = features.length > 1 ? (features as Feature[]) : [];
callback(featureArray);
});
}
/**
* Register a pointer move event handler
* @param callback - Function called when pointer moves over the map
*/
onPointerMove(callback: (feature?: Feature) => void): void {
if (!this.map) return;
let lastFeature: Feature | undefined;
this.map.on('pointermove', (event) => {
if (event.dragging) {
return;
}
const features = this.map!.getFeaturesAtPixel(event.pixel);
const feature =
features.length > 0 ? (features[0] as Feature) : undefined;
if (feature !== lastFeature) {
lastFeature = feature;
callback(feature);
}
});
}
/**
* Transform longitude/latitude to map projection
* @param coord - Coordinate as [longitude, latitude]
* @returns Transformed coordinate
*/
fromLonLat(coord: Coordinate): Coordinate {
return fromLonLat(coord);
}
/**
* Transform map projection to longitude/latitude
* @param coord - Coordinate in map projection
* @returns Coordinate as [longitude, latitude]
*/
toLonLat(coord: Coordinate): Coordinate {
return toLonLat(coord);
}
/**
* Zoom to fit the given features
* @param features - Array of features to zoom to
* @param padding - Optional padding in pixels
* @param maxZoom - Maximum zoom level
*/
zoomToFeatures(
features: Feature[],
padding: number[] = [300, 300, 300, 300],
maxZoom: number = 12,
): void {
if (!this.map) {
console.error('Map is not initialized');
return;
}
if (!features || features.length === 0) {
console.log('No features to zoom to');
return;
}
// Create extent from features
const extent = features.reduce((acc, feature) => {
const geometry = feature.getGeometry();
if (geometry) {
const featureExtent = geometry.getExtent();
if (acc === null) {
return featureExtent;
}
return [
Math.min(acc[0], featureExtent[0]),
Math.min(acc[1], featureExtent[1]),
Math.max(acc[2], featureExtent[2]),
Math.max(acc[3], featureExtent[3]),
];
}
return acc;
}, null as number[] | null);
if (extent) {
this.getView()?.fit(extent, {
padding,
maxZoom,
duration: 500,
});
}
}
/**
* Fly to specific feature(s) with animation
* @param feature - Single feature or array of features to fly to
* @param done - Callback function when animation completes
* @param padding - Optional padding in pixels
* @param maxZoom - Maximum zoom level for single feature
*/
flyToFeature(
feature: Feature | Feature[],
done: (complete: boolean) => void = () => {},
padding: number[] = [300, 300, 300, 300],
maxZoom: number = 8,
): void {
if (!this.map) {
console.error('Map is not initialized');
return;
}
const features = Array.isArray(feature) ? feature : [feature];
if (!features || features.length === 0) {
console.error('No feature provided for flyToFeature');
return;
}
const view = this.map.getView();
if (!view) return;
// Create extent from features
const extent = features.reduce((acc, feature) => {
const geometry = feature.getGeometry();
if (geometry) {
const featureExtent = geometry.getExtent();
if (acc === null) {
return featureExtent;
}
return [
Math.min(acc[0], featureExtent[0]),
Math.min(acc[1], featureExtent[1]),
Math.max(acc[2], featureExtent[2]),
Math.max(acc[3], featureExtent[3]),
];
}
return acc;
}, null as number[] | null);
if (!extent) {
console.error('Empty extent for selected features');
return;
}
const duration = 1000;
const currentZoom = view.getZoom() || 5;
let parts = 2;
let called = false;
const callback = (complete: boolean) => {
parts--;
if (called) {
return;
}
if (parts === 0 || !complete) {
called = true;
done(complete);
}
};
// First fit to the extent
view.fit(extent, {
padding,
maxZoom: features.length === 1 ? maxZoom : 10,
duration,
});
// Then animate with a zoom effect
view.animate(
{
zoom: currentZoom - 1,
duration: duration / 2,
},
{
zoom: features.length === 1 ? maxZoom + 2 : 10,
duration: duration / 2,
},
callback,
);
}
/**
* Create base layer instances
* @returns Object containing base layers
*/
private createBaseLayers(): Record<
'osm' | 'satellite' | 'dark',
TileLayer<XYZ>
> {
return {
osm: new TileLayer({
source: new XYZ({
url: BASEMAP_URL,
attributions: BASEMAP_ATTRIBUTIONS,
}),
properties: { type: 'osm' },
}),
satellite: new TileLayer({
source: new XYZ({
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attributions: 'Esri',
}),
properties: { type: 'satellite' },
}),
dark: new TileLayer({
source: new XYZ({
url: 'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png',
attributions: '© Stadia Maps',
}),
properties: { type: 'dark' },
}),
};
}
/**
* Initialize draw and modify interactions for features
* @param layerId - The ID of the vector layer to draw on
* @param style - Optional style for drawing
* @returns An object with methods to control drawing and modification
*/
DrawAndModifyFeature(layerId: string, style?: Style) {
if (!this.map) {
console.error('Map is not initialized');
return null;
}
const vectorLayer = this.vectorLayers.get(layerId);
if (!vectorLayer) {
throw new Error(`Vector layer with ID '${layerId}' not found`);
}
// Get or create the vector source for the layer
this.source = vectorLayer.getSource() || new VectorSource();
if (!vectorLayer.getSource()) {
vectorLayer.setSource(this.source);
}
// Remove existing interactions
this.removeDrawInteractions();
/**
* Create draw interaction for specified geometry type
*/
const createDrawInteraction = (
type: 'Polygon' | 'LineString' | 'Circle' | 'Point',
) => {
this.drawInteraction = new Draw({
source: this.source!,
type: type,
style: style || this.getDefaultDrawStyle(),
});
// Handle draw end event
this.drawInteraction.on('drawend', (event) => {
const feature = event.feature;
const geometry = feature.getGeometry();
if (geometry) {
const result = this.extractGeometryCoordinates(geometry);
// Store data on the feature for later retrieval
const drawData = {
type: type,
coordinates: result,
feature: feature,
};
feature.set('drawData', drawData);
// Trigger custom event with data
this.map?.dispatchEvent({
type: 'featureDrawn',
data: drawData,
} as any);
}
});
this.map!.addInteraction(this.drawInteraction);
return this.drawInteraction;
};
/**
* Create modify interaction
*/
const createModifyInteraction = () => {
this.modifyInteraction = new Modify({
source: this.source!,
style: this.getModifyStyle(),
});
// Handle modify end event
this.modifyInteraction.on('modifyend', (event) => {
event.features.getArray().forEach((feature: Feature) => {
const geometry = feature.getGeometry();
if (geometry) {
const result = this.extractGeometryCoordinates(geometry);
// Store data on the feature for later retrieval
const modifyData = {
feature: feature,
coordinates: result,
};
feature.set('modifyData', modifyData);
// Trigger custom event with data
this.map?.dispatchEvent({
type: 'featureModified',
data: modifyData,
} as any);
}
});
});
this.map!.addInteraction(this.modifyInteraction);
return this.modifyInteraction;
};
/**
* Create snap interaction
*/
const createSnapInteraction = () => {
this.snapInteraction = new Snap({
source: this.source!,
});
this.map!.addInteraction(this.snapInteraction);
return this.snapInteraction;
};
/**
* Remove all interactions
*/
const removeInteractions = () => {
this.removeDrawInteractions();
};
/**
* Get the current draw interaction
*/
const getDrawInteraction = () => this.drawInteraction;
/**
* Get the current modify interaction
*/
const getModifyInteraction = () => this.modifyInteraction;
/**
* Get the current snap interaction
*/
const getSnapInteraction = () => this.snapInteraction;
return {
drawPolygon: () => createDrawInteraction('Polygon'),
drawLineString: () => createDrawInteraction('LineString'),
drawCircle: () => createDrawInteraction('Circle'),
drawPoint: () => createDrawInteraction('Point'),
enableModify: () => {
createModifyInteraction();
createSnapInteraction();
},
removeInteractions,
getDrawInteraction,
getModifyInteraction,
getSnapInteraction,
};
}
/**
* Register a feature drawn event handler
* @param callback - Function called when a feature is drawn
*/
onFeatureDrawn(
callback: (data: {
type: string;
feature: Feature;
coordinates: any;
}) => void,
): void {
if (!this.map) return;
// Listen to the custom event
this.map.on('featureDrawn' as any, (event: any) => {
if (event.data) {
callback(event.data);
}
});
}
/**
* Register a feature modified event handler
* @param callback - Function called when a feature is modified
*/
onFeatureModified(
callback: (data: { feature: Feature; coordinates: any }) => void,
): void {
if (!this.map) return;
// Listen to the custom event
this.map.on('featureModified' as any, (event: any) => {
if (event.data) {
callback(event.data);
}
});
}
/**
* Remove all draw/modify/snap interactions
*/
private removeDrawInteractions(): void {
if (!this.map) return;
if (this.drawInteraction) {
this.map.removeInteraction(this.drawInteraction);
this.drawInteraction = null;
}
if (this.modifyInteraction) {
this.map.removeInteraction(this.modifyInteraction);
this.modifyInteraction = null;
}
if (this.snapInteraction) {
this.map.removeInteraction(this.snapInteraction);
this.snapInteraction = null;
}
}
/**
* Get default style for drawing
*/
private getDefaultDrawStyle(): Style {
return new Style({
fill: new Fill({
color: 'rgba(255, 255, 255, 0.2)',
}),
stroke: new Stroke({
color: '#3399CC',
width: 2,
}),
image: new CircleStyle({
radius: 5,
fill: new Fill({
color: '#3399CC',
}),
stroke: new Stroke({
color: '#fff',
width: 2,
}),
}),
});
}
/**
* Get style for modify interaction
*/
private getModifyStyle(): Style {
return new Style({
image: new CircleStyle({
radius: 5,
fill: new Fill({
color: '#ffcc33',
}),
stroke: new Stroke({
color: '#fff',
width: 2,
}),
}),
stroke: new Stroke({
color: '#ffcc33',
width: 2,
}),
fill: new Fill({
color: 'rgba(255, 204, 51, 0.2)',
}),
});
}
/**
* Extract coordinates from different geometry types
*/
private extractGeometryCoordinates(geometry: any): any {
const type = geometry.getType();
switch (type) {
case 'Polygon':
// Return coordinates in lon/lat format
return geometry
.getCoordinates()
.map((ring: number[][]) => ring.map((coord) => toLonLat(coord)));
case 'LineString':
// Return coordinates in lon/lat format
return geometry
.getCoordinates()
.map((coord: number[]) => toLonLat(coord));
case 'Circle': {
// Return center and radius
const center = geometry.getCenter();
return {
center: toLonLat(center),
radius: geometry.getRadius(), // Radius in map units (meters)
};
}
case 'Point':
// Return coordinate in lon/lat format
return toLonLat(geometry.getCoordinates());
default:
return null;
}
}
/**
* Hiệu ứng gợn sóng cho SOS point - lặp lại sau mỗi 1.5s
* @param feature - Feature cần áp dụng hiệu ứng
* @param layerId - ID của vectorLayer chứa feature
* @returns Hàm để dừng animation
*/
sosPulse(feature: Feature, layerId: string): () => void {
const vectorLayer = this.vectorLayers.get(layerId);
if (!vectorLayer || !this.map) {
return () => {};
}
const geometry = feature.getGeometry();
if (!geometry) {
return () => {};
}
const duration = 1500; // 1.5s cho mỗi chu kỳ
const maxRadius = 40; // Bán kính tối đa của vòng gợn sóng
const baseRadius = 8; // Bán kính gốc của điểm SOS
let listenerKey: any = null;
let canceled = false;
const animate = (event: any) => {
if (canceled) return;
// Tính elapsedRatio dựa trên thời gian hiện tại modulo duration
// để tạo hiệu ứng lặp lại liên tục
const currentTime = Date.now();
const elapsedInCycle = currentTime % duration;
const elapsedRatio = elapsedInCycle / duration;
const vectorContext = getVectorContext(event);
// Vẽ các vòng gợn sóng (tạo 2 vòng sóng để hiệu ứng mượt hơn)
for (let i = 0; i < 2; i++) {
const offset = i * 0.5; // Độ lệch giữa các vòng
let adjustedRatio = elapsedRatio + offset;
if (adjustedRatio > 1) adjustedRatio -= 1;
const radius = baseRadius + easeOut(adjustedRatio) * maxRadius;
const opacity = easeOut(1 - adjustedRatio) * 0.8;
const style = new Style({
image: new CircleStyle({
radius: radius,
stroke: new Stroke({
color: `rgba(255, 0, 0, ${opacity})`,
width: 2,
}),
}),
});
vectorContext.setStyle(style);
vectorContext.drawGeometry(geometry);
}
this.map!.render();
};
listenerKey = vectorLayer.on('postrender', animate);
// Trả về hàm để dừng animation
return () => {
canceled = true;
if (listenerKey) {
unByKey(listenerKey);
}
};
}
/**
* Thêm hoặc cập nhật overlay cho SOS
* @param id - Unique ID cho overlay (thường là thing.id)
* @param coordinate - Tọa độ [lon, lat]
* @param element - HTML element để hiển thị trong overlay
*/
addOrUpdateOverlay(
id: string,
coordinate: [number, number],
element: HTMLElement,
): void {
if (!this.map) return;
// Nếu overlay đã tồn tại, cập nhật vị trí
const existingOverlay = this.overlays.get(id);
if (existingOverlay) {
existingOverlay.setPosition(fromLonLat(coordinate));
return;
}
// Tạo overlay mới
const overlay = new Overlay({
element: element,
position: fromLonLat(coordinate),
positioning: 'bottom-center',
stopEvent: false,
offset: [0, -50], // Đẩy lên trên icon
className: 'sos-overlay',
});
this.overlays.set(id, overlay);
this.map.addOverlay(overlay);
}
/**
* Xóa overlay theo ID
*/
removeOverlay(id: string): void {
const overlay = this.overlays.get(id);
if (overlay && this.map) {
this.map.removeOverlay(overlay);
this.overlays.delete(id);
}
}
/**
* Xóa tất cả overlays
*/
clearOverlays(): void {
if (!this.map) return;
this.overlays.forEach((overlay) => {
this.map!.removeOverlay(overlay);
});
this.overlays.clear();
}
/**
* Cập nhật vị trí overlay
*/
updateOverlayPosition(id: string, coordinate: [number, number]): void {
const overlay = this.overlays.get(id);
if (overlay) {
overlay.setPosition(fromLonLat(coordinate));
}
}
/**
* Lấy overlay theo ID
*/
getOverlay(id: string): Overlay | undefined {
return this.overlays.get(id);
}
}