add somenew UI editor feature for more effêcncy
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
|
||||
export type MapImageOverlay = {
|
||||
url: string;
|
||||
name: string;
|
||||
opacity: number;
|
||||
aspectRatio: number;
|
||||
coordinates: maplibregl.Coordinates;
|
||||
};
|
||||
|
||||
const IMAGE_OVERLAY_SOURCE_ID = "uhm-image-overlay-source";
|
||||
const IMAGE_OVERLAY_LAYER_ID = "uhm-image-overlay-layer";
|
||||
const IMAGE_OVERLAY_CONTROL_SOURCE_ID = "uhm-image-overlay-control-source";
|
||||
const IMAGE_OVERLAY_HANDLE_LAYER_ID = "uhm-image-overlay-handles";
|
||||
const IMAGE_OVERLAY_CENTER_LAYER_ID = "uhm-image-overlay-center";
|
||||
|
||||
type OverlayControlAction = "move" | "resize";
|
||||
type OverlayResizeEdge = "top" | "right" | "bottom" | "left";
|
||||
|
||||
type OverlayControlFeature = GeoJSON.Feature<GeoJSON.Point, {
|
||||
action: OverlayControlAction;
|
||||
edge?: OverlayResizeEdge;
|
||||
}>;
|
||||
|
||||
export function applyImageOverlay(
|
||||
map: maplibregl.Map,
|
||||
overlay: MapImageOverlay | null | undefined
|
||||
) {
|
||||
if (!overlay) {
|
||||
removeImageOverlay(map);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
|
||||
if (existingSource) {
|
||||
if (existingSource.url === overlay.url) {
|
||||
existingSource.setCoordinates(overlay.coordinates);
|
||||
} else {
|
||||
existingSource.updateImage({
|
||||
url: overlay.url,
|
||||
coordinates: overlay.coordinates,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
map.addSource(IMAGE_OVERLAY_SOURCE_ID, {
|
||||
type: "image",
|
||||
url: overlay.url,
|
||||
coordinates: overlay.coordinates,
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: IMAGE_OVERLAY_LAYER_ID,
|
||||
type: "raster",
|
||||
source: IMAGE_OVERLAY_SOURCE_ID,
|
||||
paint: {
|
||||
"raster-opacity": clampOpacity(overlay.opacity),
|
||||
"raster-fade-duration": 0,
|
||||
"raster-resampling": "linear",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-opacity", clampOpacity(overlay.opacity));
|
||||
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-fade-duration", 0);
|
||||
}
|
||||
|
||||
// Không truyền beforeId để layer được đưa lên trên cùng, phục vụ trace khi vẽ.
|
||||
map.moveLayer(IMAGE_OVERLAY_LAYER_ID);
|
||||
applyImageOverlayControls(map, overlay);
|
||||
}
|
||||
|
||||
export function removeImageOverlay(map: maplibregl.Map) {
|
||||
removeImageOverlayControls(map);
|
||||
|
||||
if (map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
|
||||
map.removeLayer(IMAGE_OVERLAY_LAYER_ID);
|
||||
}
|
||||
|
||||
if (map.getSource(IMAGE_OVERLAY_SOURCE_ID)) {
|
||||
map.removeSource(IMAGE_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export function getViewportImageCoordinates(
|
||||
map: maplibregl.Map,
|
||||
aspectRatio: number
|
||||
): maplibregl.Coordinates {
|
||||
const canvas = map.getCanvas();
|
||||
const canvasWidth = Math.max(canvas.clientWidth || canvas.width || 800, 1);
|
||||
const canvasHeight = Math.max(canvas.clientHeight || canvas.height || 600, 1);
|
||||
const safeAspectRatio = normalizeAspectRatio(aspectRatio);
|
||||
|
||||
let width = canvasWidth * 0.72;
|
||||
let height = width / safeAspectRatio;
|
||||
const maxHeight = canvasHeight * 0.72;
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height * safeAspectRatio;
|
||||
}
|
||||
|
||||
return buildCoordinatesFromScreenBox(
|
||||
map,
|
||||
{ x: canvasWidth / 2, y: canvasHeight / 2 },
|
||||
width,
|
||||
height
|
||||
);
|
||||
}
|
||||
|
||||
export function moveImageOverlayCoordinatesByPixels(
|
||||
map: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): maplibregl.Coordinates {
|
||||
return moveCoordinates(map, coordinates, new maplibregl.Point(deltaX, deltaY));
|
||||
}
|
||||
|
||||
export function scaleImageOverlayCoordinatesByFactor(
|
||||
map: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates,
|
||||
factor: number,
|
||||
aspectRatio: number
|
||||
): maplibregl.Coordinates {
|
||||
const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1;
|
||||
const screenBox = getScreenBox(map, coordinates);
|
||||
const minimumSize = 48;
|
||||
const width = Math.max(screenBox.width * safeFactor, minimumSize);
|
||||
const height = width / normalizeAspectRatio(aspectRatio);
|
||||
return buildCoordinatesFromScreenBox(map, screenBox.center, width, height);
|
||||
}
|
||||
|
||||
export function bindImageOverlayInteractions(
|
||||
map: maplibregl.Map,
|
||||
getOverlay: () => MapImageOverlay | null,
|
||||
onChange: (overlay: MapImageOverlay) => void
|
||||
) {
|
||||
let rafId: number | null = null;
|
||||
let pendingCoordinates: maplibregl.Coordinates | null = null;
|
||||
let latestOverlay: MapImageOverlay | null = null;
|
||||
let activeDrag: {
|
||||
action: OverlayControlAction;
|
||||
edge: OverlayResizeEdge | null;
|
||||
startPoint: maplibregl.Point;
|
||||
startCoordinates: maplibregl.Coordinates;
|
||||
startBox: ScreenBox;
|
||||
aspectRatio: number;
|
||||
wasDragPanEnabled: boolean;
|
||||
} | null = null;
|
||||
|
||||
const startDrag = (event: maplibregl.MapLayerMouseEvent) => {
|
||||
if ((event.originalEvent as MouseEvent | undefined)?.button !== 2) return;
|
||||
|
||||
const overlay = getOverlay();
|
||||
const feature = event.features?.[0] as OverlayControlFeature | undefined;
|
||||
if (!overlay || !feature?.properties?.action) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.originalEvent.preventDefault();
|
||||
event.originalEvent.stopPropagation();
|
||||
|
||||
activeDrag = {
|
||||
action: feature.properties.action,
|
||||
edge: feature.properties.edge || null,
|
||||
startPoint: event.point,
|
||||
startCoordinates: overlay.coordinates,
|
||||
startBox: getScreenBox(map, overlay.coordinates),
|
||||
aspectRatio: normalizeAspectRatio(overlay.aspectRatio),
|
||||
wasDragPanEnabled: map.dragPan.isEnabled(),
|
||||
};
|
||||
latestOverlay = overlay;
|
||||
map.dragPan.disable();
|
||||
map.getCanvas().style.cursor = activeDrag.action === "move" ? "grabbing" : "nwse-resize";
|
||||
};
|
||||
|
||||
const moveDrag = (event: maplibregl.MapMouseEvent) => {
|
||||
if (!activeDrag) return;
|
||||
const overlay = getOverlay();
|
||||
if (!overlay) return;
|
||||
|
||||
event.preventDefault();
|
||||
const nextCoordinates = activeDrag.action === "move"
|
||||
? moveCoordinates(map, activeDrag.startCoordinates, event.point.sub(activeDrag.startPoint))
|
||||
: resizeCoordinates(map, activeDrag.startBox, event.point, activeDrag.edge, activeDrag.aspectRatio);
|
||||
|
||||
latestOverlay = {
|
||||
...overlay,
|
||||
coordinates: nextCoordinates,
|
||||
};
|
||||
scheduleImageOverlayCoordinateUpdate(map, nextCoordinates);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!activeDrag) return;
|
||||
const finishedDrag = activeDrag;
|
||||
activeDrag = null;
|
||||
flushImageOverlayCoordinateUpdate(map);
|
||||
if (latestOverlay) {
|
||||
onChange(latestOverlay);
|
||||
latestOverlay = null;
|
||||
}
|
||||
if (finishedDrag.wasDragPanEnabled && !map.dragPan.isEnabled()) {
|
||||
map.dragPan.enable();
|
||||
}
|
||||
map.getCanvas().style.cursor = "";
|
||||
};
|
||||
|
||||
const preventContextMenu = (event: maplibregl.MapLayerMouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.originalEvent.preventDefault();
|
||||
event.originalEvent.stopPropagation();
|
||||
};
|
||||
|
||||
map.on("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
|
||||
map.on("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
|
||||
map.on("mousemove", moveDrag);
|
||||
map.on("mouseup", endDrag);
|
||||
map.on("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
|
||||
map.on("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
|
||||
|
||||
return () => {
|
||||
endDrag();
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
pendingCoordinates = null;
|
||||
map.off("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
|
||||
map.off("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
|
||||
map.off("mousemove", moveDrag);
|
||||
map.off("mouseup", endDrag);
|
||||
map.off("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
|
||||
map.off("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
|
||||
};
|
||||
|
||||
function scheduleImageOverlayCoordinateUpdate(
|
||||
targetMap: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates
|
||||
) {
|
||||
pendingCoordinates = coordinates;
|
||||
if (rafId !== null) return;
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
flushImageOverlayCoordinateUpdate(targetMap);
|
||||
});
|
||||
}
|
||||
|
||||
function flushImageOverlayCoordinateUpdate(targetMap: maplibregl.Map) {
|
||||
if (!pendingCoordinates) return;
|
||||
updateImageOverlayCoordinates(targetMap, pendingCoordinates);
|
||||
pendingCoordinates = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageOverlayInteractiveLayerIds() {
|
||||
return [IMAGE_OVERLAY_HANDLE_LAYER_ID, IMAGE_OVERLAY_CENTER_LAYER_ID];
|
||||
}
|
||||
|
||||
function applyImageOverlayControls(map: maplibregl.Map, overlay: MapImageOverlay) {
|
||||
const data = buildControlFeatureCollection(overlay.coordinates);
|
||||
const existingSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||
if (existingSource) {
|
||||
existingSource.setData(data);
|
||||
} else {
|
||||
map.addSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID, {
|
||||
type: "geojson",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: IMAGE_OVERLAY_HANDLE_LAYER_ID,
|
||||
type: "circle",
|
||||
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
|
||||
filter: ["==", ["get", "action"], "resize"],
|
||||
paint: {
|
||||
"circle-color": "#38bdf8",
|
||||
"circle-radius": 7,
|
||||
"circle-stroke-color": "#0f172a",
|
||||
"circle-stroke-width": 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: IMAGE_OVERLAY_CENTER_LAYER_ID,
|
||||
type: "circle",
|
||||
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
|
||||
filter: ["==", ["get", "action"], "move"],
|
||||
paint: {
|
||||
"circle-color": "#fbbf24",
|
||||
"circle-radius": 8,
|
||||
"circle-stroke-color": "#0f172a",
|
||||
"circle-stroke-width": 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
map.moveLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
|
||||
map.moveLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
|
||||
}
|
||||
|
||||
function updateImageOverlayCoordinates(
|
||||
map: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates
|
||||
) {
|
||||
const imageSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
|
||||
imageSource?.setCoordinates(coordinates);
|
||||
|
||||
const controlSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||
controlSource?.setData(buildControlFeatureCollection(coordinates));
|
||||
}
|
||||
|
||||
function removeImageOverlayControls(map: maplibregl.Map) {
|
||||
if (map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
|
||||
map.removeLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
|
||||
}
|
||||
if (map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
|
||||
map.removeLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
|
||||
}
|
||||
if (map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID)) {
|
||||
map.removeSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
function buildControlFeatureCollection(
|
||||
coordinates: maplibregl.Coordinates
|
||||
): GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]> {
|
||||
const [topLeft, topRight, bottomRight, bottomLeft] = coordinates;
|
||||
const center = averageCoordinates(coordinates);
|
||||
|
||||
return [
|
||||
createControlFeature(center, { action: "move" }),
|
||||
createControlFeature(midpoint(topLeft, topRight), { action: "resize", edge: "top" }),
|
||||
createControlFeature(midpoint(topRight, bottomRight), { action: "resize", edge: "right" }),
|
||||
createControlFeature(midpoint(bottomRight, bottomLeft), { action: "resize", edge: "bottom" }),
|
||||
createControlFeature(midpoint(bottomLeft, topLeft), { action: "resize", edge: "left" }),
|
||||
].reduce<GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]>>(
|
||||
(collection, feature) => {
|
||||
collection.features.push(feature);
|
||||
return collection;
|
||||
},
|
||||
{ type: "FeatureCollection", features: [] }
|
||||
);
|
||||
}
|
||||
|
||||
function createControlFeature(
|
||||
coordinates: [number, number],
|
||||
properties: OverlayControlFeature["properties"]
|
||||
): OverlayControlFeature {
|
||||
return {
|
||||
type: "Feature",
|
||||
properties,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ScreenPoint = { x: number; y: number };
|
||||
type ScreenBox = {
|
||||
center: ScreenPoint;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function getScreenBox(map: maplibregl.Map, coordinates: maplibregl.Coordinates): ScreenBox {
|
||||
const points = coordinates.map((coordinate) => map.project(coordinate));
|
||||
const minX = Math.min(...points.map((point) => point.x));
|
||||
const maxX = Math.max(...points.map((point) => point.x));
|
||||
const minY = Math.min(...points.map((point) => point.y));
|
||||
const maxY = Math.max(...points.map((point) => point.y));
|
||||
return {
|
||||
center: {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
},
|
||||
width: Math.max(maxX - minX, 40),
|
||||
height: Math.max(maxY - minY, 40),
|
||||
};
|
||||
}
|
||||
|
||||
function moveCoordinates(
|
||||
map: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates,
|
||||
delta: maplibregl.Point
|
||||
): maplibregl.Coordinates {
|
||||
return coordinates.map((coordinate) => {
|
||||
const point = map.project(coordinate);
|
||||
return lngLatToCoordinate(map.unproject([point.x + delta.x, point.y + delta.y]));
|
||||
}) as maplibregl.Coordinates;
|
||||
}
|
||||
|
||||
function resizeCoordinates(
|
||||
map: maplibregl.Map,
|
||||
startBox: ScreenBox,
|
||||
currentPoint: maplibregl.Point,
|
||||
edge: OverlayResizeEdge | null,
|
||||
aspectRatio: number
|
||||
): maplibregl.Coordinates {
|
||||
const minimumSize = 48;
|
||||
let width = startBox.width;
|
||||
let height = startBox.height;
|
||||
|
||||
if (edge === "left" || edge === "right") {
|
||||
width = Math.max(Math.abs(currentPoint.x - startBox.center.x) * 2, minimumSize);
|
||||
height = width / aspectRatio;
|
||||
} else {
|
||||
height = Math.max(Math.abs(currentPoint.y - startBox.center.y) * 2, minimumSize);
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
|
||||
return buildCoordinatesFromScreenBox(map, startBox.center, width, height);
|
||||
}
|
||||
|
||||
function buildCoordinatesFromScreenBox(
|
||||
map: maplibregl.Map,
|
||||
center: ScreenPoint,
|
||||
width: number,
|
||||
height: number
|
||||
): maplibregl.Coordinates {
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return [
|
||||
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y - halfHeight])),
|
||||
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y - halfHeight])),
|
||||
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y + halfHeight])),
|
||||
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y + halfHeight])),
|
||||
];
|
||||
}
|
||||
|
||||
function averageCoordinates(coordinates: maplibregl.Coordinates): [number, number] {
|
||||
const total = coordinates.reduce(
|
||||
(sum, coordinate) => ({
|
||||
lng: sum.lng + coordinate[0],
|
||||
lat: sum.lat + coordinate[1],
|
||||
}),
|
||||
{ lng: 0, lat: 0 }
|
||||
);
|
||||
return [total.lng / coordinates.length, total.lat / coordinates.length];
|
||||
}
|
||||
|
||||
function midpoint(a: [number, number], b: [number, number]): [number, number] {
|
||||
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
}
|
||||
|
||||
function lngLatToCoordinate(lngLat: maplibregl.LngLat): [number, number] {
|
||||
return [lngLat.lng, lngLat.lat];
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
function clampOpacity(value: number) {
|
||||
if (!Number.isFinite(value)) return 0.55;
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
@@ -13,12 +13,14 @@ import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
|
||||
import { getBackgroundRasterSourceSpecification } from "@/uhm/api/tiles";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import type { EntityLabelCandidate } from "@/uhm/types/geo";
|
||||
|
||||
type Coordinate = [number, number];
|
||||
type PolygonCoordinates = Coordinate[][];
|
||||
type FeatureLabelInfo = {
|
||||
entityId: string;
|
||||
label: string;
|
||||
timeEnd: number | null;
|
||||
};
|
||||
|
||||
export function applyBackgroundLayerVisibility(
|
||||
@@ -230,8 +232,12 @@ export function splitDraftFeatures(fc: FeatureCollection) {
|
||||
return { polygons, points };
|
||||
}
|
||||
|
||||
export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
export function decoratePointFeaturesWithLabels(
|
||||
fc: FeatureCollection,
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.map((feature) => ({
|
||||
@@ -244,8 +250,12 @@ export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelCont
|
||||
};
|
||||
}
|
||||
|
||||
export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
export function decorateLineFeaturesWithLabels(
|
||||
fc: FeatureCollection,
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.map((feature) => ({
|
||||
@@ -258,8 +268,12 @@ export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelConte
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPolygonLabelFeatureCollection(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
export function buildPolygonLabelFeatureCollection(
|
||||
fc: FeatureCollection,
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
const features: Feature[] = [];
|
||||
|
||||
for (const feature of fc.features) {
|
||||
@@ -655,12 +669,15 @@ export function roundZoom(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function createFeatureLabelResolver(fc: FeatureCollection): (feature: Feature) => string | null {
|
||||
function createFeatureLabelResolver(
|
||||
fc: FeatureCollection,
|
||||
timelineYear?: number | null
|
||||
): (feature: Feature) => string | null {
|
||||
const directLabelsByFeatureId = new Map<string, FeatureLabelInfo>();
|
||||
const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>();
|
||||
|
||||
for (const feature of fc.features) {
|
||||
const labelInfo = getSingleEntityFeatureLabelInfo(feature);
|
||||
const labelInfo = getSingleEntityFeatureLabelInfo(feature, timelineYear);
|
||||
if (!labelInfo) continue;
|
||||
directLabelsByFeatureId.set(String(feature.properties.id), labelInfo);
|
||||
}
|
||||
@@ -710,14 +727,97 @@ function mergeInheritedFeatureLabel(
|
||||
}
|
||||
}
|
||||
|
||||
function getSingleEntityFeatureLabelInfo(feature: Feature): FeatureLabelInfo | null {
|
||||
function getSingleEntityFeatureLabelInfo(
|
||||
feature: Feature,
|
||||
timelineYear?: number | null
|
||||
): FeatureLabelInfo | null {
|
||||
const candidates = getFeatureEntityLabelCandidates(feature);
|
||||
if (candidates.length > 0) {
|
||||
const timelineCandidate = getLatestTimelineEntityCandidate(candidates, timelineYear);
|
||||
if (!timelineCandidate) return null;
|
||||
return {
|
||||
entityId: timelineCandidate.id,
|
||||
label: timelineCandidate.name,
|
||||
timeEnd: normalizeLabelYear(timelineCandidate.time_end),
|
||||
};
|
||||
}
|
||||
|
||||
const entityIds = getFeatureEntityIds(feature);
|
||||
if (entityIds.length !== 1) return null;
|
||||
|
||||
const label = getSingleEntityName(feature);
|
||||
if (!label) return null;
|
||||
|
||||
return { entityId: entityIds[0], label };
|
||||
return { entityId: entityIds[0], label, timeEnd: null };
|
||||
}
|
||||
|
||||
function getLatestTimelineEntityCandidate(
|
||||
candidates: EntityLabelCandidate[],
|
||||
timelineYear?: number | null
|
||||
): EntityLabelCandidate | null {
|
||||
if (!candidates.length) return null;
|
||||
|
||||
const activeCandidates = candidates.filter((candidate) =>
|
||||
isEntityCandidateVisibleAtYear(candidate, timelineYear)
|
||||
);
|
||||
if (!activeCandidates.length) return null;
|
||||
|
||||
return activeCandidates.sort(compareEntityLabelCandidates)[0] || null;
|
||||
}
|
||||
|
||||
function getFeatureEntityLabelCandidates(feature: Feature): EntityLabelCandidate[] {
|
||||
const rawCandidates = feature.properties.entity_label_candidates;
|
||||
if (!Array.isArray(rawCandidates)) return [];
|
||||
|
||||
const byId = new Map<string, EntityLabelCandidate>();
|
||||
for (const raw of rawCandidates) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const candidate = raw as EntityLabelCandidate;
|
||||
const id = String(candidate.id || "").trim();
|
||||
const name = String(candidate.name || "").trim();
|
||||
if (!id || !name) continue;
|
||||
byId.set(id, {
|
||||
id,
|
||||
name,
|
||||
time_start: normalizeLabelYear(candidate.time_start),
|
||||
time_end: normalizeLabelYear(candidate.time_end),
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function isEntityCandidateVisibleAtYear(
|
||||
candidate: EntityLabelCandidate,
|
||||
timelineYear?: number | null
|
||||
): boolean {
|
||||
if (typeof timelineYear !== "number" || !Number.isFinite(timelineYear)) return true;
|
||||
|
||||
const start = normalizeLabelYear(candidate.time_start);
|
||||
const end = normalizeLabelYear(candidate.time_end);
|
||||
if (start != null && timelineYear < start) return false;
|
||||
if (end != null && timelineYear > end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function compareEntityLabelCandidates(a: EntityLabelCandidate, b: EntityLabelCandidate): number {
|
||||
const endA = normalizeLabelYear(a.time_end);
|
||||
const endB = normalizeLabelYear(b.time_end);
|
||||
const endScoreA = endA == null ? Number.NEGATIVE_INFINITY : endA;
|
||||
const endScoreB = endB == null ? Number.NEGATIVE_INFINITY : endB;
|
||||
if (endScoreA !== endScoreB) return endScoreB - endScoreA;
|
||||
|
||||
const startA = normalizeLabelYear(a.time_start);
|
||||
const startB = normalizeLabelYear(b.time_start);
|
||||
const startScoreA = startA == null ? Number.NEGATIVE_INFINITY : startA;
|
||||
const startScoreB = startB == null ? Number.NEGATIVE_INFINITY : startB;
|
||||
if (startScoreA !== startScoreB) return startScoreB - startScoreA;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function normalizeLabelYear(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function getFeatureEntityIds(feature: Feature): string[] {
|
||||
|
||||
@@ -29,6 +29,8 @@ export function useMapInstance() {
|
||||
|
||||
const [isMapLoaded, setIsMapLoaded] = useState(false);
|
||||
const geolocationCenteredRef = useRef(false);
|
||||
// Ref khóa sync zoom từ MapLibre trong lúc user kéo slider để tránh value bị animate ghi ngược.
|
||||
const isZoomSliderDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -60,6 +62,7 @@ export function useMapInstance() {
|
||||
mapRef.current = map;
|
||||
|
||||
const syncZoomLevel = () => {
|
||||
if (isZoomSliderDraggingRef.current) return;
|
||||
setZoomLevel(roundZoom(map.getZoom()));
|
||||
};
|
||||
|
||||
@@ -80,7 +83,8 @@ export function useMapInstance() {
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Map initialization failed", err);
|
||||
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
|
||||
const message = err instanceof Error ? err.message : "Map initialization failed.";
|
||||
window.setTimeout(() => setFatalInitError(message), 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -121,10 +125,22 @@ export function useMapInstance() {
|
||||
const map = mapRef.current;
|
||||
if (!map || !Number.isFinite(nextRaw)) return;
|
||||
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
|
||||
map.easeTo({ zoom: next, duration: 80 });
|
||||
// Slider cần phản hồi trực tiếp theo pointer; easeTo liên tục sẽ làm thumb bị nhảy ngược.
|
||||
map.jumpTo({ zoom: next });
|
||||
setZoomLevel(next);
|
||||
}, [zoomBounds]);
|
||||
|
||||
const beginZoomSliderDrag = useCallback(() => {
|
||||
isZoomSliderDraggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const endZoomSliderDrag = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
isZoomSliderDraggingRef.current = false;
|
||||
if (!map) return;
|
||||
setZoomLevel(roundZoom(map.getZoom()));
|
||||
}, []);
|
||||
|
||||
const getViewState = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return null;
|
||||
@@ -152,6 +168,8 @@ export function useMapInstance() {
|
||||
geolocationCenteredRef,
|
||||
handleZoomByStep,
|
||||
handleZoomSliderChange,
|
||||
beginZoomSliderDrag,
|
||||
endZoomSliderDrag,
|
||||
getViewState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type UseMapInteractionProps = {
|
||||
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
|
||||
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
|
||||
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
|
||||
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
|
||||
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
|
||||
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
|
||||
};
|
||||
@@ -44,6 +45,7 @@ export function useMapInteraction({
|
||||
onSetModeRef,
|
||||
onCreateRef,
|
||||
onDeleteRef,
|
||||
onHideRef,
|
||||
onUpdateRef,
|
||||
onHoverFeatureChangeRef,
|
||||
}: UseMapInteractionProps) {
|
||||
@@ -149,8 +151,26 @@ export function useMapInteraction({
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
allowGeometryEditing
|
||||
? (id: string | number) => {
|
||||
const originalFeature = draftRef.current.features.find(
|
||||
(item) => String(item.properties.id) === String(id)
|
||||
);
|
||||
if (!originalFeature) return;
|
||||
|
||||
const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature);
|
||||
onCreateRef.current?.(nextFeature);
|
||||
}
|
||||
: undefined,
|
||||
allowGeometryEditing
|
||||
? (id: string | number) => {
|
||||
onHideRef.current?.(id);
|
||||
onSelectFeatureIdsRef.current?.([]);
|
||||
}
|
||||
: undefined,
|
||||
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
||||
(id: string | number) => onSetModeRef.current?.("replay", id)
|
||||
(id: string | number) => onSetModeRef.current?.("replay", id),
|
||||
() => Boolean(editingEngineRef.current?.editingRef.current)
|
||||
);
|
||||
|
||||
const cleanupPoint = initPoint(
|
||||
@@ -324,3 +344,30 @@ export function useMapInteraction({
|
||||
cleanupMapInteractions,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDuplicatedFeatureShapeOnly(
|
||||
feature: FeatureCollection["features"][number]
|
||||
): FeatureCollection["features"][number] {
|
||||
const geometry = cloneGeometry(feature.geometry);
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id: buildClientFeatureId(),
|
||||
type: feature.properties.type ?? null,
|
||||
geometry_preset: feature.properties.geometry_preset ?? null,
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_names: [],
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneGeometry(geometry: Geometry): Geometry {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(geometry);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(geometry)) as Geometry;
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
setSelectedFeatureState,
|
||||
splitDraftFeatures,
|
||||
} from "./mapUtils";
|
||||
import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
|
||||
|
||||
type UseMapSyncProps = {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
draft: FeatureCollection;
|
||||
labelContextDraft?: FeatureCollection;
|
||||
labelTimelineYear?: number | null;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
@@ -31,6 +33,7 @@ type UseMapSyncProps = {
|
||||
focusFeatureCollection?: FeatureCollection | null;
|
||||
focusRequestKey?: string | number | null;
|
||||
focusPadding?: number | maplibregl.PaddingOptions;
|
||||
imageOverlay?: MapImageOverlay | null;
|
||||
allowGeometryEditing: boolean;
|
||||
editingEngineRef: React.MutableRefObject<{
|
||||
editingRef: React.MutableRefObject<{ id: string | number } | null>;
|
||||
@@ -43,6 +46,7 @@ export function useMapSync({
|
||||
mapRef,
|
||||
draft,
|
||||
labelContextDraft,
|
||||
labelTimelineYear,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
@@ -53,29 +57,34 @@ export function useMapSync({
|
||||
focusFeatureCollection,
|
||||
focusRequestKey,
|
||||
focusPadding,
|
||||
imageOverlay,
|
||||
allowGeometryEditing,
|
||||
editingEngineRef,
|
||||
geolocationCenteredRef,
|
||||
}: UseMapSyncProps) {
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
|
||||
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
|
||||
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
|
||||
const respectBindingFilterRef = useRef(respectBindingFilter);
|
||||
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
||||
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
|
||||
|
||||
const fitBoundsAppliedRef = useRef(false);
|
||||
|
||||
useEffect(() => { draftRef.current = draft; }, [draft]);
|
||||
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
|
||||
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
|
||||
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
|
||||
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
|
||||
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
|
||||
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
|
||||
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
|
||||
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
|
||||
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
fitBoundsAppliedRef.current = false;
|
||||
@@ -102,10 +111,11 @@ export function useMapSync({
|
||||
: fc;
|
||||
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
|
||||
const labelContext = labelContextDraftRef.current || fc;
|
||||
const labelTimelineYear = labelTimelineYearRef.current;
|
||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext);
|
||||
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext);
|
||||
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext);
|
||||
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
|
||||
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
|
||||
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
|
||||
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
|
||||
|
||||
countriesSource.setData(labeledGeometries);
|
||||
@@ -135,6 +145,7 @@ export function useMapSync({
|
||||
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
|
||||
if (!source) return;
|
||||
source.setData(fc);
|
||||
moveHighlightLayersToTop(map);
|
||||
}, [mapRef]);
|
||||
|
||||
const tryCenterToUserLocation = useCallback(() => {
|
||||
@@ -175,6 +186,12 @@ export function useMapSync({
|
||||
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
|
||||
}, [highlightFeatures, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
applyImageOverlay(map, imageOverlay);
|
||||
}, [imageOverlay, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
applyDraftToMap(draft);
|
||||
const editingId = editingEngineRef.current?.editingRef?.current?.id;
|
||||
@@ -188,6 +205,7 @@ export function useMapSync({
|
||||
allowGeometryEditing,
|
||||
draft,
|
||||
labelContextDraft,
|
||||
labelTimelineYear,
|
||||
selectedFeatureIds,
|
||||
respectBindingFilter,
|
||||
geometryVisibility,
|
||||
@@ -231,5 +249,19 @@ export function useMapSync({
|
||||
applyDraftToMap,
|
||||
applyHighlightToMap,
|
||||
tryCenterToUserLocation,
|
||||
applyImageOverlayToMap: () => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
applyImageOverlay(map, imageOverlayRef.current);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveHighlightLayersToTop(map: maplibregl.Map) {
|
||||
const layerIds = ["entity-focus-fill", "entity-focus-line", "entity-focus-points"];
|
||||
for (const layerId of layerIds) {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.moveLayer(layerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user