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
+5
View File
@@ -63,19 +63,24 @@ export default function Editor({
undoStack,
width = 280,
}: Props) {
// State đóng/mở modal submit project.
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
// State nội dung submit gửi lên backend khi user xác nhận.
const [submitContent, setSubmitContent] = useState("");
// Mở modal submit với nội dung sạch cho lần submit mới.
const handleOpenSubmitModal = () => {
setSubmitContent("");
setIsSubmitModalOpen(true);
};
// Xác nhận submit: đóng modal trước rồi chuyển content cho command cha.
const handleConfirmSubmit = () => {
setIsSubmitModalOpen(false);
onSubmit(submitContent);
};
// Hủy submit mà không thay đổi draft/commit.
const handleCancelSubmit = () => {
setIsSubmitModalOpen(false);
};
+68
View File
@@ -11,6 +11,7 @@ import { useMapInstance } from "./map/useMapInstance";
import { setupMapLayers } from "./map/useMapLayers";
import { useMapInteraction } from "./map/useMapInteraction";
import { useMapSync } from "./map/useMapSync";
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
export type MapHoverPayload = {
featureId: string | number;
@@ -39,8 +40,10 @@ type MapProps = {
onSelectFeatureIds: (ids: (string | number)[]) => void;
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void;
onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
respectBindingFilter?: boolean;
@@ -52,6 +55,8 @@ type MapProps = {
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions;
imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
};
const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -63,8 +68,10 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
selectedFeatureIds,
onSelectFeatureIds,
labelContextDraft,
labelTimelineYear,
onCreateFeature,
onDeleteFeature,
onHideFeature,
onUpdateFeature,
allowGeometryEditing = true,
respectBindingFilter = true,
@@ -76,15 +83,31 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
focusFeatureCollection = null,
focusRequestKey = null,
focusPadding,
imageOverlay = null,
onImageOverlayChange,
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
// Ref giữ draft mới nhất để engine đọc không bị stale closure.
const draftRef = useRef<FeatureCollection>(draft);
// Ref callback select feature mới nhất cho event click trên map.
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
const onSetModeRef = useRef(onSetMode);
// Ref callback hover mới nhất cho tooltip/panel ngoài map.
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
// Ref callback create mới nhất khi drawing engine tạo feature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Ref callback delete mới nhất khi editing engine xóa feature.
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
// Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map.
const onHideRef = useRef<MapProps["onHideFeature"]>(onHideFeature);
// Ref callback update mới nhất khi editing engine đổi geometry.
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
// Ref giữ overlay mới nhất cho right-drag controls.
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay);
// Ref callback update overlay mới nhất để interaction không stale.
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { draftRef.current = draft; }, [draft]);
@@ -93,8 +116,12 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]);
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]);
useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]);
useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]);
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const {
mapRef,
containerRef,
@@ -107,14 +134,18 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
geolocationCenteredRef,
handleZoomByStep,
handleZoomSliderChange,
beginZoomSliderDrag,
endZoomSliderDrag,
getViewState,
} = useMapInstance();
// Public API cho parent đọc map instance/view state mà không expose implementation nội bộ.
useImperativeHandle(ref, () => ({
getViewState,
getMap: () => mapRef.current,
}), [getViewState, mapRef]);
// Hook gắn/dọn các interaction vẽ, chọn, sửa geometry.
const {
editingEngineRef,
setupMapInteractions,
@@ -130,18 +161,22 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
onSetModeRef,
onCreateRef,
onDeleteRef,
onHideRef,
onUpdateRef,
onHoverFeatureChangeRef,
});
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
const {
applyDraftToMap,
applyHighlightToMap,
applyImageOverlayToMap,
tryCenterToUserLocation,
} = useMapSync({
mapRef,
draft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
@@ -152,6 +187,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
focusFeatureCollection,
focusRequestKey,
focusPadding,
imageOverlay,
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
@@ -162,6 +198,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
if (!map || !isMapLoaded) return;
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
applyImageOverlayToMap();
setupMapInteractions(map);
applyDraftToMap(draftRef.current);
tryCenterToUserLocation();
@@ -180,6 +217,17 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
}
}, [mode, isMapLoaded, mapRef]);
const hasImageOverlay = Boolean(imageOverlay);
useEffect(() => {
const map = mapRef.current;
if (!map || !isMapLoaded || !hasImageOverlay) return;
return bindImageOverlayInteractions(
map,
() => imageOverlayRef.current,
(nextOverlay) => onImageOverlayChangeRef.current?.(nextOverlay)
);
}, [hasImageOverlay, isMapLoaded, mapRef]);
return (
<div style={{ width: "100%", height, position: "relative" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
@@ -320,6 +368,26 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
max={zoomBounds.max}
step={0.1}
value={zoomLevel}
onPointerDown={(event) => {
event.stopPropagation();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Browser may reject capture for non-primary pointers; drag lock still works.
}
beginZoomSliderDrag();
}}
onPointerUp={(event) => {
event.stopPropagation();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Ignore if capture was already released.
}
endZoomSliderDrag();
}}
onPointerCancel={endZoomSliderDrag}
onBlur={endZoomSliderDrag}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
style={{
flex: 1,
@@ -8,6 +8,9 @@ import { useEditorStore } from "@/uhm/store/editorStore";
type GeometryChoice = {
id: string;
label?: string;
time_start?: number | null;
time_end?: number | null;
isTimelineVisible?: boolean;
isNew?: boolean;
};
@@ -31,16 +34,16 @@ export default function GeometryBindingPanel({
statusText,
bindingFilterEnabled,
setGeometryBindingFilterEnabled,
hoveredGeometryId,
setHoveredGeometryId,
geometryVisibility,
setGeometryVisibility,
} = useEditorStore(
useShallow((state) => ({
selectedFeatureIds: state.selectedFeatureIds,
statusText: state.geoBindingStatus,
bindingFilterEnabled: state.geometryBindingFilterEnabled,
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
hoveredGeometryId: state.hoveredGeometryId,
setHoveredGeometryId: state.setHoveredGeometryId,
geometryVisibility: state.geometryVisibility,
setGeometryVisibility: state.setGeometryVisibility,
}))
);
const effectiveSelectedGeometryId =
@@ -55,7 +58,14 @@ export default function GeometryBindingPanel({
const rows = useMemo(() => {
const cleaned = (geometries || [])
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim(), isNew: Boolean(g.isNew) }));
.map((g) => ({
id: g.id.trim(),
label: (g.label || "").trim(),
time_start: typeof g.time_start === "number" ? g.time_start : null,
time_end: typeof g.time_end === "number" ? g.time_end : null,
isTimelineVisible: Boolean(g.isTimelineVisible),
isNew: Boolean(g.isNew),
}));
cleaned.sort((a, b) => a.id.localeCompare(b.id));
return cleaned;
}, [geometries]);
@@ -80,15 +90,20 @@ export default function GeometryBindingPanel({
if (!canFocusGeometry) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
setHoveredGeometryId((current) => (current === geometryId ? null : current));
onFocusGeometry?.(geometryId);
};
const handleFocusGeometry = (geometryId: string) => {
setHoveredGeometryId((current) => (current === geometryId ? null : current));
onFocusGeometry?.(geometryId);
};
const toggleGeometryVisibility = (geometryId: string) => {
setGeometryVisibility((prev) => ({
...prev,
[geometryId]: prev[geometryId] === false,
}));
};
return (
<div
style={{
@@ -97,7 +112,6 @@ export default function GeometryBindingPanel({
borderRadius: "8px",
border: "1px solid #1f2937",
}}
onMouseLeave={() => setHoveredGeometryId(null)}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
@@ -148,31 +162,28 @@ export default function GeometryBindingPanel({
</div>
{collapsed ? null : selectedGeometry ? (
(() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false;
const idColor = getGeometryIdColor(selectedGeometry);
const labelColor = selectedGeometry.isTimelineVisible ? "#22c55e" : "#e5e7eb";
return (
<div
style={{
marginTop: 10,
padding: "8px",
borderRadius: "6px",
border:
hoveredGeometryId === selectedGeometry.id
? "1px solid rgba(245, 158, 11, 0.95)"
: "1px solid rgba(59, 130, 246, 0.45)",
background:
hoveredGeometryId === selectedGeometry.id
? "rgba(245, 158, 11, 0.18)"
: "rgba(37, 99, 235, 0.12)",
"1px solid rgba(59, 130, 246, 0.45)",
background: "rgba(37, 99, 235, 0.12)",
cursor: canFocusGeometry ? "pointer" : "default",
boxShadow:
hoveredGeometryId === selectedGeometry.id
? "0 0 0 2px rgba(251, 191, 36, 0.18)"
: "none",
opacity: isHidden ? 0.58 : 1,
boxShadow: "none",
}}
title={selectedGeometry.id}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(selectedGeometry.id)}
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)}
>
<div
style={{
@@ -190,7 +201,7 @@ export default function GeometryBindingPanel({
<span
style={{
fontSize: "12px",
color: "#e5e7eb",
color: labelColor,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
@@ -199,13 +210,26 @@ export default function GeometryBindingPanel({
>
{selectedGeometry.label || selectedGeometry.id}
</span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{selectedGeometry.isNew ? <NewBadge /> : null}
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(selectedGeometry.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${selectedGeometry.id}` : `Hide geometry ${selectedGeometry.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
<div
style={{
marginTop: 3,
fontSize: "11px",
color: "#94a3b8",
color: idColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
@@ -214,6 +238,8 @@ export default function GeometryBindingPanel({
{selectedGeometry.id}
</div>
</div>
);
})()
) : null}
{collapsed ? null : rows.length ? (
@@ -221,36 +247,33 @@ export default function GeometryBindingPanel({
{visibleRows
.map((g) => {
const isBound = bindingSet.has(g.id);
const isHovered = hoveredGeometryId === g.id;
const isHidden = geometryVisibility[g.id] === false;
const idColor = getGeometryIdColor(g);
const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb";
return (
<div
key={g.id}
style={{
padding: "8px",
borderRadius: "6px",
border: isHovered
? "1px solid rgba(245, 158, 11, 0.95)"
: isBound
border: isBound
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isHovered
? "rgba(245, 158, 11, 0.18)"
: isBound
background: isBound
? "rgba(20, 184, 166, 0.12)"
: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
cursor: canFocusGeometry ? "pointer" : "default",
opacity: canBindToggle ? 1 : 0.75,
boxShadow: isHovered ? "0 0 0 2px rgba(251, 191, 36, 0.18)" : "none",
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
boxShadow: "none",
}}
title={g.id}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
onMouseEnter={() => setHoveredGeometryId(g.id)}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -264,7 +287,7 @@ export default function GeometryBindingPanel({
<span
style={{
fontSize: "12px",
color: "#e5e7eb",
color: labelColor,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
@@ -273,13 +296,14 @@ export default function GeometryBindingPanel({
>
{g.label || g.id}
</span>
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
{g.isNew ? <NewBadge /> : null}
</div>
<div
style={{
fontSize: "11px",
color: "#94a3b8",
color: idColor,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
@@ -289,6 +313,19 @@ export default function GeometryBindingPanel({
</div>
</div>
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(g.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${g.id}` : `Hide geometry ${g.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
{canBindToggle ? (
<button
type="button"
@@ -356,6 +393,74 @@ const boundBadgeStyle: CSSProperties = {
letterSpacing: 0,
};
const hiddenBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.32)",
color: "#cbd5e1",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
const iconButtonStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
};
function getGeometryIdColor(geometry: GeometryChoice): string {
const hasStart = typeof geometry.time_start === "number";
const hasEnd = typeof geometry.time_end === "number";
if (!hasStart && !hasEnd) return "#f87171";
if (!hasStart || !hasEnd) return "#facc15";
return "#94a3b8";
}
function EyeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="#cbd5e1" strokeWidth="2" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 3l18 18" stroke="#fca5a5" strokeWidth="2" strokeLinecap="round" />
<path
d="M10.6 6.2A10.5 10.5 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.1 2.8M6.2 8.1A17 17 0 0 0 2.5 12s3.5 6 9.5 6c1.3 0 2.5-.3 3.5-.7"
stroke="#fca5a5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -0,0 +1,127 @@
"use client";
import type { ChangeEvent } from "react";
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
type Props = {
overlay: MapImageOverlay | null;
onPickImage: (file: File | null) => void;
onPasteImage: () => void;
keyboardEnabled: boolean;
onKeyboardEnabledChange: (enabled: boolean) => void;
onOpacityChange: (opacity: number) => void;
onRemove: () => void;
};
export default function ImageOverlayPanel({
overlay,
onPickImage,
onPasteImage,
keyboardEnabled,
onKeyboardEnabledChange,
onOpacityChange,
onRemove,
}: Props) {
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
onPickImage(file);
event.target.value = "";
};
return (
<section style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<div style={{ fontWeight: 800, fontSize: 13, color: "white" }}>Trace Image</div>
<div style={{ marginTop: 2, fontSize: 11, color: "#94a3b8" }}>
Chuột phải kéo điểm vàng đ di chuyển, điểm xanh đ kéo dãn giữ ratio.
{keyboardEnabled ? " WASD di chuyển, Q/E phóng to/thu nhỏ." : ""}
</div>
</div>
{overlay ? (
<button type="button" onClick={onRemove} style={dangerButtonStyle}>
Remove
</button>
) : null}
</div>
<div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}>
<label style={uploadButtonStyle}>
{overlay ? "Đổi ảnh" : "Thêm ảnh"}
<input
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: "none" }}
/>
</label>
<button type="button" onClick={onPasteImage} style={uploadButtonStyle}>
Paste nh
</button>
<button
type="button"
onClick={() => onKeyboardEnabledChange(!keyboardEnabled)}
disabled={!overlay}
style={{
...uploadButtonStyle,
opacity: overlay ? 1 : 0.5,
color: keyboardEnabled ? "#86efac" : "#93c5fd",
cursor: overlay ? "pointer" : "not-allowed",
}}
title="Bật/tắt điều khiển ảnh bằng WASD và Q/E"
>
Keys: {keyboardEnabled ? "On" : "Off"}
</button>
</div>
{overlay ? (
<div style={{ marginTop: 10, display: "grid", gap: 8 }}>
<div style={{ fontSize: 12, color: "#cbd5e1", overflowWrap: "anywhere" }}>
{overlay.name}
</div>
<label style={{ display: "grid", gap: 6, fontSize: 12, color: "#cbd5e1" }}>
<span>Opacity: {Math.round(overlay.opacity * 100)}%</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={overlay.opacity}
onChange={(event) => onOpacityChange(Number(event.target.value))}
style={{ width: "100%", accentColor: "#38bdf8" }}
/>
</label>
</div>
) : (
<div style={{ marginTop: 8, fontSize: 12, color: "#94a3b8" }}>
Chưa nh overlay.
</div>
)}
</section>
);
}
const uploadButtonStyle = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #334155",
borderRadius: 6,
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "7px 10px",
} as const;
const dangerButtonStyle = {
border: "1px solid #7f1d1d",
borderRadius: 6,
background: "#1f1111",
color: "#fecaca",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "6px 8px",
} as const;
@@ -8,8 +8,9 @@ import { useEditorStore } from "@/uhm/store/editorStore";
type Props = {
onCreateEntityOnly: () => void;
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => void;
hasSelectedGeometry?: boolean;
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
};
@@ -17,6 +18,7 @@ export default function ProjectEntityRefsPanel({
onCreateEntityOnly,
onUpdateEntity,
hasSelectedGeometry,
selectedGeometryTime,
onToggleBindEntityForSelectedGeometry,
}: Props) {
const {
@@ -78,15 +80,35 @@ export default function ProjectEntityRefsPanel({
);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editTimeStart, setEditTimeStart] = useState("");
const [editTimeEnd, setEditTimeEnd] = useState("");
const canCopySelectedGeometryTime =
selectedGeometryTime != null &&
(selectedGeometryTime.time_start != null || selectedGeometryTime.time_end != null);
const openEntityEditor = (entity: EntitySnapshot) => {
setActiveEntityId(String(entity.id));
setEditName(typeof entity.name === "string" ? entity.name : "");
setEditDescription(entity.description == null ? "" : String(entity.description));
setEditTimeStart(entity.time_start != null ? String(entity.time_start) : "");
setEditTimeEnd(entity.time_end != null ? String(entity.time_end) : "");
};
const handleEntityFormChange = (key: "name" | "description", value: string) => {
const handleEntityFormChange = (key: "name" | "description" | "time_start" | "time_end", value: string) => {
setEntityForm((prev) => ({ ...prev, [key]: value }));
};
const copySelectedGeometryTimeToCreateForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEntityForm((prev) => ({
...prev,
time_start: selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "",
time_end: selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "",
}));
};
const copySelectedGeometryTimeToEditForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEditTimeStart(selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "");
setEditTimeEnd(selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "");
};
return (
<div
@@ -237,27 +259,27 @@ export default function ProjectEntityRefsPanel({
</span>
{isNewEntityRef(activeEntity) ? <NewBadge /> : null}
</div>
<button
type="button"
onClick={() => setActiveEntityId(null)}
title="Dong"
aria-label="Dong sua entity"
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
<CloseIcon />
</button>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<button
type="button"
onClick={copySelectedGeometryTimeToEditForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity dang sua"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
<button
type="button"
onClick={() => setActiveEntityId(null)}
title="Dong"
aria-label="Dong sua entity"
style={iconButtonStyle(false)}
>
<CloseIcon />
</button>
</div>
</div>
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
@@ -277,10 +299,31 @@ export default function ProjectEntityRefsPanel({
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={timeInputGridStyle}>
<input
value={editTimeStart}
onChange={(event) => setEditTimeStart(event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={editTimeEnd}
onChange={(event) => setEditTimeEnd(event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<button
type="button"
onClick={() => onUpdateEntity!(String(activeEntity.id), { name: editName, description: editDescription.trim().length ? editDescription : null })}
onClick={() => onUpdateEntity!(String(activeEntity.id), {
name: editName,
description: editDescription.trim().length ? editDescription : null,
time_start: editTimeStart,
time_end: editTimeEnd,
})}
disabled={isEntitySubmitting}
style={{
border: "none",
@@ -315,29 +358,30 @@ export default function ProjectEntityRefsPanel({
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới
</div>
<button
type="button"
onClick={() => setIsCreateOpen((v) => !v)}
disabled={isEntitySubmitting}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
opacity: isEntitySubmitting ? 0.6 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{isCreateOpen ? (
<button
type="button"
onClick={copySelectedGeometryTimeToCreateForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity moi"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
) : null}
<button
type="button"
onClick={() => setIsCreateOpen((v) => !v)}
disabled={isEntitySubmitting}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
style={iconButtonStyle(isEntitySubmitting)}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
</div>
{isCreateOpen ? (
@@ -356,6 +400,22 @@ export default function ProjectEntityRefsPanel({
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={timeInputGridStyle}>
<input
value={entityForm.time_start}
onChange={(event) => handleEntityFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.time_end}
onChange={(event) => handleEntityFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<button
type="button"
@@ -403,6 +463,29 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px",
};
const timeInputGridStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
};
function iconButtonStyle(disabled: boolean): CSSProperties {
return {
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
};
}
const boundBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
@@ -488,3 +571,12 @@ function CloseIcon() {
</svg>
);
}
function ClockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="8" stroke="#fbbf24" strokeWidth="2" />
<path d="M12 7v5l3 2" stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+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);
}
}
}
+1
View File
@@ -60,6 +60,7 @@ Các type đang được register:
- `state`
- `empire`
- `kingdom`
- `faction`
- `war`
- `battle`
- `civilization`
@@ -51,11 +51,26 @@ export function buildFeatureEntityPatch(
const entityNames = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0);
const entityLabelCandidates = entityIds
.map((id) => {
const entity = entities.find((item) => item.id === id) || null;
if (!entity) return null;
const name = String(entity.name || "").trim();
if (!name) return null;
return {
id,
name,
time_start: entity.time_start ?? null,
time_end: entity.time_end ?? null,
};
})
.filter((candidate) => candidate !== null);
return {
entity_id: primaryEntityId,
entity_ids: entityIds,
entity_name: primaryEntity?.name || null,
entity_names: entityNames,
entity_label_candidates: entityLabelCandidates,
};
}
@@ -311,6 +311,8 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
operation: "reference",
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description ?? null,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
};
});
}
@@ -19,6 +19,8 @@ export type TimelineRange = {
export type EntityFormState = {
name: string;
description: string;
time_start: string;
time_end: string;
};
export type GeometryMetaFormState = {
@@ -20,6 +20,8 @@ export function useEntitySessionState() {
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
description: "",
time_start: "",
time_end: "",
});
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
@@ -31,6 +31,8 @@ interface RawEntityRow extends UnknownRecord {
ref?: { id?: string };
name?: string;
description?: string;
time_start?: unknown;
time_end?: unknown;
}
interface RawWikiRow extends UnknownRecord {
@@ -124,6 +126,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
operation,
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start == null ? undefined : undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end == null ? undefined : undefined,
};
})
: undefined;
@@ -266,12 +270,17 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
byGeom.set(row.geometry_id, list);
}
const entityNameById = new Map<string, string>();
const entityTimeById = new Map<string, { time_start: number | null; time_end: number | null }>();
for (const r of entities || []) {
const row = r as RawEntityRow;
const id = typeof row?.id === "string" ? row.id : "";
if (!id) continue;
const name = typeof row.name === "string" ? String(row.name).trim() : "";
if (name) entityNameById.set(id, name);
entityTimeById.set(id, {
time_start: typeof row.time_start === "number" ? row.time_start : null,
time_end: typeof row.time_end === "number" ? row.time_end : null,
});
}
const geometryById = new Map<string, GeometrySnapshot>();
for (const row of geometries || []) {
@@ -299,6 +308,19 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0);
p.entity_name = primaryName || null;
p.entity_names = names;
p.entity_label_candidates = entity_ids
.map((id) => {
const name = entityNameById.get(id) || "";
if (!name) return null;
const time = entityTimeById.get(id) || { time_start: null, time_end: null };
return {
id,
name,
time_start: time.time_start,
time_end: time.time_end,
};
})
.filter((candidate) => candidate !== null);
}
// Generate geometry metadata onto feature properties (optional in persisted snapshot).
@@ -388,6 +410,8 @@ export function buildEditorSnapshot(options: {
operation: "reference",
name: typeof cloned.name === "string" ? cloned.name : undefined,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
});
}
for (const row of options.snapshotEntities || []) {
@@ -411,6 +435,8 @@ export function buildEditorSnapshot(options: {
name,
operation,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
});
}
@@ -537,6 +563,7 @@ export function buildEditorSnapshot(options: {
delete p.entity_ids;
delete p.entity_name;
delete p.entity_names;
delete p.entity_label_candidates;
delete p.entity_type_id;
}
@@ -662,6 +689,8 @@ export function buildEditorSnapshot(options: {
operation: e.operation,
name: typeof e.name === "string" ? e.name : undefined,
description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null,
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
}))
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
+141 -62
View File
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
export type EditingHandle = {
id: string | number;
@@ -25,12 +26,16 @@ export function createEditingEngine(options: {
const { mapRef, onUpdate } = options;
const editingRef = { current: null as EditingHandle | null };
const dragStateRef = { current: null as { idx: number } | null };
const modifierRef = { current: { ctrl: false, meta: false } };
const deleteVertexModeRef = { current: false };
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
const clearEditing = () => {
editingRef.current = null;
dragStateRef.current = null;
setDeleteVertexMode(false);
hideContextMenu();
const map = mapRef.current;
if (!map) return;
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
@@ -135,6 +140,14 @@ export function createEditingEngine(options: {
clearEditing();
};
const setDeleteVertexMode = (enabled: boolean) => {
deleteVertexModeRef.current = enabled;
const map = mapRef.current;
if (!map?.getLayer("edit-handles-circle")) return;
map.setPaintProperty("edit-handles-circle", "circle-color", enabled ? "#ef4444" : "#f97316");
map.setPaintProperty("edit-handles-circle", "circle-stroke-color", enabled ? "#7f1d1d" : "#0f172a");
};
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return;
@@ -154,18 +167,19 @@ export function createEditingEngine(options: {
circleCenter: geom.circle_center,
circleRadius: geom.circle_radius,
};
setDeleteVertexMode(false);
updateEditSources();
};
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
const oe = e?.originalEvent as MouseEvent | undefined;
return (
modifierRef.current.ctrl ||
modifierRef.current.meta ||
!!oe?.ctrlKey ||
!!oe?.metaKey
);
const hideContextMenu = () => {
if (contextMenu) {
contextMenu.remove();
contextMenu = null;
}
if (docClickHandler) {
document.removeEventListener("click", docClickHandler);
docClickHandler = null;
}
};
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
@@ -173,10 +187,16 @@ export function createEditingEngine(options: {
// Bắt đầu kéo một handle point.
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return;
if (e.originalEvent.button === 2) return;
const feature = e.features?.[0];
const idx = feature?.properties?.idx;
if (idx === undefined) return;
const idx = Number(feature?.properties?.idx);
if (!Number.isInteger(idx)) return;
e.preventDefault();
if (deleteVertexModeRef.current) {
e.originalEvent.stopPropagation();
deleteVertex(idx);
return;
}
dragStateRef.current = { idx };
map.getCanvas().style.cursor = "grabbing";
map.dragPan.disable();
@@ -188,19 +208,21 @@ export function createEditingEngine(options: {
const editing = editingRef.current;
if (!drag || !editing) return;
const lngLat = e.originalEvent.shiftKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
const nextCoordinate: [number, number] = [lngLat.lng, lngLat.lat];
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
if (drag.idx === 0) {
// Move center
editing.circleCenter = [e.lngLat.lng, e.lngLat.lat];
editing.circleCenter = nextCoordinate;
} else if (drag.idx === 1) {
// Change radius
editing.circleRadius = distanceMeters(editing.circleCenter, [
e.lngLat.lng,
e.lngLat.lat,
]);
editing.circleRadius = distanceMeters(editing.circleCenter, nextCoordinate);
}
} else {
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
editing.ring[drag.idx] = nextCoordinate;
}
updateEditSources();
};
@@ -212,55 +234,39 @@ export function createEditingEngine(options: {
map.dragPan.enable();
};
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
// Bắt phím điều khiển phiên chỉnh sửa.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Control") {
modifierRef.current.ctrl = true;
} else if (e.key === "Meta") {
modifierRef.current.meta = true;
}
if (!editingRef.current) return;
const editing = editingRef.current;
if (!editing) return;
if (e.key === "Enter") {
finishEditing();
} else if (e.key === "Delete" && !editing.isCircle) {
e.preventDefault();
setDeleteVertexMode(!deleteVertexModeRef.current);
} else if (e.key === "Escape") {
if (deleteVertexModeRef.current) {
e.preventDefault();
setDeleteVertexMode(false);
return;
}
cancelEditing();
}
};
// Hạ cờ modifier khi nhả phím.
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "Control") {
modifierRef.current.ctrl = false;
} else if (e.key === "Meta") {
modifierRef.current.meta = false;
}
};
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current || editingRef.current.isCircle) return;
if (!isModifierPressed(e)) return;
e.preventDefault();
// Chuột phải vào handle để mở menu xóa/thêm đỉnh.
const onHandleContextMenu = (e: maplibregl.MapLayerMouseEvent) => {
const editing = editingRef.current;
const ring = editing.ring;
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number];
let nearestIdx = 0;
let bestDist = Number.POSITIVE_INFINITY;
ring.forEach((pt, idx) => {
const dx = pt[0] - click[0];
const dy = pt[1] - click[1];
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt.
if (d < bestDist) {
bestDist = d;
nearestIdx = idx;
}
});
const insertIdx = nearestIdx + 1;
ring.splice(insertIdx, 0, click);
dragStateRef.current = { idx: insertIdx };
map.getCanvas().style.cursor = "grabbing";
map.dragPan.disable();
updateEditSources();
if (!editing || editing.isCircle) return;
e.preventDefault();
e.originalEvent.stopPropagation();
const feature = e.features?.[0];
const idx = Number(feature?.properties?.idx);
if (!Number.isInteger(idx)) return;
showHandleContextMenu(
e.originalEvent.clientX,
e.originalEvent.clientY,
idx
);
};
// Ngắt kéo nếu con trỏ rời canvas.
@@ -269,24 +275,97 @@ export function createEditingEngine(options: {
};
map.on("mousedown", "edit-handles-circle", onHandleDown);
map.on("mousedown", "edit-shape-line", onInsertHandle);
map.on("contextmenu", "edit-handles-circle", onHandleContextMenu);
map.on("mousemove", onHandleMove);
map.on("mouseup", stopDragging);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
map.on("remove", () => {
map.off("mousedown", "edit-handles-circle", onHandleDown);
map.off("mousedown", "edit-shape-line", onInsertHandle);
map.off("contextmenu", "edit-handles-circle", onHandleContextMenu);
map.off("mousemove", onHandleMove);
map.off("mouseup", stopDragging);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
hideContextMenu();
});
};
const showHandleContextMenu = (x: number, y: number, idx: number) => {
hideContextMenu();
const menu = document.createElement("div");
menu.style.position = "fixed";
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.background = "#0f172a";
menu.style.color = "white";
menu.style.border = "1px solid #1f2937";
menu.style.borderRadius = "6px";
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
menu.style.zIndex = "9999";
menu.style.minWidth = "120px";
menu.style.fontSize = "14px";
menu.style.padding = "4px 0";
const createItem = (label: string, onClick: () => void, disabled = false) => {
const item = document.createElement("div");
item.textContent = label;
item.style.padding = "8px 12px";
item.style.cursor = disabled ? "not-allowed" : "pointer";
item.style.opacity = disabled ? "0.45" : "1";
item.onmouseenter = () => {
if (!disabled) item.style.background = "#1f2937";
};
item.onmouseleave = () => (item.style.background = "transparent");
item.onclick = () => {
if (disabled) return;
onClick();
hideContextMenu();
};
return item;
};
const editing = editingRef.current;
const canDelete = Boolean(editing && !editing.isCircle && editing.ring.length > 3);
menu.appendChild(createItem("Xóa đỉnh", () => deleteVertex(idx), !canDelete));
menu.appendChild(createItem("Thêm đỉnh", () => insertVertexAfter(idx)));
document.body.appendChild(menu);
contextMenu = menu;
const onDocClick = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
hideContextMenu();
}
};
docClickHandler = onDocClick;
setTimeout(() => document.addEventListener("click", onDocClick), 0);
};
const deleteVertex = (idx: number) => {
const editing = editingRef.current;
if (!editing || editing.isCircle || editing.ring.length <= 3) return;
if (idx < 0 || idx >= editing.ring.length) return;
editing.ring.splice(idx, 1);
updateEditSources();
};
const insertVertexAfter = (idx: number) => {
const editing = editingRef.current;
if (!editing || editing.isCircle || editing.ring.length < 2) return;
if (idx < 0 || idx >= editing.ring.length) return;
const current = editing.ring[idx];
const next = editing.ring[(idx + 1) % editing.ring.length];
const midpoint: [number, number] = [
(current[0] + next[0]) / 2,
(current[1] + next[1]) / 2,
];
editing.ring.splice(idx + 1, 0, midpoint);
updateEditSources();
};
return {
beginEditing,
clearEditing,
+11 -2
View File
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
@@ -74,7 +75,11 @@ export function initLine(
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-line") return;
coords.push([e.lngLat.lng, e.lngLat.lat]);
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
coords.push([lngLat.lng, lngLat.lat]);
updatePreview(coords);
};
@@ -94,7 +99,11 @@ export function initLine(
canvas.style.cursor = "crosshair";
if (coords.length === 0) return;
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
updatePreview([...coords, [lngLat.lng, lngLat.lat]]);
};
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
+10 -2
View File
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
@@ -75,7 +76,11 @@ export function initPath(
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-path") return;
coords.push([e.lngLat.lng, e.lngLat.lat]);
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
coords.push([lngLat.lng, lngLat.lat]);
updatePreview(coords);
};
@@ -96,7 +101,10 @@ export function initPath(
canvas.style.cursor = "crosshair";
if (coords.length === 0) return;
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
updatePreview([...coords, [lngLat.lng, lngLat.lat]]);
};
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
+6 -1
View File
@@ -1,6 +1,7 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
// Khởi tạo engine thêm point bằng click đơn.
export function initPoint(
@@ -12,9 +13,13 @@ export function initPoint(
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "add-point") return;
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
const geometry: Geometry = {
type: "Point",
coordinates: [e.lngLat.lng, e.lngLat.lat],
coordinates: [lngLat.lng, lngLat.lat],
};
onComplete?.(geometry);
+28 -3
View File
@@ -7,8 +7,11 @@ export function initSelect(
getMode: ModeGetter,
onDelete?: (id: string | number) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onDuplicate?: (id: string | number) => void,
onHide?: (id: string | number) => void,
onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void
onReplayEdit?: (id: string | number) => void,
isEditSessionActive?: () => boolean
) {
const FEATURE_STATE_SOURCES = [
@@ -17,7 +20,7 @@ export function initSelect(
"path-arrow-shapes",
] as const;
const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit || onReplayEdit);
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit);
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
@@ -56,6 +59,7 @@ export function initSelect(
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select" && getMode() !== "replay") return;
if (isEditSessionActive?.()) return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
@@ -81,6 +85,7 @@ export function initSelect(
e.preventDefault(); // block browser menu
if (getMode() === "replay") return;
if (isEditSessionActive?.()) return;
const features = map.queryRenderedFeatures(e.point, {
layers: selectableLayers,
@@ -125,7 +130,11 @@ export function initSelect(
const style = map.getStyle();
if (!style || !style.layers) return [];
return style.layers
.filter((layer) => "source" in layer && FEATURE_STATE_SOURCES.includes(layer.source as any))
.filter((layer) =>
"source" in layer &&
typeof layer.source === "string" &&
FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number])
)
.map((layer) => layer.id);
}
@@ -236,6 +245,22 @@ export function initSelect(
hasMenuItems = true;
}
if (selectedCount === 1 && onDuplicate) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId)));
hasMenuItems = true;
}
}
if (selectedCount === 1 && onHide) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId !== undefined && featureId !== null) {
menu.appendChild(createItem("Hide", () => onHide(featureId)));
hasMenuItems = true;
}
}
if (onReplayEdit) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) {
+99 -17
View File
@@ -1,6 +1,16 @@
import maplibregl from "maplibre-gl";
import { PATH_ARROW_SOURCE_ID } from "@/uhm/lib/map/constants";
const SNAP_THRESHOLD_PX = 15;
// SHIFT/ALT snap should be forgiving while drawing quickly.
// Vertices get a larger radius and always win over edges when both are available.
const VERTEX_SNAP_THRESHOLD_PX = 34;
const EDGE_SNAP_THRESHOLD_PX = 24;
const QUERY_THRESHOLD_PX = Math.max(VERTEX_SNAP_THRESHOLD_PX, EDGE_SNAP_THRESHOLD_PX);
type Coordinate = [number, number];
type GeometryWithCoordinates = Exclude<GeoJSON.Geometry, GeoJSON.GeometryCollection> & {
coordinates: unknown;
};
export function snapToNearestGeometry(
map: maplibregl.Map,
@@ -8,14 +18,21 @@ export function snapToNearestGeometry(
pointPx: maplibregl.Point
): maplibregl.LngLat {
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[pointPx.x - SNAP_THRESHOLD_PX, pointPx.y - SNAP_THRESHOLD_PX],
[pointPx.x + SNAP_THRESHOLD_PX, pointPx.y + SNAP_THRESHOLD_PX],
[pointPx.x - QUERY_THRESHOLD_PX, pointPx.y - QUERY_THRESHOLD_PX],
[pointPx.x + QUERY_THRESHOLD_PX, pointPx.y + QUERY_THRESHOLD_PX],
];
const features = map.queryRenderedFeatures(bbox);
const snapLayerIds = getSnapLayerIds(map);
if (!snapLayerIds.length) return lngLat;
let nearestDist = Infinity;
let nearestLngLat: maplibregl.LngLat | null = null;
const features = map.queryRenderedFeatures(bbox, {
layers: snapLayerIds,
});
let nearestVertexDist = Infinity;
let nearestVertexLngLat: maplibregl.LngLat | null = null;
let nearestEdgeDist = Infinity;
let nearestEdgeLngLat: maplibregl.LngLat | null = null;
const getDistSq = (p1: maplibregl.Point, p2: maplibregl.Point) => {
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
@@ -34,24 +51,49 @@ export function snapToNearestGeometry(
return new maplibregl.Point(a.x + atob.x * t, a.y + atob.y * t);
};
const processVertex = (coordinate: Coordinate) => {
const vertexLngLat = new maplibregl.LngLat(coordinate[0], coordinate[1]);
const vertexPx = map.project(vertexLngLat);
const distSq = getDistSq(pointPx, vertexPx);
if (
distSq < nearestVertexDist &&
distSq <= VERTEX_SNAP_THRESHOLD_PX ** 2
) {
nearestVertexDist = distSq;
nearestVertexLngLat = vertexLngLat;
}
};
const processLineString = (line: number[][]) => {
if (!line || line.length < 2) return;
for (let i = 0; i < line.length - 1; i++) {
const p1LngLat = new maplibregl.LngLat(line[i][0], line[i][1]);
const p2LngLat = new maplibregl.LngLat(line[i + 1][0], line[i + 1][1]);
const start = toCoordinate(line[i]);
const end = toCoordinate(line[i + 1]);
if (!start || !end) continue;
processVertex(start);
if (i === line.length - 2) processVertex(end);
const p1LngLat = new maplibregl.LngLat(start[0], start[1]);
const p2LngLat = new maplibregl.LngLat(end[0], end[1]);
const p1 = map.project(p1LngLat);
const p2 = map.project(p2LngLat);
const closestPx = getClosestPointOnSegment(pointPx, p1, p2);
const distSq = getDistSq(pointPx, closestPx);
if (distSq < nearestDist && distSq <= SNAP_THRESHOLD_PX ** 2) {
nearestDist = distSq;
nearestLngLat = map.unproject(closestPx);
if (distSq < nearestEdgeDist && distSq <= EDGE_SNAP_THRESHOLD_PX ** 2) {
nearestEdgeDist = distSq;
nearestEdgeLngLat = map.unproject(closestPx);
}
}
};
const processPoint = (coordinate: unknown) => {
const point = toCoordinate(coordinate);
if (point) processVertex(point);
};
for (const feature of features) {
if (!feature.geometry) continue;
@@ -62,21 +104,61 @@ export function snapToNearestGeometry(
const type = feature.geometry.type;
if (type === "GeometryCollection") continue;
const coords = (feature.geometry as any).coordinates;
const coords = (feature.geometry as GeometryWithCoordinates).coordinates;
// Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString
if (type === "Polygon") {
for (const ring of coords) processLineString(ring);
for (const ring of asCoordinateMatrix(coords)) processLineString(ring);
} else if (type === "MultiPolygon") {
for (const poly of coords) {
for (const poly of asCoordinateTensor(coords)) {
for (const ring of poly) processLineString(ring);
}
} else if (type === "LineString") {
processLineString(coords);
processLineString(asCoordinateArray(coords));
} else if (type === "MultiLineString") {
for (const line of coords) processLineString(line);
for (const line of asCoordinateMatrix(coords)) processLineString(line);
} else if (type === "Point") {
processPoint(coords);
} else if (type === "MultiPoint") {
for (const point of asCoordinateArray(coords)) processPoint(point);
}
}
return nearestLngLat || lngLat;
return nearestVertexLngLat || nearestEdgeLngLat || lngLat;
}
function getSnapLayerIds(map: maplibregl.Map): string[] {
const systemGeometrySources = new Set(["countries", "places", PATH_ARROW_SOURCE_ID]);
const style = map.getStyle();
if (!style?.layers?.length) return [];
return style.layers
.filter((layer) => {
if (!("source" in layer)) return false;
if (!systemGeometrySources.has(String(layer.source))) return false;
if (layer.id.includes("preview") || layer.id.includes("edit-")) return false;
return true;
})
.map((layer) => layer.id)
.filter((layerId) => Boolean(map.getLayer(layerId)));
}
function toCoordinate(value: unknown): Coordinate | null {
if (!Array.isArray(value) || value.length < 2) return null;
const lng = Number(value[0]);
const lat = Number(value[1]);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
return [lng, lat];
}
function asCoordinateArray(value: unknown): number[][] {
return Array.isArray(value) ? value as number[][] : [];
}
function asCoordinateMatrix(value: unknown): number[][][] {
return Array.isArray(value) ? value as number[][][] : [];
}
function asCoordinateTensor(value: unknown): number[][][][] {
return Array.isArray(value) ? value as number[][][][] : [];
}
+2 -2
View File
@@ -8,10 +8,11 @@
{ "type_key": "trade_route", "geo_type_code": 7 },
{ "type_key": "shipping_route", "geo_type_code": 8 },
{ "type_key": "country", "geo_type_code": 9 },
{ "type_key": "country", "geo_type_code": 9, "fixed": true },
{ "type_key": "state", "geo_type_code": 10 },
{ "type_key": "empire", "geo_type_code": 11 },
{ "type_key": "kingdom", "geo_type_code": 12 },
{ "type_key": "faction", "geo_type_code": 28 },
{ "type_key": "war", "geo_type_code": 13 },
{ "type_key": "battle", "geo_type_code": 14 },
@@ -30,4 +31,3 @@
{ "type_key": "port", "geo_type_code": 26 },
{ "type_key": "bridge", "geo_type_code": 27 }
]
@@ -75,6 +75,7 @@ const RAW_GEOMETRY_TYPE_OPTIONS: Array<{
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
{ value: "faction", label: "Faction", groupId: "polygon", geometryPreset: "polygon" },
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
+2
View File
@@ -14,6 +14,7 @@ import { getCountryLayers } from "./geotypes/country";
import { getStateLayers } from "./geotypes/state";
import { getEmpireLayers } from "./geotypes/empire";
import { getKingdomLayers } from "./geotypes/kingdom";
import { getFactionLayers } from "./geotypes/faction";
import { getWarLayers } from "./geotypes/war";
import { getBattleLayers } from "./geotypes/battle";
import { getCivilizationLayers } from "./geotypes/civilization";
@@ -40,6 +41,7 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
...getKingdomLayers(sourceId, pathArrowSourceId, pointSourceId),
...getFactionLayers(sourceId, pathArrowSourceId, pointSourceId),
...getWarLayers(sourceId, pathArrowSourceId, pointSourceId),
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId),
@@ -0,0 +1,15 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getFactionLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
void pointSourceId;
return buildPolygonGeotypeLayers(sourceId, {
typeId: "faction",
fillColor: "#f97316",
strokeColor: "#9a3412",
fillOpacity: 0.3,
strokeWidth: { z1: 1.6, z4: 2.3, z6: 3.1 },
dasharray: [2, 1.5],
});
}
+26 -3
View File
@@ -35,11 +35,14 @@ export type GeometryFocusRequest = {
};
type EditorStoreValues = {
// Editor mode + draft seed.
mode: EditorMode;
initialData: FeatureCollection;
// Task flags; setTaskFlag ensures only one blocking task is active at a time.
isSaving: boolean;
isSubmitting: boolean;
isOpeningSection: boolean;
// Project/commit state loaded from backend.
availableSections: Project[];
selectedProjectId: string;
newSectionTitle: string;
@@ -49,6 +52,7 @@ type EditorStoreValues = {
projectState: ProjectState | null;
sectionCommits: ProjectCommit[];
baselineSnapshot: EditorSnapshot | null;
// Entity state: backend catalog plus snapshot-local rows and form/search status.
entityCatalog: Entity[];
snapshotEntities: EntitySnapshot[];
entityStatus: string | null;
@@ -60,12 +64,15 @@ type EditorStoreValues = {
entityFormStatus: string | null;
entitySearchResults: Entity[];
isEntitySearchLoading: boolean;
// Timeline + background layer state for map filtering/rendering.
timelineDraftYear: number;
backgroundVisibility: BackgroundLayerVisibility;
isBackgroundVisibilityReady: boolean;
// Wiki state and entity-wiki relationship snapshot.
snapshotWikis: WikiSnapshot[];
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
blockedPendingSubmissionId: string | null;
// Unified search state shared by entity/wiki/geo search UI.
searchKind: UnifiedSearchKind;
searchQuery: string;
searchQueryDraft: string;
@@ -74,12 +81,14 @@ type EditorStoreValues = {
geoSearchResults: EntityGeometriesSearchItem[];
isGeoSearching: boolean;
requestedActiveWikiId: string | null;
// Layout sizing and panel filter state.
leftPanelWidth: number;
rightPanelWidth: number;
timelineFilterEnabled: boolean;
geometryBindingFilterEnabled: boolean;
geoBindingStatus: string | null;
hoveredGeometryId: string | null;
// Map focus/replay visibility state.
geometryFocusRequest: GeometryFocusRequest | null;
replayFeatureId: string | number | null;
hideOutside: boolean;
@@ -148,10 +157,12 @@ export type EditorStoreOptions = {
currentYear: number;
};
// Hỗ trợ setter nhận cả value trực tiếp và updater function giống React setState.
function resolveNextState<T>(next: SetStateAction<T>, prev: T): T {
return typeof next === "function" ? (next as (prevState: T) => T)(prev) : next;
}
// Khởi tạo visibility mặc định cho từng geo type trong map.
function buildInitialGeometryVisibility() {
const next: Record<string, boolean> = {};
for (const key of GEO_TYPE_KEYS) {
@@ -161,6 +172,7 @@ function buildInitialGeometryVisibility() {
}
export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
// Năm timeline ban đầu luôn nằm trong fixed range để slider/map nhất quán.
const initialTimelineYear = clampYearValue(
options.currentYear,
options.fallbackTimelineRange.min,
@@ -168,15 +180,21 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
);
return createStore<EditorStoreState>()((set) => {
// Setter generic để tránh lặp boilerplate cho từng field trong store.
const setValue = <K extends keyof EditorStoreValues>(
key: K,
next: SetStateAction<EditorStoreValues[K]>
) => {
set((state) => ({
[key]: resolveNextState(next, state[key]),
} as Pick<EditorStoreValues, K>));
set((state) => {
const nextValue = resolveNextState(next, state[key]);
if (Object.is(nextValue, state[key])) return state;
return {
[key]: nextValue,
} as Pick<EditorStoreValues, K>;
});
};
// Setter riêng cho task flags vì saving/submitting/opening không nên đồng thời true.
const setTaskFlag = (
task: "saving" | "submitting" | "opening-project",
next: SetStateAction<boolean>
@@ -230,6 +248,8 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
entityForm: {
name: "",
description: "",
time_start: "",
time_end: "",
},
selectedGeometryEntityIds: [],
geometryMetaForm: {
@@ -327,6 +347,7 @@ type EditorStoreProviderProps = {
};
export function EditorStoreProvider({ children, options }: EditorStoreProviderProps) {
// State giữ store instance ổn định trong suốt vòng đời provider.
const [store] = useState(() => createEditorStore(options));
return (
@@ -337,6 +358,7 @@ export function EditorStoreProvider({ children, options }: EditorStoreProviderPr
}
export function useEditorStore<T>(selector: (state: EditorStoreState) => T) {
// Hook đọc store bằng selector để component chỉ re-render theo slice cần thiết.
const store = useContext(EditorStoreContext);
if (!store) {
throw new Error("useEditorStore must be used within EditorStoreProvider.");
@@ -346,6 +368,7 @@ export function useEditorStore<T>(selector: (state: EditorStoreState) => T) {
}
export function useEditorStoreApi() {
// Hook lấy raw StoreApi cho command layer cần getState/setState ngoài render.
const store = useContext(EditorStoreContext);
if (!store) {
throw new Error("useEditorStoreApi must be used within EditorStoreProvider.");
+4
View File
@@ -12,6 +12,8 @@ export type Entity = {
slug?: string | null;
type_id?: string | null;
geometry_count?: number;
time_start?: number | null;
time_end?: number | null;
};
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
@@ -29,4 +31,6 @@ export type EntitySnapshot = {
operation?: EntitySnapshotOperation;
name?: string;
description?: string | null;
time_start?: number | null;
time_end?: number | null;
};
+8
View File
@@ -26,12 +26,20 @@ export type FeatureProperties = {
entity_ids?: string[];
entity_name?: string | null;
entity_names?: string[];
entity_label_candidates?: EntityLabelCandidate[];
entity_type_id?: string | null;
point_label?: string | null;
line_label?: string | null;
polygon_label?: string | null;
};
export type EntityLabelCandidate = {
id: string;
name: string;
time_start?: number | null;
time_end?: number | null;
};
export type Feature = {
type: "Feature";
properties: FeatureProperties;