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 = new Map(); private vectorLayers: Map> = new Map(); private overlays: Map = 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 > { 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); } }