Files
FE-DEVICE-SGW/src/pages/Home/components/BaseMap.tsx
2025-09-26 18:22:04 +07:00

665 lines
19 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 { createEmpty, extend, Extent, isEmpty } from 'ol/extent';
import { MultiPolygon, Point, Polygon } from 'ol/geom';
import BaseLayer from 'ol/layer/Base';
import VectorLayer from 'ol/layer/Vector';
import { fromLonLat } from 'ol/proj';
import VectorSource from 'ol/source/Vector';
import Fill from 'ol/style/Fill';
import Icon from 'ol/style/Icon';
import Stroke from 'ol/style/Stroke';
import Style from 'ol/style/Style';
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 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
}
// Interface for feature data
interface FeatureData {
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
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
}
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
const vectorLayer = createGeoJSONLayer({
data,
style: createLabelAndFillStyle('#77BEF0', '#000000'),
});
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.flyToFeature(feature);
this.featureLayer?.getSource()?.addFeature(feature);
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;
}
}
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;
}
}
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 ? 10 : 12,
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 };