Skip to content

Commit 8b2fb5b

Browse files
fix: scrub widgets, comments, and legend when layers disappear (#1627)
* fix: scrub widgets, comments, and legend when layers disappear Drop dangling dashboard/widget, feature-comment, and legend keys on layer/group delete and when applying a project with orphan refs. * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix: efficient override scan and separate customEntries changed flag Use base-id extraction (key.includes("::") ? key.slice(…) : key) for override lookups instead of spreading the removed Set per key. Track customEntries changes with a separate flag so an unrelated order/override change does not needlessly rebuild the customEntries object. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 14d929a commit 8b2fb5b

5 files changed

Lines changed: 501 additions & 11 deletions

File tree

packages/core/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export * from "./layer-defaults";
2222
export * from "./layer-style-clipboard";
2323
export * from "./layer-groups";
2424
export { createSampleStoryMap } from "./storymap-sample";
25+
export {
26+
scrubWidgetsForRemovedLayers,
27+
scrubCommentsForRemovedLayers,
28+
scrubLegendForRemovedLayers,
29+
} from "./layer-ref-scrub";
2530
export {
2631
serializeStoryMapJson,
2732
parseStoryMapJson,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { DashboardWidget, LegendConfig, ProjectComment } from "./types";
2+
3+
/**
4+
* Normalize the removed-layer-ids argument into a Set for uniform lookup.
5+
*/
6+
export function removedLayerIdSet(layerIds: string | Iterable<string>): Set<string> {
7+
if (typeof layerIds === "string") return new Set([layerIds]);
8+
if (layerIds instanceof Set) return layerIds as Set<string>;
9+
return new Set(layerIds);
10+
}
11+
12+
/**
13+
* Filter out dashboard widgets whose `layerId` references a removed layer.
14+
* Returns the same array reference when nothing changes.
15+
*/
16+
export function scrubWidgetsForRemovedLayers(
17+
widgets: DashboardWidget[],
18+
layerIds: string | Iterable<string>,
19+
): DashboardWidget[] {
20+
const removed = removedLayerIdSet(layerIds);
21+
if (removed.size === 0 || widgets.length === 0) return widgets;
22+
const filtered = widgets.filter((w) => !removed.has(w.layerId));
23+
return filtered.length === widgets.length ? widgets : filtered;
24+
}
25+
26+
/**
27+
* Drop comments whose anchor is a feature on a removed layer.
28+
* Point-anchored comments are always kept.
29+
* Returns the same array reference when nothing changes.
30+
*/
31+
export function scrubCommentsForRemovedLayers(
32+
comments: ProjectComment[],
33+
layerIds: string | Iterable<string>,
34+
): ProjectComment[] {
35+
const removed = removedLayerIdSet(layerIds);
36+
if (removed.size === 0 || comments.length === 0) return comments;
37+
const filtered = comments.filter(
38+
(c) => !(c.anchor.type === "feature" && removed.has(c.anchor.layerId)),
39+
);
40+
return filtered.length === comments.length ? comments : filtered;
41+
}
42+
43+
/**
44+
* Scrub legend config of references to removed layers:
45+
* - `order`: filter out removed layer ids
46+
* - `overrides`: drop keys that equal a removed id OR start with `${id}::`
47+
* - `customEntries`: drop keys that equal a removed layer id but KEEP keys
48+
* starting with `custom:`
49+
*
50+
* Returns the same reference when nothing changes.
51+
*/
52+
export function scrubLegendForRemovedLayers(
53+
legend: LegendConfig,
54+
layerIds: string | Iterable<string>,
55+
): LegendConfig {
56+
const removed = removedLayerIdSet(layerIds);
57+
if (removed.size === 0) return legend;
58+
59+
let changed = false;
60+
61+
// --- order ---
62+
const order = legend.order.filter((id) => !removed.has(id));
63+
if (order.length !== legend.order.length) changed = true;
64+
65+
// --- overrides ---
66+
const overrides: Record<string, (typeof legend.overrides)[string]> = {};
67+
for (const [key, value] of Object.entries(legend.overrides)) {
68+
const base = key.includes("::") ? key.slice(0, key.indexOf("::")) : key;
69+
if (removed.has(base)) {
70+
changed = true;
71+
} else {
72+
overrides[key] = value;
73+
}
74+
}
75+
76+
// --- customEntries ---
77+
let customEntries = legend.customEntries;
78+
if (customEntries) {
79+
let customEntriesChanged = false;
80+
const nextCustom: Record<string, (typeof customEntries)[string]> = {};
81+
for (const [key, value] of Object.entries(customEntries)) {
82+
if (key.startsWith("custom:")) {
83+
nextCustom[key] = value;
84+
} else if (removed.has(key)) {
85+
customEntriesChanged = true;
86+
} else {
87+
nextCustom[key] = value;
88+
}
89+
}
90+
if (customEntriesChanged) {
91+
customEntries = Object.keys(nextCustom).length > 0 ? nextCustom : undefined;
92+
changed = true;
93+
}
94+
}
95+
96+
if (!changed) return legend;
97+
return { ...legend, order, overrides, customEntries };
98+
}

packages/core/src/project.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ import {
5252
import { DEFAULT_LAYER_GROUP_OPACITY, normalizeGroupContiguity } from "./layer-groups";
5353
import { normalizeStyleLibraryEntries } from "./style-library";
5454
import { getEllipsoid } from "./ellipsoids";
55+
import {
56+
scrubWidgetsForRemovedLayers,
57+
scrubCommentsForRemovedLayers,
58+
scrubLegendForRemovedLayers,
59+
} from "./layer-ref-scrub";
5560

5661
/** Placeholder name a project carries before the user names it. */
5762
export const DEFAULT_PROJECT_NAME = "Untitled Project";
@@ -1499,6 +1504,41 @@ export function applyProjectToStore(project: GeoLibreProject): {
14991504
normalizeSecondaryMapViews(project.secondaryMapViews),
15001505
{ mapView },
15011506
);
1507+
1508+
// Scrub cross-references that point at layers not present in the loaded
1509+
// project (orphans from hand-editing or a partial project file).
1510+
const existingLayerIds = new Set(normalizedLayers.map((l) => l.id));
1511+
const widgets = normalizeWidgets(project.widgets) ?? [];
1512+
const comments = normalizeProjectComments(project.comments);
1513+
const legend = normalizeLegendConfig(project.legend) ?? {
1514+
...DEFAULT_LEGEND_CONFIG,
1515+
};
1516+
1517+
const allReferencedIds = new Set<string>();
1518+
for (const w of widgets) allReferencedIds.add(w.layerId);
1519+
for (const c of comments) {
1520+
if (c.anchor.type === "feature") allReferencedIds.add(c.anchor.layerId);
1521+
}
1522+
for (const id of legend.order) allReferencedIds.add(id);
1523+
for (const key of Object.keys(legend.overrides)) {
1524+
const base = key.includes("::") ? key.slice(0, key.indexOf("::")) : key;
1525+
allReferencedIds.add(base);
1526+
}
1527+
if (legend.customEntries) {
1528+
for (const key of Object.keys(legend.customEntries)) {
1529+
if (!key.startsWith("custom:")) allReferencedIds.add(key);
1530+
}
1531+
}
1532+
1533+
const orphanIds = new Set([...allReferencedIds].filter((id) => !existingLayerIds.has(id)));
1534+
1535+
const scrubbedWidgets =
1536+
orphanIds.size > 0 ? scrubWidgetsForRemovedLayers(widgets, orphanIds) : widgets;
1537+
const scrubbedComments =
1538+
orphanIds.size > 0 ? scrubCommentsForRemovedLayers(comments, orphanIds) : comments;
1539+
const scrubbedLegend =
1540+
orphanIds.size > 0 ? scrubLegendForRemovedLayers(legend, orphanIds) : legend;
1541+
15021542
return {
15031543
projectName: project.name,
15041544
mapView,
@@ -1509,19 +1549,17 @@ export function applyProjectToStore(project: GeoLibreProject): {
15091549
layerGroups,
15101550
preferences: normalizeProjectPreferences(project.preferences),
15111551
projectPlugins: normalizeProjectPlugins(project.plugins),
1512-
legend: normalizeLegendConfig(project.legend) ?? {
1513-
...DEFAULT_LEGEND_CONFIG,
1514-
},
1552+
legend: scrubbedLegend,
15151553
storymap: normalizeStoryMap(project.storymap),
15161554
models: normalizeModels(project.models) ?? [],
15171555
processingHistory: normalizeProcessingHistory(project.processingHistory) ?? [],
1518-
widgets: normalizeWidgets(project.widgets) ?? [],
1556+
widgets: scrubbedWidgets,
15191557
dashboardColumns: normalizeDashboardColumns(project.dashboardColumns),
15201558
mapLayout,
15211559
secondaryMapViews,
15221560
primaryMapLabel: normalizeString(project.primaryMapLabel),
15231561
projectStyleLibrary: normalizeStyleLibraryEntries(project.styleLibrary),
1524-
comments: normalizeProjectComments(project.comments),
1562+
comments: scrubbedComments,
15251563
metadata: project.metadata,
15261564
};
15271565
}

packages/core/src/store.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ import {
6363
type CommentReply,
6464
type ProjectComment,
6565
} from "./types";
66+
import {
67+
removedLayerIdSet,
68+
scrubWidgetsForRemovedLayers,
69+
scrubCommentsForRemovedLayers,
70+
scrubLegendForRemovedLayers,
71+
} from "./layer-ref-scrub";
6672
import { hasSimpleStyleProperties } from "./vector-color";
6773
import {
6874
applyCopiedLayerStyle,
@@ -747,12 +753,6 @@ function sameCamera(a: MapViewState, b: MapViewState): boolean {
747753
);
748754
}
749755

750-
function removedLayerIdSet(layerIds: string | Iterable<string>): Set<string> {
751-
if (typeof layerIds === "string") return new Set([layerIds]);
752-
if (layerIds instanceof Set) return layerIds;
753-
return new Set(layerIds);
754-
}
755-
756756
/**
757757
* Strip storymap chapter enter/exit opacity rows that reference any of the
758758
* removed layer ids. Returns the same reference when nothing changes so
@@ -1618,6 +1618,9 @@ export const useAppStore = create<AppState>()(
16181618
// Drop storymap chapter enter/exit opacity rows that pointed at the
16191619
// removed layer so they do not keep a dangling id across save/reload.
16201620
storymap: scrubStorymapLayerRefs(s.storymap, id),
1621+
widgets: scrubWidgetsForRemovedLayers(s.widgets, id),
1622+
comments: scrubCommentsForRemovedLayers(s.comments, id),
1623+
legend: scrubLegendForRemovedLayers(s.legend, id),
16211624
selectedLayerId:
16221625
s.selectedLayerId === id
16231626
? (s.layers.find((l) => l.id !== id)?.id ?? null)
@@ -1953,6 +1956,13 @@ export const useAppStore = create<AppState>()(
19531956
? scrubSecondaryPaneLayerVisibility(s.secondaryMapViews, removedIds)
19541957
: s.secondaryMapViews,
19551958
storymap: removeChildren ? scrubStorymapLayerRefs(s.storymap, removedIds) : s.storymap,
1959+
widgets: removeChildren
1960+
? scrubWidgetsForRemovedLayers(s.widgets, removedIds)
1961+
: s.widgets,
1962+
comments: removeChildren
1963+
? scrubCommentsForRemovedLayers(s.comments, removedIds)
1964+
: s.comments,
1965+
legend: removeChildren ? scrubLegendForRemovedLayers(s.legend, removedIds) : s.legend,
19561966
selectedLayerId: selectionRemoved
19571967
? (layers[layers.length - 1]?.id ?? null)
19581968
: s.selectedLayerId,

0 commit comments

Comments
 (0)