Skip to content

Commit 202ff89

Browse files
committed
Add fallback units and navigation updates
Add system-managed fallback circulation units, update IMDF relationship handling for intermediary arrays and categories, and carry floor ids through route picking.
1 parent d5eea8e commit 202ff89

14 files changed

Lines changed: 378 additions & 52 deletions

src/App.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from "./lib/imdf/creationDefaults";
4343
import { sortFeaturesForRendering } from "./lib/imdf/export";
4444
import { cloneImdfFeature } from "./lib/imdf/factories";
45+
import { reconcileFallbackUnits } from "./lib/imdf/fallbackUnits";
4546
import { readImdfType } from "./lib/imdf/featureCatalog";
4647
import { getLevelGeometryFeatures, isLevelGeometryFeature } from "./lib/imdf/levelGeometry";
4748
import { migrateProjectSnapshotToImdfNavigationV7 } from "./lib/imdf/migrations/v7";
@@ -670,7 +671,9 @@ function App() {
670671
const [archiveNotices, setArchiveNotices] = useState<ArchiveNotice[]>([]);
671672
const [routePickEnabled, setRoutePickEnabled] = useState(false);
672673
const [routeStartCoordinate, setRouteStartCoordinate] = useState<Coordinates>();
674+
const [routeStartLevelId, setRouteStartLevelId] = useState<string>();
673675
const [routeEndCoordinate, setRouteEndCoordinate] = useState<Coordinates>();
676+
const [routeEndLevelId, setRouteEndLevelId] = useState<string>();
674677
const [relocationRequest, setRelocationRequest] = useState<MapRelocationRequest>({
675678
center: initialMapView.center,
676679
zoom: initialMapView.zoom,
@@ -957,21 +960,47 @@ function App() {
957960
);
958961
}, [editorState.features, activeLevel]);
959962

963+
// biome-ignore lint/correctness/useExhaustiveDependencies: feature edits must trigger fallback reconciliation.
964+
useEffect(() => {
965+
if (levels.length === 0) {
966+
return;
967+
}
968+
setEditorState((current) => {
969+
const reconciledFeatures = reconcileFallbackUnits(current.features, levels);
970+
if (areFeatureListsEqual(current.features, reconciledFeatures)) {
971+
return current;
972+
}
973+
const selectedFeatureId =
974+
current.selectedFeatureId &&
975+
reconciledFeatures.some((feature) => feature.id === current.selectedFeatureId)
976+
? current.selectedFeatureId
977+
: undefined;
978+
return selectFeature(replaceAllFeatures(current, reconciledFeatures), selectedFeatureId);
979+
});
980+
}, [levels, editorState.features]);
981+
960982
const navigationGraph = useMemo(
961983
() => buildNavigationGraph(editorState.features),
962984
[editorState.features],
963985
);
964986
const routeResult = useMemo(() => {
965-
if (!activeLevel || !routeStartCoordinate || !routeEndCoordinate) {
987+
if (!routeStartCoordinate || !routeEndCoordinate) {
966988
return undefined;
967989
}
968990
return findRouteBetweenPoints(
969991
navigationGraph,
970992
routeStartCoordinate,
971993
routeEndCoordinate,
972-
activeLevel.id,
994+
routeStartLevelId,
995+
routeEndLevelId,
973996
);
974-
}, [navigationGraph, routeStartCoordinate, routeEndCoordinate, activeLevel]);
997+
}, [
998+
navigationGraph,
999+
routeStartCoordinate,
1000+
routeEndCoordinate,
1001+
routeStartLevelId,
1002+
routeEndLevelId,
1003+
]);
9751004

