feat: Support multi-select editor workflow and improve UI/UX

- Refactored state from single selectedFeatureId to selectedFeatureIds array in Editor and Viewer
- Updated Map component to support multi-select filtering for geometry binding visibility
- Made entity, wiki, and geometry side panels scrollable for better overflow handling
- Fixed viewer mode wiki link navigation for independent wikis
- Improved geometry binding UX and state synchronization
This commit is contained in:
taDuc
2026-05-11 04:49:28 +07:00
parent f2f5295218
commit fe7696b72d
14 changed files with 200 additions and 161 deletions
+17 -13
View File
@@ -16,7 +16,7 @@ type EditorDraftApi = {
type Options = { type Options = {
editor: EditorDraftApi; editor: EditorDraftApi;
selectedFeature: Feature | null; selectedFeatures: Feature[];
geometryMetaForm: GeometryMetaFormState; geometryMetaForm: GeometryMetaFormState;
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>; setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
selectedGeometryEntityIds: string[]; selectedGeometryEntityIds: string[];
@@ -29,7 +29,7 @@ type Options = {
export function useFeatureCommands(options: Options) { export function useFeatureCommands(options: Options) {
const { const {
editor, editor,
selectedFeature, selectedFeatures,
geometryMetaForm, geometryMetaForm,
setGeometryMetaForm, setGeometryMetaForm,
selectedGeometryEntityIds, selectedGeometryEntityIds,
@@ -40,8 +40,8 @@ export function useFeatureCommands(options: Options) {
} = options; } = options;
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => { const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
if (!selectedFeature) { if (!selectedFeatures || selectedFeatures.length === 0) {
const msg = "Hãy chọn một geometry trước."; const msg = "Hãy chọn ít nhất một geometry trước.";
setEntityFormStatus(msg); setEntityFormStatus(msg);
return { ok: false, error: msg }; return { ok: false, error: msg };
} }
@@ -64,7 +64,9 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting(true); setIsEntitySubmitting(true);
setEntityFormStatus(null); setEntityFormStatus(null);
try { try {
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch); for (const feature of selectedFeatures) {
editor.patchFeatureProperties(feature.properties.id, metadata.patch);
}
setGeometryMetaForm(metadata.formState); setGeometryMetaForm(metadata.formState);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
return { ok: true }; return { ok: true };
@@ -74,15 +76,15 @@ export function useFeatureCommands(options: Options) {
}, [ }, [
editor, editor,
geometryMetaForm, geometryMetaForm,
selectedFeature, selectedFeatures,
setEntityFormStatus, setEntityFormStatus,
setGeometryMetaForm, setGeometryMetaForm,
setIsEntitySubmitting, setIsEntitySubmitting,
]); ]);
const applyEntitiesToSelectedGeometry = useCallback(async () => { const applyEntitiesToSelectedGeometry = useCallback(async () => {
if (!selectedFeature) { if (!selectedFeatures || selectedFeatures.length === 0) {
setEntityFormStatus("Hãy chọn một geometry trước."); setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
return; return;
} }
@@ -90,10 +92,12 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting(true); setIsEntitySubmitting(true);
setEntityFormStatus(null); setEntityFormStatus(null);
try { try {
editor.patchFeatureProperties( for (const feature of selectedFeatures) {
selectedFeature.properties.id, editor.patchFeatureProperties(
buildFeatureEntityPatch(selectedFeature, entityIds, entities) feature.properties.id,
); buildFeatureEntityPatch(feature, entityIds, entities)
);
}
setSelectedGeometryEntityIds(entityIds); setSelectedGeometryEntityIds(entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
} catch (err) { } catch (err) {
@@ -108,7 +112,7 @@ export function useFeatureCommands(options: Options) {
}, [ }, [
editor, editor,
entities, entities,
selectedFeature, selectedFeatures,
selectedGeometryEntityIds, selectedGeometryEntityIds,
setEntityFormStatus, setEntityFormStatus,
setIsEntitySubmitting, setIsEntitySubmitting,
+77 -51
View File
@@ -128,8 +128,8 @@ export default function Page() {
setSnapshotEntities, setSnapshotEntities,
entityStatus, entityStatus,
setEntityStatus, setEntityStatus,
selectedFeatureId, selectedFeatureIds,
setSelectedFeatureId, setSelectedFeatureIds,
entityForm, entityForm,
setEntityForm, setEntityForm,
selectedGeometryEntityIds, selectedGeometryEntityIds,
@@ -263,12 +263,20 @@ export default function Page() {
rows.sort((a, b) => a.name.localeCompare(b.name)); rows.sort((a, b) => a.name.localeCompare(b.name));
return rows; return rows;
}, [entities, snapshotEntitiesVisible]); }, [entities, snapshotEntitiesVisible]);
const selectedFeature = const selectedFeatures = useMemo(() => {
selectedFeatureId === null if (!selectedFeatureIds || selectedFeatureIds.length === 0) return [];
? null return selectedFeatureIds
: editor.draft.features.find((feature) => .map(id => editor.draft.features.find(f => String(f.properties.id) === String(id)))
String(feature.properties.id) === String(selectedFeatureId) .filter(Boolean) as Feature[];
) || null; }, [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 geometryChoices = useMemo(() => {
const rows = (editor.draft.features || []) const rows = (editor.draft.features || [])
@@ -383,7 +391,7 @@ export default function Page() {
setSnapshotWikis, setSnapshotWikis,
setSnapshotEntityWikiLinks, setSnapshotEntityWikiLinks,
setEntityFormStatus, setEntityFormStatus,
setSelectedFeatureId, setSelectedFeatureIds,
setEntityStatus, setEntityStatus,
setIsSaving, setIsSaving,
setIsSubmitting, setIsSubmitting,
@@ -682,14 +690,14 @@ export default function Page() {
}, [geoSearchRequestRef, searchKind, searchQuery]); }, [geoSearchRequestRef, searchKind, searchQuery]);
useEffect(() => { useEffect(() => {
if (selectedFeatureId === null) return; if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const stillExists = timelineVisibleDraft.features.some((feature) => const stillExistIds = selectedFeatureIds.filter(id =>
String(feature.properties.id) === String(selectedFeatureId) timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id))
); );
if (!stillExists) { if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureId(null); setSelectedFeatureIds(stillExistIds);
} }
}, [timelineVisibleDraft, selectedFeatureId, setSelectedFeatureId]); }, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]);
useEffect(() => { useEffect(() => {
if (!selectedFeature) { if (!selectedFeature) {
@@ -868,10 +876,14 @@ export default function Page() {
}, [editor, flashEntityFormStatus]); }, [editor, flashEntityFormStatus]);
const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => { const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => {
if (!selectedFeature) { if (!selectedFeatures || selectedFeatures.length === 0) {
flashEntityFormStatus("Chưa chọn geometry để bind entity."); flashEntityFormStatus("Chưa chọn geometry để bind entity.");
return; return;
} }
if (!isMultiEditValid) {
flashEntityFormStatus("Không thể bind entity cho nhiều geometry khác loại.");
return;
}
const id = String(entityId || "").trim(); const id = String(entityId || "").trim();
if (!id) return; if (!id) return;
const nextEntityIds = (() => { const nextEntityIds = (() => {
@@ -888,10 +900,12 @@ export default function Page() {
setIsEntitySubmitting(true); setIsEntitySubmitting(true);
flashEntityFormStatus(null, 0); flashEntityFormStatus(null, 0);
try { try {
editor.patchFeatureProperties( for (const feature of selectedFeatures) {
selectedFeature.properties.id, editor.patchFeatureProperties(
buildFeatureEntityPatch(selectedFeature, nextEntityIds, entities) feature.properties.id,
); buildFeatureEntityPatch(feature, nextEntityIds, entities)
);
}
setSelectedGeometryEntityIds(nextEntityIds); setSelectedGeometryEntityIds(nextEntityIds);
flashEntityFormStatus( flashEntityFormStatus(
nextChecked nextChecked
@@ -906,37 +920,53 @@ export default function Page() {
editor, editor,
entities, entities,
flashEntityFormStatus, flashEntityFormStatus,
selectedFeature, selectedFeatures,
isMultiEditValid,
selectedGeometryEntityIds, selectedGeometryEntityIds,
setIsEntitySubmitting, setIsEntitySubmitting,
setSelectedGeometryEntityIds, setSelectedGeometryEntityIds,
]); ]);
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => { const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
if (!selectedFeature) { if (!selectedFeatures || selectedFeatures.length === 0) {
flashGeoBindingStatus("Chưa chọn geometry để bind."); flashGeoBindingStatus("Chưa chọn geometry để bind.");
return; return;
} }
if (!isMultiEditValid) {
flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại.");
return;
}
const id = String(geoId || "").trim(); const id = String(geoId || "").trim();
if (!id) return; 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); setIsEntitySubmitting(true);
flashGeoBindingStatus(null, 0); flashGeoBindingStatus(null, 0);
try { try {
editor.patchFeatureProperties(selectedFeature.properties.id, { binding: nextBindingIds }); for (const feature of selectedFeatures) {
setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIds.join(", ") })); 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( flashGeoBindingStatus(
nextChecked nextChecked
? "Đã bind geometry vào binding. Commit khi sẵn sàng." ? "Đã bind geometry vào binding. Commit khi sẵn sàng."
@@ -949,7 +979,8 @@ export default function Page() {
}, [ }, [
editor, editor,
flashGeoBindingStatus, flashGeoBindingStatus,
selectedFeature, selectedFeatures,
isMultiEditValid,
setGeometryMetaForm, setGeometryMetaForm,
setIsEntitySubmitting, setIsEntitySubmitting,
]); ]);
@@ -996,7 +1027,7 @@ export default function Page() {
const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null; const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null;
if (existing) { if (existing) {
setSelectedFeatureId(existing.properties.id); setSelectedFeatureIds([existing.properties.id]);
flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000); flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000);
return; return;
} }
@@ -1027,19 +1058,19 @@ export default function Page() {
}; };
editor.createFeature(feature); editor.createFeature(feature);
setSelectedFeatureId(feature.properties.id); setSelectedFeatureIds([feature.properties.id]);
flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000); flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000);
}, [ }, [
editor, editor,
flashEntityFormStatus, flashEntityFormStatus,
handleAddEntityRefToProject, handleAddEntityRefToProject,
setSelectedFeatureId, setSelectedFeatureIds,
setTimelineFilterEnabled, setTimelineFilterEnabled,
]); ]);
const featureCommands = useFeatureCommands({ const featureCommands = useFeatureCommands({
editor, editor,
selectedFeature, selectedFeatures,
geometryMetaForm, geometryMetaForm,
setGeometryMetaForm, setGeometryMetaForm,
selectedGeometryEntityIds, selectedGeometryEntityIds,
@@ -1119,7 +1150,7 @@ export default function Page() {
const handleCreateFeature = (feature: Feature) => { const handleCreateFeature = (feature: Feature) => {
editor.createFeature(feature); editor.createFeature(feature);
setSelectedFeatureId(feature.properties.id); setSelectedFeatureIds([feature.properties.id]);
}; };
return ( return (
@@ -1205,8 +1236,8 @@ export default function Page() {
<Map <Map
mode={mode} mode={mode}
draft={timelineVisibleDraft} draft={timelineVisibleDraft}
selectedFeatureId={selectedFeatureId} selectedFeatureIds={selectedFeatureIds}
onSelectFeatureId={setSelectedFeatureId} onSelectFeatureIds={setSelectedFeatureIds}
onCreateFeature={handleCreateFeature} onCreateFeature={handleCreateFeature}
onDeleteFeature={editor.deleteFeature} onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature} onUpdateFeature={editor.updateFeature}
@@ -1416,8 +1447,8 @@ export default function Page() {
</div> </div>
) : null} ) : null}
{Array.isArray(item.geometries) && item.geometries.length ? ( {Array.isArray(item.geometries) && item.geometries.length ? (
<div style={{ display: "grid", gap: 6 }}> <div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{item.geometries.slice(0, 4).map((geo) => ( {item.geometries.map((geo) => (
<div <div
key={geo.id} key={geo.id}
style={{ style={{
@@ -1462,11 +1493,6 @@ export default function Page() {
</button> </button>
</div> </div>
))} ))}
{item.geometries.length > 4 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>
+{item.geometries.length - 4} more
</div>
) : null}
</div> </div>
) : ( ) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}> <div style={{ fontSize: 12, color: "#94a3b8" }}>
@@ -1520,7 +1546,7 @@ export default function Page() {
/> />
{!wikiOnly && selectedFeature ? ( {!wikiOnly && selectedFeature ? (
<SelectedGeometryPanel <SelectedGeometryPanel
selectedFeature={selectedFeature} selectedFeatures={selectedFeatures}
selectedFeatureEntitySummary={ selectedFeatureEntitySummary={
selectedFeature selectedFeature
? formatEntityNamesForDisplay(selectedFeature, entities) ? formatEntityNamesForDisplay(selectedFeature, entities)
+26 -23
View File
@@ -57,7 +57,7 @@ const EMPTY_RELATIONS: RelationIndex = {
export default function Page() { export default function Page() {
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION); const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null); const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timeRange, setTimeRange] = useState<number>(0); const [timeRange, setTimeRange] = useState<number>(0);
@@ -94,17 +94,21 @@ export default function Page() {
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null); const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
const selectedFeature = useMemo(() => { const selectedFeature = useMemo(() => {
if (selectedFeatureId === null) return null; if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null;
return ( 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(() => { useEffect(() => {
if (selectedFeatureId === null) return; if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId)); const stillExistIds = selectedFeatureIds.filter(id =>
if (!stillExists) setSelectedFeatureId(null); data.features.some(feature => String(feature.properties.id) === String(id))
}, [data.features, selectedFeatureId]); );
if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds);
}
}, [data.features, selectedFeatureIds]);
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
@@ -315,25 +319,26 @@ export default function Page() {
if (options?.focusMap !== false) { if (options?.focusMap !== false) {
setEntityFocusToken((prev) => prev + 1); setEntityFocusToken((prev) => prev + 1);
} }
if (options?.selectGeometry && options?.sourceFeatureId !== undefined) { if (options?.selectGeometry && options?.sourceFeatureId != null) {
setSelectedFeatureId(options.sourceFeatureId); setSelectedFeatureIds([options.sourceFeatureId]);
} }
}, [relations.entitiesById, relations.entityWikisById]); }, [relations.entitiesById, relations.entityWikisById]);
useEffect(() => { useEffect(() => {
if (selectedFeatureId === null) return; if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureId)] || []; // For UI simplicity in viewer, just link to the first selected geometry
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
if (linkedEntityIds.length !== 1) return; if (linkedEntityIds.length !== 1) return;
const onlyEntityId = linkedEntityIds[0]; const onlyEntityId = linkedEntityIds[0];
if (activeEntityId === onlyEntityId) return; if (activeEntityId === onlyEntityId) return;
selectEntity(onlyEntityId, { selectEntity(onlyEntityId, {
sourceFeatureId: selectedFeatureId, sourceFeatureId: selectedFeatureIds[0],
focusMap: false, focusMap: false,
selectGeometry: false, selectGeometry: false,
}); });
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureId]); }, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => { const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => {
clearHoverHideTimer(); clearHoverHideTimer();
@@ -461,12 +466,12 @@ export default function Page() {
<Map <Map
mode="select" mode="select"
draft={data} draft={data}
selectedFeatureId={selectedFeatureId} selectedFeatureIds={selectedFeatureIds}
onSelectFeatureId={setSelectedFeatureId} onSelectFeatureIds={setSelectedFeatureIds}
backgroundVisibility={backgroundVisibility} backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility} geometryVisibility={geometryVisibility}
allowGeometryEditing={false} allowGeometryEditing={false}
respectBindingFilter={false} respectBindingFilter={true}
onHoverFeatureChange={handleMapHoverChange} onHoverFeatureChange={handleMapHoverChange}
highlightFeatures={activeEntityGeometries} highlightFeatures={activeEntityGeometries}
focusFeatureCollection={activeEntityGeometries} focusFeatureCollection={activeEntityGeometries}
@@ -514,11 +519,10 @@ export default function Page() {
key={layer.id} key={layer.id}
type="button" type="button"
onClick={() => handleToggleBackgroundLayer(layer.id)} onClick={() => handleToggleBackgroundLayer(layer.id)}
className={`rounded-md border px-2.5 py-1 text-xs transition ${ className={`rounded-md border px-2.5 py-1 text-xs transition ${active
active
? "border-sky-400/40 bg-sky-500/10 text-sky-200" ? "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" : "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
}`} }`}
> >
{layer.label} {layer.label}
</button> </button>
@@ -544,11 +548,10 @@ export default function Page() {
[typeKey]: prev[typeKey] === false, [typeKey]: prev[typeKey] === false,
})); }));
}} }}
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${ className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active
active
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200" ? "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" : "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
}`} }`}
> >
{typeKey.replaceAll("_", " ")} {typeKey.replaceAll("_", " ")}
</button> </button>
@@ -208,9 +208,9 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
{!activeEntityId ? ( {!activeEntityId ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div> <div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
) : activeLinks.size ? ( ) : activeLinks.size ? (
<div style={{ display: "grid", gap: "6px" }}> <div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div> <div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
{Array.from(activeLinks).slice(0, 8).map((id) => { {Array.from(activeLinks).map((id) => {
const w = wikiChoices.find((x) => x.id === id) || null; const w = wikiChoices.find((x) => x.id === id) || null;
return ( return (
<div <div
@@ -264,9 +264,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
</div> </div>
); );
})} })}
{activeLinks.size > 8 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>+{activeLinks.size - 8} more</div>
) : null}
</div> </div>
) : ( ) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div> <div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
+3 -8
View File
@@ -41,7 +41,6 @@ export default function GeometryBindingPanel({
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]); const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
const visibleRows = rows.slice(0, 12);
return ( return (
<div <div
@@ -101,8 +100,8 @@ export default function GeometryBindingPanel({
</div> </div>
{collapsed ? null : rows.length ? ( {collapsed ? null : rows.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}> <div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{visibleRows {rows
.filter((g) => g.id !== selectedGeometryId) .filter((g) => g.id !== selectedGeometryId)
.map((g) => { .map((g) => {
const isBound = bindingSet.has(g.id); const isBound = bindingSet.has(g.id);
@@ -176,11 +175,7 @@ export default function GeometryBindingPanel({
</div> </div>
); );
})} })}
{rows.length > visibleRows.length ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
+{rows.length - visibleRows.length} more
</div>
) : null}
</div> </div>
) : ( ) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}> <div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
+47 -33
View File
@@ -43,8 +43,8 @@ type MapProps = {
draft: FeatureCollection; draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility; backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>; geometryVisibility?: Record<string, boolean>;
selectedFeatureId: string | number | null; selectedFeatureIds: (string | number)[];
onSelectFeatureId: (id: string | number | null) => void; onSelectFeatureIds: (ids: (string | number)[]) => void;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void; onDeleteFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
@@ -84,8 +84,8 @@ export default function Map({
draft, draft,
backgroundVisibility, backgroundVisibility,
geometryVisibility, geometryVisibility,
selectedFeatureId, selectedFeatureIds,
onSelectFeatureId, onSelectFeatureIds,
onCreateFeature, onCreateFeature,
onDeleteFeature, onDeleteFeature,
onUpdateFeature, onUpdateFeature,
@@ -123,10 +123,10 @@ export default function Map({
const focusFeatureCollectionRef = useRef<FeatureCollection | null>(focusFeatureCollection); const focusFeatureCollectionRef = useRef<FeatureCollection | null>(focusFeatureCollection);
const focusRequestKeyRef = useRef<MapProps["focusRequestKey"]>(focusRequestKey); const focusRequestKeyRef = useRef<MapProps["focusRequestKey"]>(focusRequestKey);
const focusPaddingRef = useRef<MapProps["focusPadding"]>(focusPadding); const focusPaddingRef = useRef<MapProps["focusPadding"]>(focusPadding);
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render). // Mirror của selectedFeatureIds để filter/select trên map (không phụ thuộc re-render).
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId); const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
// Mirror của callback onSelectFeatureId. // Mirror của callback onSelectFeatureIds.
const onSelectFeatureIdRef = useRef(onSelectFeatureId); const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange); const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
// Mirror của callback onCreateFeature. // Mirror của callback onCreateFeature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature); const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
@@ -225,26 +225,26 @@ export default function Map({
}, [draft]); }, [draft]);
useEffect(() => { useEffect(() => {
selectedFeatureIdRef.current = selectedFeatureId; selectedFeatureIdsRef.current = selectedFeatureIds;
}, [selectedFeatureId]); }, [selectedFeatureIds]);
useEffect(() => { useEffect(() => {
onHoverFeatureChangeRef.current = onHoverFeatureChange; onHoverFeatureChangeRef.current = onHoverFeatureChange;
}, [onHoverFeatureChange]); }, [onHoverFeatureChange]);
useEffect(() => { useEffect(() => {
if (mode !== "select" || selectedFeatureId === null) { if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
editingEngineRef.current?.clearEditing(); editingEngineRef.current?.clearEditing();
} }
}, [mode, selectedFeatureId]); }, [mode, selectedFeatureIds]);
useEffect(() => { useEffect(() => {
fitBoundsAppliedRef.current = false; fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]); }, [fitBoundsKey]);
useEffect(() => { useEffect(() => {
onSelectFeatureIdRef.current = onSelectFeatureId; onSelectFeatureIdsRef.current = onSelectFeatureIds;
}, [onSelectFeatureId]); }, [onSelectFeatureIds]);
useEffect(() => { useEffect(() => {
backgroundVisibilityRef.current = backgroundVisibility; backgroundVisibilityRef.current = backgroundVisibility;
@@ -315,7 +315,7 @@ export default function Map({
} }
const visibleDraftRaw = respectBindingFilterRef.current const visibleDraftRaw = respectBindingFilterRef.current
? filterDraftByBinding(fc, selectedFeatureIdRef.current) ? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current)
: fc; : fc;
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current); const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
const { polygons, points } = splitDraftFeatures(visibleDraft); const { polygons, points } = splitDraftFeatures(visibleDraft);
@@ -326,11 +326,15 @@ export default function Map({
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined) (map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)
?.setData(pathArrowShapes); ?.setData(pathArrowShapes);
const selectedId = selectedFeatureIdRef.current; const currentSelectedIds = selectedFeatureIdsRef.current;
setSelectedFeatureState(map, selectedId, true); currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (mapRef.current !== map) return; if (mapRef.current !== map) return;
setSelectedFeatureState(map, selectedId, true); currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
}); });
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) { if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft); fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
@@ -1034,7 +1038,7 @@ export default function Map({
? (id: string | number) => { ? (id: string | number) => {
// ensure edit overlays are cleared when a feature gets removed // ensure edit overlays are cleared when a feature gets removed
editingEngineRef.current?.clearEditing(); editingEngineRef.current?.clearEditing();
onSelectFeatureIdRef.current?.(null); onSelectFeatureIdsRef.current?.([]);
onDeleteRef.current?.(id); onDeleteRef.current?.(id);
} }
: undefined, : undefined,
@@ -1048,7 +1052,7 @@ export default function Map({
editingEngineRef.current?.beginEditing((originalFeature || feature) as any); editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
} }
: undefined, : undefined,
(id) => onSelectFeatureIdRef.current?.(id) (ids) => onSelectFeatureIdsRef.current?.(ids)
); );
const cleanupPoint = initPoint( const cleanupPoint = initPoint(
@@ -1283,7 +1287,7 @@ export default function Map({
editingEngineRef.current?.clearEditing(); editingEngineRef.current?.clearEditing();
} }
} }
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]); }, [allowGeometryEditing, draft, selectedFeatureIds, applyDraftToMap]);
useEffect(() => { useEffect(() => {
if (focusRequestKey === null || focusRequestKey === undefined) return; if (focusRequestKey === null || focusRequestKey === undefined) return;
@@ -1557,9 +1561,16 @@ function getSelectableLayers(map: maplibregl.Map): string[] {
function filterDraftByBinding( function filterDraftByBinding(
fc: FeatureCollection, fc: FeatureCollection,
selectedFeatureId: string | number | null selectedFeatureIds: (string | number)[],
highlightFeatures?: FeatureCollection | null
): FeatureCollection { ): 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: // Semantics:
// - A feature's `binding` is a list of "child" geometry ids. // - 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. // - 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))) }; return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
} }
const selectedFeature = const selectedChildren = new Set<string>();
fc.features.find((feature) => String(feature.properties.id) === selectedId) || null; for (const feature of fc.features) {
const selectedChildren = new Set<string>( if (selectedIds.has(String(feature.properties.id))) {
normalizeBindingIds(selectedFeature?.properties.binding) for (const id of normalizeBindingIds(feature.properties.binding)) {
); selectedChildren.add(id);
}
}
}
return { return {
...fc, ...fc,
features: fc.features.filter((feature) => { features: fc.features.filter((feature) => {
const featureId = String(feature.properties.id); const featureId = String(feature.properties.id);
if (featureId === selectedId) return true; if (selectedIds.has(featureId)) return true;
if (selectedChildren.has(featureId)) return true; if (selectedChildren.has(featureId)) return true;
return !childIds.has(featureId); return !childIds.has(featureId);
}), }),
@@ -1778,9 +1792,9 @@ function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
if (bodyPoints.length < 2) return null; if (bodyPoints.length < 2) return null;
const tailWidth = clampNumber(totalLength * 0.018, 25000, 140000); const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
const shoulderWidth = clampNumber(totalLength * 0.055, 60000, 420000); const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
const headWidth = shoulderWidth * 1.65; const headWidth = shoulderWidth * 2.0;
const leftBody: ProjectedPoint[] = []; const leftBody: ProjectedPoint[] = [];
const rightBody: ProjectedPoint[] = []; const rightBody: ProjectedPoint[] = [];
@@ -97,8 +97,8 @@ export default function ProjectEntityRefsPanel({
</div> </div>
{collapsed ? null : entityRefs.length ? ( {collapsed ? null : entityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}> <div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{entityRefs.slice(0, 8).map((e) => ( {entityRefs.map((e) => (
<div <div
key={e.id} key={e.id}
style={{ style={{
@@ -170,7 +170,7 @@ export default function ProjectEntityRefsPanel({
) : null} ) : null}
</div> </div>
))} ))}
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more</div> : null}
</div> </div>
) : ( ) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div> <div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
+2 -3
View File
@@ -258,11 +258,10 @@ export default function PublicWikiSidebar({
<a <a
key={item.id} key={item.id}
href={`#${item.id}`} href={`#${item.id}`}
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${ className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${isActive
isActive
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300" ? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]" : "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]"
}`} }`}
> >
{item.text} {item.text}
</a> </a>
+6 -5
View File
@@ -13,7 +13,7 @@ import {
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes"; import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
type Props = { type Props = {
selectedFeature: Feature | null; selectedFeatures: Feature[];
selectedFeatureEntitySummary: string; selectedFeatureEntitySummary: string;
selectedFeatureBindingSummary: string; selectedFeatureBindingSummary: string;
entities: Entity[]; entities: Entity[];
@@ -28,7 +28,7 @@ type Props = {
}; };
export default function SelectedGeometryPanel({ export default function SelectedGeometryPanel({
selectedFeature, selectedFeatures,
selectedFeatureEntitySummary, selectedFeatureEntitySummary,
selectedFeatureBindingSummary, selectedFeatureBindingSummary,
entities, entities,
@@ -78,10 +78,11 @@ export default function SelectedGeometryPanel({
const visibleGeoApplyFeedback = const visibleGeoApplyFeedback =
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null; 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 groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature); const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset); const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) => const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id) allowedGroupIds.includes(group.id)
@@ -130,7 +131,7 @@ export default function SelectedGeometryPanel({
{collapsed ? null : ( {collapsed ? null : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}> <div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}> <div style={{ color: "#e2e8f0" }}>
ID: {String(selectedFeature.properties.id)} ID: {selectedFeatures.map(f => String(f.properties.id)).join(", ")}
</div> </div>
<div style={{ color: "#cbd5e1" }}> <div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {selectedFeatureEntitySummary} Entities hiện tại: {selectedFeatureEntitySummary}
+3 -5
View File
@@ -575,8 +575,8 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
</div> </div>
{collapsed ? null : wikis.length ? ( {collapsed ? null : wikis.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}> <div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{wikis.slice(0, 8).map((w) => ( {wikis.map((w) => (
<div <div
key={w.id} key={w.id}
style={{ style={{
@@ -629,9 +629,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
</button> </button>
</div> </div>
))} ))}
{wikis.length > 8 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikis.length - 8} more</div>
) : null}
</div> </div>
) : ( ) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}> <div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
@@ -46,7 +46,7 @@ type Options = {
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>; setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>; setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>; setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>; setSelectedFeatureIds: Dispatch<SetStateAction<FeatureId[]>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>; setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
setEntityStatus: Dispatch<SetStateAction<string | null>>; setEntityStatus: Dispatch<SetStateAction<string | null>>;
setIsSaving: Dispatch<SetStateAction<boolean>>; setIsSaving: Dispatch<SetStateAction<boolean>>;
@@ -76,7 +76,7 @@ export function useSectionCommands(options: Options) {
options.setSnapshotEntities(sessionSnapshot?.entities || []); options.setSnapshotEntities(sessionSnapshot?.entities || []);
options.setSnapshotWikis(sessionSnapshot?.wikis || []); options.setSnapshotWikis(sessionSnapshot?.wikis || []);
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
options.setSelectedFeatureId(null); options.setSelectedFeatureIds([]);
options.setEntityFormStatus(null); options.setEntityFormStatus(null);
}, [options]); }, [options]);
@@ -271,7 +271,7 @@ export function useSectionCommands(options: Options) {
options.setSnapshotEntities(sessionSnapshot?.entities || []); options.setSnapshotEntities(sessionSnapshot?.entities || []);
options.setSnapshotWikis(sessionSnapshot?.wikis || []); options.setSnapshotWikis(sessionSnapshot?.wikis || []);
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []); options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
options.setSelectedFeatureId(null); options.setSelectedFeatureIds([]);
options.setEntityFormStatus(null); options.setEntityFormStatus(null);
// Refresh commits list for UI, but keep sectionState/head as-is. // Refresh commits list for UI, but keep sectionState/head as-is.
@@ -14,8 +14,8 @@ export function useEntitySessionState() {
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]); const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
// Thông báo trạng thái/lỗi liên quan entity/session. // Thông báo trạng thái/lỗi liên quan entity/session.
const [entityStatus, setEntityStatus] = useState<string | null>(null); const [entityStatus, setEntityStatus] = useState<string | null>(null);
// Feature đang được chọn để thao tác bind entities/metadata. // Features đang được chọn để thao tác bind entities/metadata.
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null); const [selectedFeatureIds, setSelectedFeatureIds] = useState<FeatureId[]>([]);
// Form tạo entity mới (độc lập). // Form tạo entity mới (độc lập).
const [entityForm, setEntityForm] = useState<EntityFormState>({ const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "", name: "",
@@ -50,8 +50,8 @@ export function useEntitySessionState() {
setSnapshotEntities, setSnapshotEntities,
entityStatus, entityStatus,
setEntityStatus, setEntityStatus,
selectedFeatureId, selectedFeatureIds,
setSelectedFeatureId, setSelectedFeatureIds,
entityForm, entityForm,
setEntityForm, setEntityForm,
selectedGeometryEntityIds, selectedGeometryEntityIds,
+4 -4
View File
@@ -7,7 +7,7 @@ export function initSelect(
getMode: ModeGetter, getMode: ModeGetter,
onDelete?: (id: string | number) => void, onDelete?: (id: string | number) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onSelectId?: (id: string | number | null) => void onSelectIds?: (ids: (string | number)[]) => void
) { ) {
const SELECTABLE_LAYERS = [ const SELECTABLE_LAYERS = [
"countries-fill", "countries-fill",
@@ -35,7 +35,7 @@ export function initSelect(
selectedIds.forEach((id) => setSelectionStateForId(id, false)); selectedIds.forEach((id) => setSelectionStateForId(id, false));
selectedIds.clear(); selectedIds.clear();
if (emit) { if (emit) {
onSelectId?.(null); onSelectIds?.([]);
} }
} }
@@ -52,13 +52,13 @@ export function initSelect(
// Alt + click on an already selected feature removes it from the selection // Alt + click on an already selected feature removes it from the selection
setSelectionStateForId(id, false); setSelectionStateForId(id, false);
selectedIds.delete(id); selectedIds.delete(id);
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null); onSelectIds?.(Array.from(selectedIds));
return; return;
} }
setSelectionStateForId(id, true); setSelectionStateForId(id, true);
selectedIds.add(id); 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. // Chọn feature theo click trái, hỗ trợ additive bằng Alt.
+2 -1
View File
@@ -61,7 +61,8 @@ export function snapToNearestGeometry(
} }
const type = feature.geometry.type; 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 // Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString
if (type === "Polygon") { if (type === "Polygon") {