diff --git a/src/app/editor/[id]/featureCommands.ts b/src/app/editor/[id]/featureCommands.ts index 023cb3d..1e57736 100644 --- a/src/app/editor/[id]/featureCommands.ts +++ b/src/app/editor/[id]/featureCommands.ts @@ -16,7 +16,7 @@ type EditorDraftApi = { type Options = { editor: EditorDraftApi; - selectedFeature: Feature | null; + selectedFeatures: Feature[]; geometryMetaForm: GeometryMetaFormState; setGeometryMetaForm: Dispatch>; selectedGeometryEntityIds: string[]; @@ -29,7 +29,7 @@ type Options = { export function useFeatureCommands(options: Options) { const { editor, - selectedFeature, + selectedFeatures, geometryMetaForm, setGeometryMetaForm, selectedGeometryEntityIds, @@ -40,8 +40,8 @@ export function useFeatureCommands(options: Options) { } = options; const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => { - if (!selectedFeature) { - const msg = "Hãy chọn một geometry trước."; + if (!selectedFeatures || selectedFeatures.length === 0) { + const msg = "Hãy chọn ít nhất một geometry trước."; setEntityFormStatus(msg); return { ok: false, error: msg }; } @@ -64,7 +64,9 @@ export function useFeatureCommands(options: Options) { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch); + for (const feature of selectedFeatures) { + editor.patchFeatureProperties(feature.properties.id, metadata.patch); + } setGeometryMetaForm(metadata.formState); setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); return { ok: true }; @@ -74,15 +76,15 @@ export function useFeatureCommands(options: Options) { }, [ editor, geometryMetaForm, - selectedFeature, + selectedFeatures, setEntityFormStatus, setGeometryMetaForm, setIsEntitySubmitting, ]); const applyEntitiesToSelectedGeometry = useCallback(async () => { - if (!selectedFeature) { - setEntityFormStatus("Hãy chọn một geometry trước."); + if (!selectedFeatures || selectedFeatures.length === 0) { + setEntityFormStatus("Hãy chọn ít nhất một geometry trước."); return; } @@ -90,10 +92,12 @@ export function useFeatureCommands(options: Options) { setIsEntitySubmitting(true); setEntityFormStatus(null); try { - editor.patchFeatureProperties( - selectedFeature.properties.id, - buildFeatureEntityPatch(selectedFeature, entityIds, entities) - ); + for (const feature of selectedFeatures) { + editor.patchFeatureProperties( + feature.properties.id, + buildFeatureEntityPatch(feature, entityIds, entities) + ); + } setSelectedGeometryEntityIds(entityIds); setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); } catch (err) { @@ -108,7 +112,7 @@ export function useFeatureCommands(options: Options) { }, [ editor, entities, - selectedFeature, + selectedFeatures, selectedGeometryEntityIds, setEntityFormStatus, setIsEntitySubmitting, diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 89fc70b..16b8a79 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -128,8 +128,8 @@ export default function Page() { setSnapshotEntities, entityStatus, setEntityStatus, - selectedFeatureId, - setSelectedFeatureId, + selectedFeatureIds, + setSelectedFeatureIds, entityForm, setEntityForm, selectedGeometryEntityIds, @@ -263,12 +263,20 @@ export default function Page() { rows.sort((a, b) => a.name.localeCompare(b.name)); return rows; }, [entities, snapshotEntitiesVisible]); - const selectedFeature = - selectedFeatureId === null - ? null - : editor.draft.features.find((feature) => - String(feature.properties.id) === String(selectedFeatureId) - ) || null; + const selectedFeatures = useMemo(() => { + if (!selectedFeatureIds || selectedFeatureIds.length === 0) return []; + return selectedFeatureIds + .map(id => editor.draft.features.find(f => String(f.properties.id) === String(id))) + .filter(Boolean) as Feature[]; + }, [selectedFeatureIds, editor.draft.features]); + + const isMultiEditValid = useMemo(() => { + if (selectedFeatures.length <= 1) return true; + const firstShape = selectedFeatures[0].geometry.type; + return selectedFeatures.every(f => f.geometry.type === firstShape); + }, [selectedFeatures]); + + const selectedFeature = selectedFeatures.length > 0 && isMultiEditValid ? selectedFeatures[0] : null; const geometryChoices = useMemo(() => { const rows = (editor.draft.features || []) @@ -383,7 +391,7 @@ export default function Page() { setSnapshotWikis, setSnapshotEntityWikiLinks, setEntityFormStatus, - setSelectedFeatureId, + setSelectedFeatureIds, setEntityStatus, setIsSaving, setIsSubmitting, @@ -682,14 +690,14 @@ export default function Page() { }, [geoSearchRequestRef, searchKind, searchQuery]); useEffect(() => { - if (selectedFeatureId === null) return; - const stillExists = timelineVisibleDraft.features.some((feature) => - String(feature.properties.id) === String(selectedFeatureId) + if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; + const stillExistIds = selectedFeatureIds.filter(id => + timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id)) ); - if (!stillExists) { - setSelectedFeatureId(null); + if (stillExistIds.length !== selectedFeatureIds.length) { + setSelectedFeatureIds(stillExistIds); } - }, [timelineVisibleDraft, selectedFeatureId, setSelectedFeatureId]); + }, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]); useEffect(() => { if (!selectedFeature) { @@ -868,10 +876,14 @@ export default function Page() { }, [editor, flashEntityFormStatus]); const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => { - if (!selectedFeature) { + if (!selectedFeatures || selectedFeatures.length === 0) { flashEntityFormStatus("Chưa chọn geometry để bind entity."); return; } + if (!isMultiEditValid) { + flashEntityFormStatus("Không thể bind entity cho nhiều geometry khác loại."); + return; + } const id = String(entityId || "").trim(); if (!id) return; const nextEntityIds = (() => { @@ -888,10 +900,12 @@ export default function Page() { setIsEntitySubmitting(true); flashEntityFormStatus(null, 0); try { - editor.patchFeatureProperties( - selectedFeature.properties.id, - buildFeatureEntityPatch(selectedFeature, nextEntityIds, entities) - ); + for (const feature of selectedFeatures) { + editor.patchFeatureProperties( + feature.properties.id, + buildFeatureEntityPatch(feature, nextEntityIds, entities) + ); + } setSelectedGeometryEntityIds(nextEntityIds); flashEntityFormStatus( nextChecked @@ -906,37 +920,53 @@ export default function Page() { editor, entities, flashEntityFormStatus, - selectedFeature, + selectedFeatures, + isMultiEditValid, selectedGeometryEntityIds, setIsEntitySubmitting, setSelectedGeometryEntityIds, ]); const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { - if (!selectedFeature) { + if (!selectedFeatures || selectedFeatures.length === 0) { flashGeoBindingStatus("Chưa chọn geometry để bind."); return; } + if (!isMultiEditValid) { + flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại."); + return; + } const id = String(geoId || "").trim(); if (!id) return; - if (String(selectedFeature.properties.id) === id) return; + if (selectedFeatures.some(f => String(f.properties.id) === id)) return; + - const prevBindingIds = normalizeFeatureBindingIds(selectedFeature); - const has = prevBindingIds.includes(id); - const nextBindingIds = (() => { - if (nextChecked) { - if (has) return prevBindingIds; - return [...prevBindingIds, id]; - } - if (!has) return prevBindingIds; - return prevBindingIds.filter((x) => x !== id); - })(); setIsEntitySubmitting(true); flashGeoBindingStatus(null, 0); try { - editor.patchFeatureProperties(selectedFeature.properties.id, { binding: nextBindingIds }); - setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIds.join(", ") })); + for (const feature of selectedFeatures) { + const prevBindingIds = normalizeFeatureBindingIds(feature); + const has = prevBindingIds.includes(id); + const nextBindingIds = (() => { + if (nextChecked) { + if (has) return prevBindingIds; + return [...prevBindingIds, id]; + } + if (!has) return prevBindingIds; + return prevBindingIds.filter((x) => x !== id); + })(); + editor.patchFeatureProperties(feature.properties.id, { binding: nextBindingIds }); + } + + // Assume selectedFeature (the first one) reflects the representative binding in UI + const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]); + const firstFeatureHas = firstFeaturePrevBindings.includes(id); + const nextBindingIdsForUI = (() => { + if (nextChecked) return firstFeatureHas ? firstFeaturePrevBindings : [...firstFeaturePrevBindings, id]; + return firstFeatureHas ? firstFeaturePrevBindings.filter(x => x !== id) : firstFeaturePrevBindings; + })(); + setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIdsForUI.join(", ") })); flashGeoBindingStatus( nextChecked ? "Đã bind geometry vào binding. Commit khi sẵn sàng." @@ -949,7 +979,8 @@ export default function Page() { }, [ editor, flashGeoBindingStatus, - selectedFeature, + selectedFeatures, + isMultiEditValid, setGeometryMetaForm, setIsEntitySubmitting, ]); @@ -996,7 +1027,7 @@ export default function Page() { const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null; if (existing) { - setSelectedFeatureId(existing.properties.id); + setSelectedFeatureIds([existing.properties.id]); flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000); return; } @@ -1027,19 +1058,19 @@ export default function Page() { }; editor.createFeature(feature); - setSelectedFeatureId(feature.properties.id); + setSelectedFeatureIds([feature.properties.id]); flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000); }, [ editor, flashEntityFormStatus, handleAddEntityRefToProject, - setSelectedFeatureId, + setSelectedFeatureIds, setTimelineFilterEnabled, ]); const featureCommands = useFeatureCommands({ editor, - selectedFeature, + selectedFeatures, geometryMetaForm, setGeometryMetaForm, selectedGeometryEntityIds, @@ -1119,7 +1150,7 @@ export default function Page() { const handleCreateFeature = (feature: Feature) => { editor.createFeature(feature); - setSelectedFeatureId(feature.properties.id); + setSelectedFeatureIds([feature.properties.id]); }; return ( @@ -1205,8 +1236,8 @@ export default function Page() { ) : null} {Array.isArray(item.geometries) && item.geometries.length ? ( -
- {item.geometries.slice(0, 4).map((geo) => ( +
+ {item.geometries.map((geo) => (
))} - {item.geometries.length > 4 ? ( -
- +{item.geometries.length - 4} more… -
- ) : null}
) : (
@@ -1520,7 +1546,7 @@ export default function Page() { /> {!wikiOnly && selectedFeature ? ( (EMPTY_FEATURE_COLLECTION); - const [selectedFeatureId, setSelectedFeatureId] = useState(null); + const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]); const [timelineYear, setTimelineYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); const [timeRange, setTimeRange] = useState(0); @@ -94,17 +94,21 @@ export default function Page() { const linkEntityPopupRef = useRef(null); const selectedFeature = useMemo(() => { - if (selectedFeatureId === null) return null; + if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null; return ( - data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null + data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null ); - }, [data.features, selectedFeatureId]); + }, [data.features, selectedFeatureIds]); useEffect(() => { - if (selectedFeatureId === null) return; - const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId)); - if (!stillExists) setSelectedFeatureId(null); - }, [data.features, selectedFeatureId]); + if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; + const stillExistIds = selectedFeatureIds.filter(id => + data.features.some(feature => String(feature.properties.id) === String(id)) + ); + if (stillExistIds.length !== selectedFeatureIds.length) { + setSelectedFeatureIds(stillExistIds); + } + }, [data.features, selectedFeatureIds]); useEffect(() => { const timeoutId = window.setTimeout(() => { @@ -315,25 +319,26 @@ export default function Page() { if (options?.focusMap !== false) { setEntityFocusToken((prev) => prev + 1); } - if (options?.selectGeometry && options?.sourceFeatureId !== undefined) { - setSelectedFeatureId(options.sourceFeatureId); + if (options?.selectGeometry && options?.sourceFeatureId != null) { + setSelectedFeatureIds([options.sourceFeatureId]); } }, [relations.entitiesById, relations.entityWikisById]); useEffect(() => { - if (selectedFeatureId === null) return; - const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureId)] || []; + if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; + // For UI simplicity in viewer, just link to the first selected geometry + const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || []; if (linkedEntityIds.length !== 1) return; const onlyEntityId = linkedEntityIds[0]; if (activeEntityId === onlyEntityId) return; selectEntity(onlyEntityId, { - sourceFeatureId: selectedFeatureId, + sourceFeatureId: selectedFeatureIds[0], focusMap: false, selectGeometry: false, }); - }, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureId]); + }, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]); const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => { clearHoverHideTimer(); @@ -461,12 +466,12 @@ export default function Page() { handleToggleBackgroundLayer(layer.id)} - className={`rounded-md border px-2.5 py-1 text-xs transition ${ - active + className={`rounded-md border px-2.5 py-1 text-xs transition ${active ? "border-sky-400/40 bg-sky-500/10 text-sky-200" : "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200" - }`} + }`} > {layer.label} @@ -544,11 +548,10 @@ export default function Page() { [typeKey]: prev[typeKey] === false, })); }} - className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${ - active + className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active ? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200" : "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200" - }`} + }`} > {typeKey.replaceAll("_", " ")} diff --git a/src/uhm/components/EntityWikiBindingsPanel.tsx b/src/uhm/components/EntityWikiBindingsPanel.tsx index 8bcfc8a..27a5c91 100644 --- a/src/uhm/components/EntityWikiBindingsPanel.tsx +++ b/src/uhm/components/EntityWikiBindingsPanel.tsx @@ -208,9 +208,9 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin {!activeEntityId ? (
Pick an entity to see/link wikis.
) : activeLinks.size ? ( -
+
Linked wikis ({activeLinks.size})
- {Array.from(activeLinks).slice(0, 8).map((id) => { + {Array.from(activeLinks).map((id) => { const w = wikiChoices.find((x) => x.id === id) || null; return (
); })} - {activeLinks.size > 8 ? ( -
+{activeLinks.size - 8} more…
- ) : null} +
) : (
No wiki linked yet.
diff --git a/src/uhm/components/GeometryBindingPanel.tsx b/src/uhm/components/GeometryBindingPanel.tsx index 4249e2f..0fd873e 100644 --- a/src/uhm/components/GeometryBindingPanel.tsx +++ b/src/uhm/components/GeometryBindingPanel.tsx @@ -41,7 +41,6 @@ export default function GeometryBindingPanel({ const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]); - const visibleRows = rows.slice(0, 12); return (
{collapsed ? null : rows.length ? ( -
- {visibleRows +
+ {rows .filter((g) => g.id !== selectedGeometryId) .map((g) => { const isBound = bindingSet.has(g.id); @@ -176,11 +175,7 @@ export default function GeometryBindingPanel({
); })} - {rows.length > visibleRows.length ? ( -
- +{rows.length - visibleRows.length} more… -
- ) : null} +
) : (
diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 6e23bb5..278c32d 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -43,8 +43,8 @@ type MapProps = { draft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; geometryVisibility?: Record; - selectedFeatureId: string | number | null; - onSelectFeatureId: (id: string | number | null) => void; + selectedFeatureIds: (string | number)[]; + onSelectFeatureIds: (ids: (string | number)[]) => void; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onDeleteFeature?: (id: string | number) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void; @@ -84,8 +84,8 @@ export default function Map({ draft, backgroundVisibility, geometryVisibility, - selectedFeatureId, - onSelectFeatureId, + selectedFeatureIds, + onSelectFeatureIds, onCreateFeature, onDeleteFeature, onUpdateFeature, @@ -123,10 +123,10 @@ export default function Map({ const focusFeatureCollectionRef = useRef(focusFeatureCollection); const focusRequestKeyRef = useRef(focusRequestKey); const focusPaddingRef = useRef(focusPadding); - // Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render). - const selectedFeatureIdRef = useRef(selectedFeatureId); - // Mirror của callback onSelectFeatureId. - const onSelectFeatureIdRef = useRef(onSelectFeatureId); + // Mirror của selectedFeatureIds để filter/select trên map (không phụ thuộc re-render). + const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds); + // Mirror của callback onSelectFeatureIds. + const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); const onHoverFeatureChangeRef = useRef(onHoverFeatureChange); // Mirror của callback onCreateFeature. const onCreateRef = useRef(onCreateFeature); @@ -225,26 +225,26 @@ export default function Map({ }, [draft]); useEffect(() => { - selectedFeatureIdRef.current = selectedFeatureId; - }, [selectedFeatureId]); + selectedFeatureIdsRef.current = selectedFeatureIds; + }, [selectedFeatureIds]); useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); useEffect(() => { - if (mode !== "select" || selectedFeatureId === null) { + if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) { editingEngineRef.current?.clearEditing(); } - }, [mode, selectedFeatureId]); + }, [mode, selectedFeatureIds]); useEffect(() => { fitBoundsAppliedRef.current = false; }, [fitBoundsKey]); useEffect(() => { - onSelectFeatureIdRef.current = onSelectFeatureId; - }, [onSelectFeatureId]); + onSelectFeatureIdsRef.current = onSelectFeatureIds; + }, [onSelectFeatureIds]); useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; @@ -315,7 +315,7 @@ export default function Map({ } const visibleDraftRaw = respectBindingFilterRef.current - ? filterDraftByBinding(fc, selectedFeatureIdRef.current) + ? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current) : fc; const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current); const { polygons, points } = splitDraftFeatures(visibleDraft); @@ -326,11 +326,15 @@ export default function Map({ (map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined) ?.setData(pathArrowShapes); - const selectedId = selectedFeatureIdRef.current; - setSelectedFeatureState(map, selectedId, true); + const currentSelectedIds = selectedFeatureIdsRef.current; + currentSelectedIds.forEach((id) => { + setSelectedFeatureState(map, id, true); + }); requestAnimationFrame(() => { if (mapRef.current !== map) return; - setSelectedFeatureState(map, selectedId, true); + currentSelectedIds.forEach((id) => { + setSelectedFeatureState(map, id, true); + }); }); if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) { fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft); @@ -1034,7 +1038,7 @@ export default function Map({ ? (id: string | number) => { // ensure edit overlays are cleared when a feature gets removed editingEngineRef.current?.clearEditing(); - onSelectFeatureIdRef.current?.(null); + onSelectFeatureIdsRef.current?.([]); onDeleteRef.current?.(id); } : undefined, @@ -1048,7 +1052,7 @@ export default function Map({ editingEngineRef.current?.beginEditing((originalFeature || feature) as any); } : undefined, - (id) => onSelectFeatureIdRef.current?.(id) + (ids) => onSelectFeatureIdsRef.current?.(ids) ); const cleanupPoint = initPoint( @@ -1283,7 +1287,7 @@ export default function Map({ editingEngineRef.current?.clearEditing(); } } - }, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]); + }, [allowGeometryEditing, draft, selectedFeatureIds, applyDraftToMap]); useEffect(() => { if (focusRequestKey === null || focusRequestKey === undefined) return; @@ -1557,9 +1561,16 @@ function getSelectableLayers(map: maplibregl.Map): string[] { function filterDraftByBinding( fc: FeatureCollection, - selectedFeatureId: string | number | null + selectedFeatureIds: (string | number)[], + highlightFeatures?: FeatureCollection | null ): FeatureCollection { - const selectedId = selectedFeatureId !== null ? String(selectedFeatureId) : null; + const selectedIds = new Set(selectedFeatureIds.map(String)); + if (highlightFeatures?.features) { + for (const f of highlightFeatures.features) { + if (f.properties?.id != null) selectedIds.add(String(f.properties.id)); + } + } + // Semantics: // - A feature's `binding` is a list of "child" geometry ids. // - Child geometries are hidden by default, and only shown when their parent is selected. @@ -1570,21 +1581,24 @@ function filterDraftByBinding( } } - if (selectedId === null) { + if (selectedIds.size === 0) { return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) }; } - const selectedFeature = - fc.features.find((feature) => String(feature.properties.id) === selectedId) || null; - const selectedChildren = new Set( - normalizeBindingIds(selectedFeature?.properties.binding) - ); + const selectedChildren = new Set(); + for (const feature of fc.features) { + if (selectedIds.has(String(feature.properties.id))) { + for (const id of normalizeBindingIds(feature.properties.binding)) { + selectedChildren.add(id); + } + } + } return { ...fc, features: fc.features.filter((feature) => { const featureId = String(feature.properties.id); - if (featureId === selectedId) return true; + if (selectedIds.has(featureId)) return true; if (selectedChildren.has(featureId)) return true; return !childIds.has(featureId); }), @@ -1778,9 +1792,9 @@ function buildPathArrowGeometry(coords: [number, number][]): Geometry | null { if (bodyPoints.length < 2) return null; - const tailWidth = clampNumber(totalLength * 0.018, 25000, 140000); - const shoulderWidth = clampNumber(totalLength * 0.055, 60000, 420000); - const headWidth = shoulderWidth * 1.65; + const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000); + const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000); + const headWidth = shoulderWidth * 2.0; const leftBody: ProjectedPoint[] = []; const rightBody: ProjectedPoint[] = []; diff --git a/src/uhm/components/ProjectEntityRefsPanel.tsx b/src/uhm/components/ProjectEntityRefsPanel.tsx index ae8e2c1..19c264c 100644 --- a/src/uhm/components/ProjectEntityRefsPanel.tsx +++ b/src/uhm/components/ProjectEntityRefsPanel.tsx @@ -97,8 +97,8 @@ export default function ProjectEntityRefsPanel({
{collapsed ? null : entityRefs.length ? ( -
- {entityRefs.slice(0, 8).map((e) => ( +
+ {entityRefs.map((e) => (
))} - {entityRefs.length > 8 ?
+{entityRefs.length - 8} more…
: null} +
) : (
No entity ref yet for this project.
diff --git a/src/uhm/components/PublicWikiSidebar.tsx b/src/uhm/components/PublicWikiSidebar.tsx index 16d333c..b96a3a2 100644 --- a/src/uhm/components/PublicWikiSidebar.tsx +++ b/src/uhm/components/PublicWikiSidebar.tsx @@ -258,11 +258,10 @@ export default function PublicWikiSidebar({ {item.text} diff --git a/src/uhm/components/SelectedGeometryPanel.tsx b/src/uhm/components/SelectedGeometryPanel.tsx index e4120f5..7e3b6ca 100644 --- a/src/uhm/components/SelectedGeometryPanel.tsx +++ b/src/uhm/components/SelectedGeometryPanel.tsx @@ -13,7 +13,7 @@ import { import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes"; type Props = { - selectedFeature: Feature | null; + selectedFeatures: Feature[]; selectedFeatureEntitySummary: string; selectedFeatureBindingSummary: string; entities: Entity[]; @@ -28,7 +28,7 @@ type Props = { }; export default function SelectedGeometryPanel({ - selectedFeature, + selectedFeatures, selectedFeatureEntitySummary, selectedFeatureBindingSummary, entities, @@ -78,10 +78,11 @@ export default function SelectedGeometryPanel({ const visibleGeoApplyFeedback = geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null; - if (!selectedFeature) return null; + if (!selectedFeatures || selectedFeatures.length === 0) return null; + const representativeFeature = selectedFeatures[0]; const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions); - const featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature); + const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature); const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset); const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) => allowedGroupIds.includes(group.id) @@ -130,7 +131,7 @@ export default function SelectedGeometryPanel({ {collapsed ? null : (
- ID: {String(selectedFeature.properties.id)} + ID: {selectedFeatures.map(f => String(f.properties.id)).join(", ")}
Entities hiện tại: {selectedFeatureEntitySummary} diff --git a/src/uhm/components/WikiSidebarPanel.tsx b/src/uhm/components/WikiSidebarPanel.tsx index e08528b..05b22cd 100644 --- a/src/uhm/components/WikiSidebarPanel.tsx +++ b/src/uhm/components/WikiSidebarPanel.tsx @@ -575,8 +575,8 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
{collapsed ? null : wikis.length ? ( -
- {wikis.slice(0, 8).map((w) => ( +
+ {wikis.map((w) => (
))} - {wikis.length > 8 ? ( -
+{wikis.length - 8} more…
- ) : null} +
) : (
diff --git a/src/uhm/lib/editor/section/useSectionCommands.ts b/src/uhm/lib/editor/section/useSectionCommands.ts index 2f4d6d9..cc8e5df 100644 --- a/src/uhm/lib/editor/section/useSectionCommands.ts +++ b/src/uhm/lib/editor/section/useSectionCommands.ts @@ -46,7 +46,7 @@ type Options = { setSnapshotEntities: Dispatch>; setSnapshotWikis: Dispatch>; setSnapshotEntityWikiLinks: Dispatch>; - setSelectedFeatureId: Dispatch>; + setSelectedFeatureIds: Dispatch>; setEntityFormStatus: Dispatch>; setEntityStatus: Dispatch>; setIsSaving: Dispatch>; @@ -76,7 +76,7 @@ export function useSectionCommands(options: Options) { options.setSnapshotEntities(sessionSnapshot?.entities || []); options.setSnapshotWikis(sessionSnapshot?.wikis || []); options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); - options.setSelectedFeatureId(null); + options.setSelectedFeatureIds([]); options.setEntityFormStatus(null); }, [options]); @@ -271,7 +271,7 @@ export function useSectionCommands(options: Options) { options.setSnapshotEntities(sessionSnapshot?.entities || []); options.setSnapshotWikis(sessionSnapshot?.wikis || []); options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); - options.setSelectedFeatureId(null); + options.setSelectedFeatureIds([]); options.setEntityFormStatus(null); // Refresh commits list for UI, but keep sectionState/head as-is. diff --git a/src/uhm/lib/editor/session/useEntitySessionState.ts b/src/uhm/lib/editor/session/useEntitySessionState.ts index 29f92dc..9a71473 100644 --- a/src/uhm/lib/editor/session/useEntitySessionState.ts +++ b/src/uhm/lib/editor/session/useEntitySessionState.ts @@ -14,8 +14,8 @@ export function useEntitySessionState() { const [snapshotEntities, setSnapshotEntities] = useState([]); // Thông báo trạng thái/lỗi liên quan entity/session. const [entityStatus, setEntityStatus] = useState(null); - // Feature đang được chọn để thao tác bind entities/metadata. - const [selectedFeatureId, setSelectedFeatureId] = useState(null); + // Features đang được chọn để thao tác bind entities/metadata. + const [selectedFeatureIds, setSelectedFeatureIds] = useState([]); // Form tạo entity mới (độc lập). const [entityForm, setEntityForm] = useState({ name: "", @@ -50,8 +50,8 @@ export function useEntitySessionState() { setSnapshotEntities, entityStatus, setEntityStatus, - selectedFeatureId, - setSelectedFeatureId, + selectedFeatureIds, + setSelectedFeatureIds, entityForm, setEntityForm, selectedGeometryEntityIds, diff --git a/src/uhm/lib/engine/selectingEngine.ts b/src/uhm/lib/engine/selectingEngine.ts index d8363e8..78dc1d6 100644 --- a/src/uhm/lib/engine/selectingEngine.ts +++ b/src/uhm/lib/engine/selectingEngine.ts @@ -7,7 +7,7 @@ export function initSelect( getMode: ModeGetter, onDelete?: (id: string | number) => void, onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, - onSelectId?: (id: string | number | null) => void + onSelectIds?: (ids: (string | number)[]) => void ) { const SELECTABLE_LAYERS = [ "countries-fill", @@ -35,7 +35,7 @@ export function initSelect( selectedIds.forEach((id) => setSelectionStateForId(id, false)); selectedIds.clear(); if (emit) { - onSelectId?.(null); + onSelectIds?.([]); } } @@ -52,13 +52,13 @@ export function initSelect( // Alt + click on an already selected feature removes it from the selection setSelectionStateForId(id, false); selectedIds.delete(id); - onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null); + onSelectIds?.(Array.from(selectedIds)); return; } setSelectionStateForId(id, true); selectedIds.add(id); - onSelectId?.(selectedIds.size === 1 ? id : null); + onSelectIds?.(Array.from(selectedIds)); } // Chọn feature theo click trái, hỗ trợ additive bằng Alt. diff --git a/src/uhm/lib/engine/snapUtils.ts b/src/uhm/lib/engine/snapUtils.ts index 8d58e77..aebb13b 100644 --- a/src/uhm/lib/engine/snapUtils.ts +++ b/src/uhm/lib/engine/snapUtils.ts @@ -61,7 +61,8 @@ export function snapToNearestGeometry( } const type = feature.geometry.type; - const coords = feature.geometry.coordinates as any; + if (type === "GeometryCollection") continue; + const coords = (feature.geometry as any).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") {