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:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user