9761005
const routeOverlayFeatures = useMemo(() => {
9771006
if (!activeLevel || !routeResult?.found) {
@@ -1450,10 +1479,13 @@ function App() {
14501479
}
14511480
if (!routeStartCoordinate || routeEndCoordinate) {
14521481
setRouteStartCoordinate(coordinate);
1482+
setRouteStartLevelId(activeLevel.id);
14531483
setRouteEndCoordinate(undefined);
1484+
setRouteEndLevelId(undefined);
14541485
return;
14551486
}
14561487
setRouteEndCoordinate(coordinate);
1488+
setRouteEndLevelId(activeLevel.id);
14571489
},
14581490
[activeLevel, routePickEnabled, drawMode, routeStartCoordinate, routeEndCoordinate],
14591491
);
@@ -3147,13 +3179,13 @@ function App() {
31473179
<div className="mt-2 text-xs text-base-content/70">
31483180
Start:{" "}
31493181
{routeStartCoordinate
3150-
? `${routeStartCoordinate[0].toFixed(6)}, ${routeStartCoordinate[1].toFixed(6)}`
3182+
? `${routeStartCoordinate[0].toFixed(6)}, ${routeStartCoordinate[1].toFixed(6)} (${routeStartLevelId ?? "unknown floor"})`
31513183
: "not set"}
31523184
</div>
31533185
<div className="text-xs text-base-content/70">
31543186
Destination:{" "}
31553187
{routeEndCoordinate
3156-
? `${routeEndCoordinate[0].toFixed(6)}, ${routeEndCoordinate[1].toFixed(6)}`
3188+
? `${routeEndCoordinate[0].toFixed(6)}, ${routeEndCoordinate[1].toFixed(6)} (${routeEndLevelId ?? "unknown floor"})`
31573189
: "not set"}
31583190
</div>
31593191
<div className="mt-2 text-xs text-base-content/70">
@@ -3168,7 +3200,9 @@ function App() {
31683200
type="button"
31693201
onClick={() => {
31703202
setRouteStartCoordinate(undefined);
3203+
setRouteStartLevelId(undefined);
31713204
setRouteEndCoordinate(undefined);
3205+
setRouteEndLevelId(undefined);
31723206
}}
31733207
>
31743208
<AppIcon name="clear" />

src/components/Sidebar/AddFeatureButtonGroups.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ type AddFeatureButtonGroupsProps = {
2121
const ADDABLE_GROUPS: Array<{ title: string; types: SupportedImdfType[] }> = [
2222
{
2323
title: "Areas",
24-
types: ["unit", "section", "geofence"],
24+
types: ["unit", "section", "geofence", "fixture", "kiosk"],
2525
},
2626
{
2727
title: "Points",
28-
types: ["amenity", "anchor", "detail", "fixture", "kiosk", "occupant"],
28+
types: ["amenity", "anchor", "detail", "occupant"],
2929
},
3030
];
3131

src/components/Sidebar/SelectionSidebar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Selection } from "../../lib/editor/selection";
2+
import { isFallbackUnitFeature } from "../../lib/imdf/fallbackUnits";
23
import { readFeatureType } from "../../lib/imdf/featureDisplay";
34
import { getLevelGeometryFeatures, hasLevelGeometry } from "../../lib/imdf/levelGeometry";
45
import { validateFloor } from "../../lib/imdf/validate";
@@ -57,7 +58,9 @@ const deriveBuildingFeatureCandidates = (
5758

5859
return {
5960
anchorCandidates: buildingFeatures.filter((current) => readFeatureType(current) === "anchor"),
60-
unitCandidates: buildingFeatures.filter((current) => readFeatureType(current) === "unit"),
61+
unitCandidates: buildingFeatures.filter(
62+
(current) => readFeatureType(current) === "unit" && !isFallbackUnitFeature(current),
63+
),
6164
};
6265
};
6366

@@ -304,6 +307,9 @@ export const SelectionSidebar = ({
304307
}
305308

306309
if (selection.kind === "feature" && feature) {
310+
if (isFallbackUnitFeature(feature)) {
311+
return <SelectionMessageCard message="Fallback circulation units are system-managed." />;
312+
}
307313
return (
308314
<FeatureEditor
309315
feature={feature}

src/components/Tree/BuildingsTree.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Selection } from "../../lib/editor/selection";
33
import { getFeatureIconKey } from "../../lib/icons/iconRegistry";
44
import { getChildrenByParent } from "../../lib/imdf/containment";
55
import { sortFeaturesForRendering } from "../../lib/imdf/export";
6+
import { isFallbackUnitFeature } from "../../lib/imdf/fallbackUnits";
67
import { isLevelGeometryFeature } from "../../lib/imdf/levelGeometry";
78
import {
89
isNavigationPathOpening,
@@ -127,6 +128,9 @@ export const BuildingsTree = ({
127128
if (isLevelGeometryFeature(feature)) {
128129
continue;
129130
}
131+
if (isFallbackUnitFeature(feature)) {
132+
continue;
133+
}
130134
if (typeof feature.properties.level_id !== "string") {
131135
continue;
132136
}

src/lib/imdf/archiveExport.ts

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,22 @@ const normalizePolygon = (
156156
return { type: "Polygon", coordinates: [closed] };
157157
};
158158

159+
const polygonFromPoint = (
160+
coordinate: Coordinates,
161+
span = 0.00001,
162+
): Extract<FloorFeature["geometry"], { type: "Polygon" }> => ({
163+
type: "Polygon",
164+
coordinates: [
165+
[
166+
[coordinate[0] - span, coordinate[1] - span],
167+
[coordinate[0] + span, coordinate[1] - span],
168+
[coordinate[0] + span, coordinate[1] + span],
169+
[coordinate[0] - span, coordinate[1] + span],
170+
[coordinate[0] - span, coordinate[1] - span],
171+
],
172+
],
173+
});
174+
159175
const normalizeLine = (
160176
geometry: FloorFeature["geometry"],
161177
): Extract<FloorFeature["geometry"], { type: "LineString" }> | undefined => {
@@ -342,6 +358,15 @@ const readRelationshipReference = (
342358
return undefined;
343359
};
344360

361+
const readRelationshipReferences = (
362+
value: unknown,
363+
): Array<{ id: string; feature_type?: string }> =>
364+
Array.isArray(value)
365+
? value
366+
.map((entry) => readRelationshipReference(entry))
367+
.filter((entry): entry is { id: string; feature_type?: string } => Boolean(entry))
368+
: [];
369+
345370
type BuildInput = {
346371
building: Building;
347372
floors: Floor[];
@@ -581,25 +606,29 @@ export const buildImdfArchivePayload = ({
581606
(typeof feature.properties.intermediary_id === "string"
582607
? { id: feature.properties.intermediary_id }
583608
: undefined);
609+
const intermediaryRefsFromArray = readRelationshipReferences(feature.properties.intermediary);
584610
const destinationRef =
585611
readRelationshipReference(feature.properties.destination) ??
586612
(typeof feature.properties.destination_id === "string"
587613
? { id: feature.properties.destination_id }
588614
: undefined);
589615
const resolvedOriginId = relation?.origin?.featureId ?? originRef?.id;
590-
const resolvedIntermediaryId = relation?.intermediary?.featureId ?? intermediaryRef?.id;
616+
const resolvedIntermediaryIds =
617+
relation?.intermediary?.map((entry) => entry.featureId).filter((entry) => Boolean(entry)) ??
618+
(intermediaryRefsFromArray.length > 0
619+
? intermediaryRefsFromArray.map((entry) => entry.id)
620+
: intermediaryRef?.id
621+
? [intermediaryRef.id]
622+
: []);
591623
const resolvedDestinationId = relation?.destination?.featureId ?? destinationRef?.id;
592624
if (mappedType === "relationship") {
625+
if (baseProperties["category"] === undefined) {
626+
baseProperties["category"] = "contains";
627+
}
593628
const resolvedOriginType =
594629
originRef?.feature_type ??
595630
(resolvedOriginId ? resolvedTypeByFeatureId.get(resolvedOriginId) : undefined) ??
596631
"unit";
597-
const resolvedIntermediaryType =
598-
intermediaryRef?.feature_type ??
599-
(resolvedIntermediaryId
600-
? resolvedTypeByFeatureId.get(resolvedIntermediaryId)
601-
: undefined) ??
602-
"unit";
603632
const resolvedDestinationType =
604633
destinationRef?.feature_type ??
605634
(resolvedDestinationId ? resolvedTypeByFeatureId.get(resolvedDestinationId) : undefined) ??
@@ -610,12 +639,14 @@ export const buildImdfArchivePayload = ({
610639
feature_type: resolvedOriginType,
611640
};
612641
}
613-
if (resolvedIntermediaryId) {
614-
baseProperties["intermediary"] = {
615-
id:
616-
featureUuidById.get(resolvedIntermediaryId) ?? resolveImdfUuid(resolvedIntermediaryId),
617-
feature_type: resolvedIntermediaryType,
618-
};
642+
if (resolvedIntermediaryIds.length > 0) {
643+
baseProperties["intermediary"] = resolvedIntermediaryIds.map((intermediaryId) => ({
644+
id: featureUuidById.get(intermediaryId) ?? resolveImdfUuid(intermediaryId),
645+
feature_type:
646+
intermediaryRefsFromArray.find((entry) => entry.id === intermediaryId)?.feature_type ??
647+
resolvedTypeByFeatureId.get(intermediaryId) ??
648+
"unit",
649+
}));
619650
}
620651
if (resolvedDestinationId) {
621652
baseProperties["destination"] = {
@@ -636,9 +667,10 @@ export const buildImdfArchivePayload = ({
636667
baseProperties["origin_id"] =
637668
featureUuidById.get(resolvedOriginId) ?? resolveImdfUuid(resolvedOriginId);
638669
}
639-
if (resolvedIntermediaryId) {
670+
if (resolvedIntermediaryIds[0]) {
671+
const intermediaryId = resolvedIntermediaryIds[0];
640672
baseProperties["intermediary_id"] =
641-
featureUuidById.get(resolvedIntermediaryId) ?? resolveImdfUuid(resolvedIntermediaryId);
673+
featureUuidById.get(intermediaryId) ?? resolveImdfUuid(intermediaryId);
642674
}
643675
if (resolvedDestinationId) {
644676
baseProperties["destination_id"] =
@@ -663,7 +695,11 @@ export const buildImdfArchivePayload = ({
663695

664696
let geometry: FloorFeature["geometry"] | null = null;
665697
if (spec.geometryType === "Polygon") {
666-
const normalized = normalizePolygon(feature.geometry);
698+
const normalized =
699+
normalizePolygon(feature.geometry) ??
700+
((mappedType === "fixture" || mappedType === "kiosk") && feature.geometry.type === "Point"
701+
? polygonFromPoint(feature.geometry.coordinates)
702+
: undefined);
667703
if (!normalized) {
668704
warnings.push(`Feature ${feature.id} skipped: invalid polygon geometry for ${mappedType}.`);
669705
continue;

src/lib/imdf/archiveImport.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ const readRelationshipRefId = (value: unknown): string | undefined => {
149149
return undefined;
150150
};
151151

152+
const readRelationshipRefIds = (value: unknown): string[] => {
153+
if (!Array.isArray(value)) {
154+
return [];
155+
}
156+
return value
157+
.map((entry) => readRelationshipRefId(entry))
158+
.filter((entry): entry is string => Boolean(entry));
159+
};
160+
152161
const readRelationshipRefType = (value: unknown): string | undefined => {
153162
if (
154163
isRecord(value) &&
@@ -514,7 +523,9 @@ export const importImdfArchiveZip = async (file: File): Promise<ImportArchiveRes
514523
const originId =
515524
readRelationshipRefId(properties["origin"]) ??
516525
(typeof properties["origin_id"] === "string" ? properties["origin_id"] : undefined);
526+
const intermediaryIds = readRelationshipRefIds(properties["intermediary"]);
517527
const intermediaryId =
528+
intermediaryIds[0] ??
518529
readRelationshipRefId(properties["intermediary"]) ??
519530
(typeof properties["intermediary_id"] === "string"
520531
? properties["intermediary_id"]
@@ -559,7 +570,12 @@ export const importImdfArchiveZip = async (file: File): Promise<ImportArchiveRes
559570
: {}),
560571
...(intermediaryId
561572
? {
562-
intermediary: { id: intermediaryId, feature_type: "unit" },
573+
intermediary: [...new Set([intermediaryId, ...intermediaryIds]).values()].map(
574+
(id) => ({
575+
id,
576+
feature_type: "unit",
577+
}),
578+
),
563579
intermediary_id: intermediaryId,
564580
}
565581
: {}),
@@ -574,7 +590,14 @@ export const importImdfArchiveZip = async (file: File): Promise<ImportArchiveRes
574590
"formation:relation": {
575591
origin: { featureId: originId },
576592
...(intermediaryId
577-
? { intermediary: { featureId: intermediaryId, level_id } }
593+
? {
594+
intermediary: [
595+
...new Set([intermediaryId, ...intermediaryIds]).values(),
596+
].map((id) => ({
597+
featureId: id,
598+
level_id,
599+
})),
600+
}
578601
: {}),
579602
destination: { featureId: destinationId },
580603
},
@@ -730,6 +753,12 @@ export const importImdfArchiveZip = async (file: File): Promise<ImportArchiveRes
730753
: typeof destinationFeature?.properties.level_id === "string"
731754
? destinationFeature.properties.level_id
732755
: undefined);
756+
const intermediaryForRelationship = readRelationshipRefIds(properties["intermediary"]);
757+
const relationshipCategory = normalizeImportedCategory(
758+
"relationship",
759+
properties["category"],
760+
raw["id"],
761+
);
733762
features.push({
734763
type: "Feature",
735764
id: raw["id"],
@@ -748,6 +777,16 @@ export const importImdfArchiveZip = async (file: File): Promise<ImportArchiveRes
748777
...(originId
749778
? { origin: { id: originId, feature_type: "opening" }, origin_id: originId }
750779
: {}),
780+
...(relationshipCategory ? { category: relationshipCategory } : {}),
781+
...(intermediaryForRelationship.length > 0
782+
? {
783+
intermediary: intermediaryForRelationship.map((id) => ({
784+
id,
785+
feature_type: "opening",
786+
})),
787+
intermediary_id: intermediaryForRelationship[0],
788+
}
789+
: {}),
751790
...(destinationId
752791
? {
753792
destination: { id: destinationId, feature_type: "opening" },
@@ -758,6 +797,13 @@ export const importImdfArchiveZip = async (file: File): Promise<ImportArchiveRes
758797
? {
759798
"formation:relation": {
760799
origin: { featureId: originId },
800+
...(intermediaryForRelationship.length > 0
801+
? {
802+
intermediary: intermediaryForRelationship.map((id) => ({
803+
featureId: id,
804+
})),
805+
}
806+
: {}),
761807
destination: { featureId: destinationId },
762808
},
763809
}

0 commit comments

Comments
 (0)