665 lines
19 KiB
TypeScript
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 };
|