1201 lines
33 KiB
TypeScript
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);
|
|
}
|
|
}
|