Files
FE-DEVICE-SGW/src/pages/Home/components/BaseMap.tsx
2025-10-01 09:30:15 +07:00

844 lines
25 KiB
TypeScript

import { getAllLayer, getLayer } from '@/services/controller/MapController';
import { INITIAL_VIEW_CONFIG, osmLayer } from '@/services/service/MapService';
import {
createGeoJSONLayer,
createLabelAndFillStyle,
OL_PROJECTION,
} from '@/utils/mapUtils';
import { Feature, Map, View } from 'ol';
import { Coordinate } from 'ol/coordinate';
import { easeOut } from 'ol/easing';
import { EventsKey } from 'ol/events';
import { createEmpty, extend, Extent, isEmpty } from 'ol/extent';
import { LineString, MultiPolygon, Point, Polygon } from 'ol/geom';
import BaseLayer from 'ol/layer/Base';
import VectorLayer from 'ol/layer/Vector';
import { unByKey } from 'ol/Observable';
import { fromLonLat } from 'ol/proj';
import { getVectorContext } from 'ol/render';
import RenderEvent from 'ol/render/Event';
import VectorSource from 'ol/source/Vector';
import { Circle as CircleStyle, Style } from 'ol/style';
import Fill from 'ol/style/Fill';
import Icon from 'ol/style/Icon';
import Stroke from 'ol/style/Stroke';
import Text from 'ol/style/Text';
interface MapManagerConfig {
onFeatureClick?: (feature: any) => void;
onFeatureSelect?: (feature: any, pixel: any) => void;
onFeaturesClick?: (features: any[]) => void;
onError?: (error: string[]) => void;
}
interface AnimationConfig {
duration?: number;
maxRadius?: number;
strokeColor?: string;
strokeWidthBase?: number;
}
interface StyleConfig {
icon?: string; // URL của icon
font?: string; // font chữ cho text
textColor?: string; // màu chữ
textStrokeColor?: string; // màu viền chữ
textOffsetY?: number; // độ lệch theo trục Y
strokeColor?: string; // màu đường kẻ cho LineString
strokeWidth?: number; // độ dày đường kẻ cho LineString
fillColor?: string; // màu fill cho Polygon
borderColor?: string; // màu viền cho Polygon
borderWidth?: number; // độ dày viền cho Polygon
minScale?: number; // tỷ lệ nhỏ nhất
maxScale?: number; // tỷ lệ lớn nhất
}
// Interface for feature data
interface FeatureData {
id?: string | number;
bearing?: number;
text?: string;
}
// Interface for layer object
interface Layer {
layer: VectorLayer<VectorSource>;
}
class MapManager {
mapRef: React.RefObject<HTMLDivElement>;
map: Map | null;
featureLayer: VectorLayer<VectorSource> | null;
private features: Feature[];
private zoomListenerAdded: boolean;
private onFeatureClick: (feature: Feature) => void;
private onFeatureSelect: (feature: Feature, pixel: number[]) => void;
private onFeaturesClick: (features: Feature[]) => void;
private onError: (errors: string[]) => void;
private errors: string[];
private layers: BaseLayer[]; // Assuming layers is defined elsewhere
private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo
private eventKey: EventsKey | undefined;
constructor(
mapRef: React.RefObject<HTMLDivElement>,
config: MapManagerConfig = {},
) {
this.mapRef = mapRef;
this.map = null;
this.featureLayer = null;
this.features = [];
this.zoomListenerAdded = false;
this.onFeatureClick = config.onFeatureClick || (() => {});
this.onFeatureSelect = config.onFeatureSelect || (() => {});
this.onFeaturesClick = config.onFeaturesClick || (() => {});
this.onError = config.onError || (() => {});
this.errors = [];
this.layers = []; // Initialize layers (adjust based on actual usage)
this.isInitialized = false; // Khởi tạo là false
this.eventKey = undefined; // Khởi tạo eventKey
}
async getListLayers(): Promise<[]> {
const resp: [] = await getAllLayer();
// console.log('resp', resp);
return resp;
}
async initializeMap(): Promise<void> {
try {
const listLayers: string[] = await this.getListLayers(); // định nghĩa LayerMeta { id: string; name: string; ... }
const dynamicLayers: BaseLayer[] = [];
for (const layerMeta of listLayers) {
try {
const data = await getLayer(layerMeta); // lấy GeoJSON từ server
let style = {};
if (layerMeta === 'base-countries') {
style = createLabelAndFillStyle('#F3F2EC', '#E5BEB5');
} else {
style = createLabelAndFillStyle('#77BEF0', '#000000');
}
const vectorLayer = createGeoJSONLayer({
data,
style: style,
});
dynamicLayers.push(vectorLayer);
// gom tất cả features vào this.features để sau này click kiểm tra
const features = vectorLayer.getSource()?.getFeatures() ?? [];
this.features.push(...features);
} catch (err) {
console.error(`Không load được layer ${layerMeta}`, err);
this.errors.push(`Layer ${layerMeta} load thất bại`);
this.onError(this.errors);
}
}
if (!this.mapRef.current) {
console.error('Map reference is not available');
return;
}
this.featureLayer = new VectorLayer({
source: new VectorSource({
features: [],
}),
zIndex: 10,
});
this.zoomListenerAdded = false;
this.map = new Map({
target: this.mapRef.current,
layers: [osmLayer, ...dynamicLayers, this.featureLayer],
view: new View({
projection: OL_PROJECTION,
center: fromLonLat(INITIAL_VIEW_CONFIG.center),
zoom: INITIAL_VIEW_CONFIG.zoom,
minZoom: INITIAL_VIEW_CONFIG.minZoom,
maxZoom: INITIAL_VIEW_CONFIG.maxZoom,
}),
controls: [],
});
this.layers = [osmLayer, ...dynamicLayers, this.featureLayer];
this.initZoomListener();
this.isInitialized = true;
console.log(
'Map initialized successfully at',
new Date().toLocaleTimeString(),
);
this.map.on('singleclick', (evt: any) => {
const featuresAtPixel: {
feature: Feature;
layer: VectorLayer<VectorSource>;
}[] = [];
this.map!.forEachFeatureAtPixel(
evt.pixel,
(feature: any, layer: any) => {
featuresAtPixel.push({ feature, layer });
},
);
if (featuresAtPixel.length === 0) return;
if (featuresAtPixel.length === 1) {
const { feature } = featuresAtPixel[0];
if (this.features.includes(feature)) {
this.onFeatureClick(feature);
this.onFeatureSelect(feature, evt.pixel);
console.log(
'Feature clicked at',
new Date().toLocaleTimeString(),
':',
feature.getProperties(),
);
}
} else {
this.onFeaturesClick(featuresAtPixel.map((f) => f.feature));
}
});
} catch (err) {
console.error('Lỗi khi khởi tạo bản đồ:', err);
this.errors.push('Map initialization failed');
this.onError(this.errors);
}
}
isMapInitialized(): boolean {
return this.isInitialized;
}
initZoomListener() {
// console.log("initZoomListener: Adding zoom listener"); // Debug
if (!this.zoomListenerAdded) {
if (!this.map || !this.map.getView()) {
console.error('Map or view not initialized in initZoomListener');
return;
}
this.map.getView().on('change:resolution', () => {
// console.log("change:resolution event triggered"); // Debug
this.updateFeatureStyles();
});
this.zoomListenerAdded = true;
}
}
// Hàm cập nhật style cho tất cả features
updateFeatureStyles() {
if (!this.map || !this.map.getView()) {
console.error('Map or view not initialized in updateFeatureStyles');
return;
}
const currentZoom = this.map.getView().getZoom() ?? 5;
// console.log(`Updating feature styles with zoom: ${currentZoom}`); // Debug
this.features.forEach((feature) => {
const data = feature.get('data') || feature.getProperties();
const styleConfig = feature.get('styleConfig') || {};
const featureType = feature.get('type');
// console.log(
// `Updating style for feature type: ${featureType}, data:`,
// data,
// `styleConfig:`,
// styleConfig,
// ); // Debug
if (featureType === 'vms') {
const styles = this.createIconStyle(data, styleConfig, currentZoom);
feature.setStyle(styles);
}
});
}
// Hàm tạo style cho feature
createIconStyle = (
data: FeatureData,
styleConfig: StyleConfig,
zoom: number,
): Style[] => {
const styles: Style[] = [];
if (styleConfig.icon) {
const scale = this.calculateScale(zoom);
// console.log(`Creating icon style with zoom: ${zoom}, scale: ${scale}`); // Debug
styles.push(
new Style({
image: new Icon({
anchor: [0.5, 30],
anchorXUnits: 'fraction',
anchorYUnits: 'pixels',
src: styleConfig.icon,
scale, // Use calculated scale
rotateWithView: false,
rotation:
data.bearing && !isNaN(data.bearing)
? (data.bearing * Math.PI) / 180
: 0,
crossOrigin: 'anonymous',
}),
}),
);
} else {
console.warn('No icon provided in styleConfig, skipping icon style'); // Debug
}
if (data.text) {
styles.push(
new Style({
text: new Text({
text: data.text,
font: styleConfig.font || '14px Arial',
fill: new Fill({ color: styleConfig.textColor || 'black' }),
stroke: new Stroke({
color: styleConfig.textStrokeColor || 'white',
width: 2,
}),
offsetY: styleConfig.textOffsetY || -40,
textAlign: 'center',
textBaseline: 'bottom',
}),
}),
);
}
return styles;
};
addPoint(
coord: Coordinate,
data: Record<string, any> = {},
styleConfig: Record<string, any> = {},
) {
try {
// Kiểm tra tọa độ hợp lệ
if (
!Array.isArray(coord) ||
coord.length < 2 ||
typeof coord[0] !== 'number' ||
typeof coord[1] !== 'number'
) {
throw new Error(
`Invalid coordinates for Point: ${JSON.stringify(coord)}`,
);
}
// Tạo geometry
const geometry = new Point(fromLonLat(coord));
const feature = new Feature({
geometry,
type: 'vms', // Explicitly set featureType to 'vms' for scaling
...data,
});
// Lưu config
feature.set('styleConfig', styleConfig);
feature.set('data', data);
// Lấy zoom hiện tại (fallback 5 nếu map chưa sẵn sàng)
const currentZoom = this.map?.getView()?.getZoom() ?? 5;
// console.log(`Adding point with zoom: ${currentZoom}`); // Debug
// Gán style mặc định
const styles = this.createIconStyle(data, styleConfig, currentZoom);
feature.setStyle(styles);
// Lưu feature
this.features.push(feature);
this.featureLayer?.getSource()?.addFeature(feature);
if (styleConfig.animate) {
this.animatedMarker(feature, styleConfig.animationConfig);
}
// console.log('Point added successfully:', { coord, data, styleConfig });
return feature;
} catch (error: any) {
const message = error?.message || 'Unknown error';
this.errors.push(message);
this.onError?.(this.errors);
console.error('Error adding Point:', message);
return null;
}
}
animatedMarker = (
feature: Feature<Point>,
config: AnimationConfig = {},
): void => {
const {
duration = 3000,
maxRadius = 20,
strokeColor = 'rgba(255, 0, 0, 0.8)',
strokeWidthBase = 0.25,
} = config;
console.log('Starting animatedMarker with config:', config);
const flashGeom = feature.getGeometry()?.clone() as Point | undefined;
if (!flashGeom || !this.featureLayer) {
console.error('Invalid geometry or featureLayer for animation');
return;
}
// Tạo 2 "pha" sóng, mỗi sóng bắt đầu cách nhau duration/2
const waveCount = 1;
const waveOffsets = Array.from(
{ length: waveCount },
(_, i) => (duration / waveCount) * i,
);
const start = Date.now();
const listenerKey: EventsKey = this.featureLayer.on(
'postrender',
(event: RenderEvent) => {
const frameState = event.frameState;
if (!frameState) return;
const elapsedTotal = frameState.time - start;
if (elapsedTotal >= duration * 3) {
// chạy 2 chu kỳ rồi dừng (bạn có thể bỏ điều kiện này để chạy mãi)
unByKey(listenerKey);
return;
}
const vectorContext = getVectorContext(event);
waveOffsets.forEach((offset) => {
const elapsed =
(frameState.time - start - offset + duration) % duration;
const elapsedRatio = elapsed / duration;
const radius = easeOut(elapsedRatio) * (maxRadius - 5) + 5;
const opacity = easeOut(1 - elapsedRatio);
const style = new Style({
image: new CircleStyle({
radius,
stroke: new Stroke({
color: strokeColor.replace('0.8', opacity.toString()),
width: strokeWidthBase + opacity,
}),
}),
});
vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom);
});
this.map!.render();
},
);
this.eventKey = listenerKey;
};
addPolygon(
coords: Coordinate[][],
data: Record<string, any> = {},
styleConfig: Record<string, any> = {},
) {
try {
let normalizedCoords: Coordinate[][] | Coordinate[] = coords;
// Nếu truyền vào là [[ [lon, lat], [lon, lat], ... ]]
if (
Array.isArray(coords) &&
coords.length === 1 &&
Array.isArray(coords[0]) &&
(coords[0] as Coordinate[]).every(
(coord) =>
Array.isArray(coord) &&
coord.length === 2 &&
coord.every((val) => !isNaN(val)),
)
) {
normalizedCoords = coords[0] as Coordinate[];
}
const isMultiPolygon =
Array.isArray(normalizedCoords) &&
(normalizedCoords as Coordinate[][]).every(
(ring) =>
Array.isArray(ring) &&
(ring as Coordinate[]).every(
(coord) =>
Array.isArray(coord) &&
coord.length === 2 &&
coord.every((val) => !isNaN(val)),
),
);
const isSinglePolygon =
Array.isArray(normalizedCoords) &&
(normalizedCoords as Coordinate[]).every(
(coord) =>
Array.isArray(coord) &&
coord.length === 2 &&
coord.every((val) => !isNaN(val)),
);
if (!isMultiPolygon && !isSinglePolygon) {
throw new Error(
`Invalid coordinates for Polygon/MultiPolygon: ${JSON.stringify(
coords,
)}`,
);
}
if (isSinglePolygon && (normalizedCoords as Coordinate[]).length < 3) {
throw new Error(
`Polygon must have at least 3 coordinates: ${JSON.stringify(coords)}`,
);
}
if (
isMultiPolygon &&
(normalizedCoords as Coordinate[][]).some((ring) => ring.length < 3)
) {
throw new Error(
`Each ring in MultiPolygon must have at least 3 coordinates: ${JSON.stringify(
coords,
)}`,
);
}
let geometry: Polygon | MultiPolygon;
if (isMultiPolygon) {
const transformedCoords = (normalizedCoords as Coordinate[][]).map(
(ring) => ring.map(([lon, lat]) => fromLonLat([lon, lat])),
);
geometry = new MultiPolygon([transformedCoords]);
} else {
const transformedCoords = (normalizedCoords as Coordinate[]).map(
([lon, lat]) => fromLonLat([lon, lat]),
);
geometry = new Polygon([transformedCoords]);
}
const feature = new Feature<Polygon | MultiPolygon>({
geometry,
...data,
});
feature.set('styleConfig', styleConfig);
feature.set('data', data);
feature.set('type', 'polygon');
// Nếu bạn có sẵn hàm tạo style theo zoom
const currentZoom = this.map?.getView().getZoom();
feature.setStyle(
this.createPolygonStyle(data, styleConfig, currentZoom || 5),
);
this.features.push(feature);
this.featureLayer?.getSource()?.addFeature(feature);
return feature;
} catch (error) {
this.errors.push('Error adding Polygon: ' + (error as Error).message);
this.onError?.(this.errors);
console.error('Error adding Polygon:', (error as Error).message);
return null;
}
}
// Hàm addLineString đã được sửa đổi
addLineString(
coords: Coordinate[],
data: FeatureData = {},
styleConfig: StyleConfig = {},
): Feature | null {
try {
// Kiểm tra coords là mảng hợp lệ và có ít nhất 2 tọa độ
if (
!Array.isArray(coords) ||
coords.length < 2 ||
!coords.every(
(coord) =>
Array.isArray(coord) &&
coord.length === 2 &&
typeof coord[0] === 'number' &&
typeof coord[1] === 'number',
)
) {
throw new Error(
`Invalid coordinates for LineString: ${JSON.stringify(coords)}`,
);
}
// Chuyển đổi tọa độ từ [lon, lat] sang hệ tọa độ của OpenLayers
const transformedCoords = coords.map((coord) => fromLonLat(coord));
const geometry = new LineString(transformedCoords);
const feature = new Feature({
geometry,
...data,
});
// Tạo style cho LineString
const style = new Style({
text: new Text({
text: data.text || '',
font: styleConfig.font || '16px Arial', // Tăng kích thước font
fill: new Fill({ color: styleConfig.textColor || 'black' }), // Đổi màu thành đen để nổi bật
stroke: new Stroke({
color: styleConfig.textStrokeColor || 'white',
width: 2, // Tăng độ rộng viền
}),
placement: 'line',
textAlign: 'center',
textBaseline: 'middle', // Đổi thành 'middle' để căn giữa theo chiều dọc
offsetY: styleConfig.textOffsetY || -10, // Dịch text lên trên một chút
}),
stroke: new Stroke({
color: styleConfig.strokeColor || 'blue',
width: styleConfig.strokeWidth || 5,
}),
});
feature.setStyle(style);
// Thêm feature vào features và featureLayer
if (this.featureLayer && this.isInitialized) {
this.features.push(feature);
this.featureLayer.getSource()?.addFeature(feature);
// console.log(
// 'LineString added successfully at',
// new Date().toLocaleTimeString(),
// ':',
// { coords, data, styleConfig },
// );
// Đảm bảo bản đồ được render lại
this.map?.render();
return feature;
} else {
throw new Error('featureLayer or map is not initialized');
}
} catch (error: any) {
this.errors.push(error.message);
this.onError(this.errors);
console.error(
'Error adding LineString at',
new Date().toLocaleTimeString(),
':',
error.message,
);
return null;
}
}
createPolygonStyle = (
data: Record<string, any> = {},
styleConfig: Record<string, any> = {},
zoom: number,
) => {
// console.log(
// `createPolygonStyle called with zoom: ${zoom}, styleConfig:`,
// styleConfig,
// ); // Debug
const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${
data?.type ?? 0
}\n\nThông tin: ${data?.description ?? 'Chưa có'}`;
const textStyle = new Text({
text: textContent,
font: styleConfig.font || '14px Arial',
fill: new Fill({ color: styleConfig.textColor || 'black' }),
stroke: new Stroke({
color: styleConfig.textStrokeColor || 'white',
width: 1,
}),
offsetY: styleConfig.textOffsetY || -40,
textAlign: 'center',
textBaseline: 'bottom',
});
// Điều chỉnh fillColor dựa trên zoom (tùy chọn)
let fillColor = styleConfig.fillColor || 'rgba(255,0,0,0.3)';
// Nếu muốn tăng độ đậm của fill khi zoom gần
if (zoom > 10) {
fillColor = styleConfig.fillColor
? styleConfig.fillColor.replace(/,[\d.]*\)/, ',0.5)') // Tăng độ đậm
: 'rgba(255,0,0,0.5)';
}
const style = new Style({
stroke: new Stroke({
color: styleConfig.borderColor || 'red',
width: styleConfig.borderWidth || 2,
}),
fill: new Fill({
color: fillColor,
}),
text: textStyle,
});
// console.log(`Polygon style created:`, style); // Debug
return [style];
};
private calculateScale = (zoom: number): number => {
// console.log(`calculateScale called with zoom: ${zoom}`); // Debug
const minZoom = INITIAL_VIEW_CONFIG.minZoom; // Zoom tối thiểu
const maxZoom = INITIAL_VIEW_CONFIG.maxZoom; // Zoom tối đa
const minScale = INITIAL_VIEW_CONFIG.minScale; // Scale nhỏ nhất
const maxScale = INITIAL_VIEW_CONFIG.maxScale; // 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;
};
zoomToFeatures(): void {
if (!this.map) {
console.error('Map is not initialized');
return;
}
if (this.features.length === 0) {
this.map.getView().animate({
center: fromLonLat(INITIAL_VIEW_CONFIG.center),
zoom: INITIAL_VIEW_CONFIG.zoom,
duration: 500,
});
return;
}
let extent: Extent = createEmpty();
this.features.forEach((feature, index) => {
try {
const featureExtent = feature.getGeometry()?.getExtent();
if (featureExtent && !isEmpty(featureExtent)) {
extent = extend(extent, featureExtent);
} else {
this.errors.push(`Empty extent for feature at index ${index}`);
}
} catch (error) {
this.errors.push(
`Error getting extent for feature at index ${index}: ${
(error as Error).message
}`,
);
}
});
if (!isEmpty(extent)) {
this.map.getView().fit(extent, {
padding: [300, 300, 300, 300],
maxZoom: 12,
duration: 500,
callback: () => {
this.updateFeatureStyles();
},
});
} else {
this.errors.push('No valid features to zoom to.');
}
if (this.errors.length > 0) {
this.onError(this.errors);
console.error(
'Zoom errors at',
new Date().toLocaleTimeString(),
':',
this.errors,
);
}
}
flyToFeature(
feature: Feature | Feature[],
done: (complete: boolean) => void = () => {},
): void {
if (!this.map) {
console.error('Map is not initialized');
return;
}
const features = Array.isArray(feature) ? feature : [feature];
if (!features || features.length === 0) {
this.errors.push('No feature provided for flyToFeature');
this.onError(this.errors);
console.error(
'Fly to feature error at',
new Date().toLocaleTimeString(),
': No feature provided',
);
return;
}
const view = this.map.getView();
let extent: Extent = createEmpty();
const validFeatures: Feature[] = [];
features.forEach((f, index) => {
if (f && typeof f.getGeometry === 'function') {
const featureExtent = f.getGeometry()?.getExtent();
if (featureExtent && !isEmpty(featureExtent)) {
extent = extend(extent, featureExtent);
validFeatures.push(f);
} else {
this.errors.push(`Empty extent for feature at index ${index}`);
}
} else {
this.errors.push(`Invalid feature at index ${index}`);
}
});
if (isEmpty(extent)) {
this.errors.push('Empty extent for selected features');
this.onError(this.errors);
console.error(
'Fly to feature error at',
new Date().toLocaleTimeString(),
': Empty extent',
);
return;
}
const duration = 1000;
const zoom = view.getZoom() || 5;
let parts = 2;
let called = false;
const callback = (complete: boolean) => {
parts--;
if (called) {
return;
}
if (parts === 0 || !complete) {
called = true;
this.updateFeatureStyles();
done(complete);
}
};
view.fit(extent, {
padding: [300, 300, 300, 300],
maxZoom: features.length === 1 ? 8 : 10,
duration,
});
view.animate(
{
zoom: zoom - 1,
duration: duration / 2,
},
{
zoom: 14,
duration: duration / 2,
},
callback,
);
}
destroy(): void {
if (this.map) {
this.map.setTarget(undefined);
this.isInitialized = false;
console.log('Map destroyed at', new Date().toLocaleTimeString());
}
}
}
export { MapManager };