add somenew UI editor feature for more effêcncy

This commit is contained in:
taDuc
2026-05-20 02:14:56 +07:00
parent 488eee1a25
commit 194b3ad3c2
36 changed files with 2608 additions and 597 deletions
+465
View File
@@ -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;
}
+110 -10
View File
@@ -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[] {
+20 -2
View File
@@ -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,
};
}
+48 -1
View File
@@ -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;
}
+35 -3
View File
@@ -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);
}
}
}