@carto/ps-react-ui 4.12.2 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/components.js +706 -658
  2. package/dist/components.js.map +1 -1
  3. package/dist/legend/stores.js +20 -0
  4. package/dist/legend/stores.js.map +1 -0
  5. package/dist/legend.js +1536 -0
  6. package/dist/legend.js.map +1 -0
  7. package/dist/selectors-DwHj6mE2.js +388 -0
  8. package/dist/selectors-DwHj6mE2.js.map +1 -0
  9. package/dist/types/components/index.d.ts +1 -0
  10. package/dist/types/components/responsive-drawer/responsive-drawer.d.ts +35 -3
  11. package/dist/types/legend/components/contexts.d.ts +20 -0
  12. package/dist/types/legend/components/icons.d.ts +12 -0
  13. package/dist/types/legend/components/index.d.ts +26 -0
  14. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +24 -0
  15. package/dist/types/legend/components/legend-actions/styles.d.ts +7 -0
  16. package/dist/types/legend/components/legend-category/legend-category-ui.d.ts +13 -0
  17. package/dist/types/legend/components/legend-category/styles.d.ts +52 -0
  18. package/dist/types/legend/components/legend-config-select/legend-config-select.d.ts +14 -0
  19. package/dist/types/legend/components/legend-config-select/styles.d.ts +44 -0
  20. package/dist/types/legend/components/legend-group/legend-group.d.ts +21 -0
  21. package/dist/types/legend/components/legend-group/styles.d.ts +63 -0
  22. package/dist/types/legend/components/legend-icon/legend-icon-ui.d.ts +10 -0
  23. package/dist/types/legend/components/legend-icon/styles.d.ts +52 -0
  24. package/dist/types/legend/components/legend-item/legend-item-ui.d.ts +14 -0
  25. package/dist/types/legend/components/legend-item/legend-item.d.ts +10 -0
  26. package/dist/types/legend/components/legend-item/styles.d.ts +7 -0
  27. package/dist/types/legend/components/legend-opacity/legend-opacity.d.ts +32 -0
  28. package/dist/types/legend/components/legend-opacity/styles.d.ts +23 -0
  29. package/dist/types/legend/components/legend-proportion/legend-proportion-ui.d.ts +16 -0
  30. package/dist/types/legend/components/legend-proportion/styles.d.ts +37 -0
  31. package/dist/types/legend/components/legend-ramp/legend-ramp-ui.d.ts +20 -0
  32. package/dist/types/legend/components/legend-ramp/styles.d.ts +53 -0
  33. package/dist/types/legend/components/legend-row/legend-row.d.ts +23 -0
  34. package/dist/types/legend/components/legend-row/styles.d.ts +77 -0
  35. package/dist/types/legend/components/legend-row-menu/legend-row-menu.d.ts +18 -0
  36. package/dist/types/legend/components/legend-row-menu/legend-zoom-to.d.ts +15 -0
  37. package/dist/types/legend/components/legend-swatch/legend-swatch.d.ts +26 -0
  38. package/dist/types/legend/components/legend-visibility-toggle/legend-visibility-toggle.d.ts +35 -0
  39. package/dist/types/legend/index.d.ts +88 -0
  40. package/dist/types/legend/provider/index.d.ts +6 -0
  41. package/dist/types/legend/provider/labels.d.ts +27 -0
  42. package/dist/types/legend/provider/legend-config-context.d.ts +7 -0
  43. package/dist/types/legend/provider/legend-provider.d.ts +38 -0
  44. package/dist/types/legend/stores/index.d.ts +5 -0
  45. package/dist/types/legend/stores/legend-context.d.ts +4 -0
  46. package/dist/types/legend/stores/legend-store-registry.d.ts +39 -0
  47. package/dist/types/legend/stores/legend-store.test.d.ts +1 -0
  48. package/dist/types/legend/stores/selectors.d.ts +33 -0
  49. package/dist/types/legend/stores/types.d.ts +275 -0
  50. package/package.json +11 -3
  51. package/src/components/geolocation-controls/geolocation-controls.test.tsx +15 -0
  52. package/src/components/geolocation-controls/geolocation-controls.tsx +19 -3
  53. package/src/components/index.ts +2 -0
  54. package/src/components/responsive-drawer/responsive-drawer.test.tsx +129 -25
  55. package/src/components/responsive-drawer/responsive-drawer.tsx +149 -16
  56. package/src/legend/components/contexts.ts +32 -0
  57. package/src/legend/components/icons.tsx +22 -0
  58. package/src/legend/components/index.ts +39 -0
  59. package/src/legend/components/legend-actions/legend-actions.tsx +57 -0
  60. package/src/legend/components/legend-actions/styles.ts +9 -0
  61. package/src/legend/components/legend-category/legend-category-ui.tsx +91 -0
  62. package/src/legend/components/legend-category/styles.ts +61 -0
  63. package/src/legend/components/legend-config-select/legend-config-select.tsx +96 -0
  64. package/src/legend/components/legend-config-select/styles.ts +48 -0
  65. package/src/legend/components/legend-group/legend-group.test.tsx +115 -0
  66. package/src/legend/components/legend-group/legend-group.tsx +88 -0
  67. package/src/legend/components/legend-group/styles.ts +82 -0
  68. package/src/legend/components/legend-icon/legend-icon-ui.tsx +56 -0
  69. package/src/legend/components/legend-icon/styles.ts +56 -0
  70. package/src/legend/components/legend-item/legend-item-ui.test.tsx +362 -0
  71. package/src/legend/components/legend-item/legend-item-ui.tsx +43 -0
  72. package/src/legend/components/legend-item/legend-item.tsx +34 -0
  73. package/src/legend/components/legend-item/styles.ts +11 -0
  74. package/src/legend/components/legend-opacity/legend-opacity.tsx +179 -0
  75. package/src/legend/components/legend-opacity/styles.ts +28 -0
  76. package/src/legend/components/legend-proportion/legend-proportion-ui.tsx +122 -0
  77. package/src/legend/components/legend-proportion/styles.ts +43 -0
  78. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +159 -0
  79. package/src/legend/components/legend-ramp/styles.ts +62 -0
  80. package/src/legend/components/legend-row/legend-row.test.tsx +498 -0
  81. package/src/legend/components/legend-row/legend-row.tsx +129 -0
  82. package/src/legend/components/legend-row/styles.ts +111 -0
  83. package/src/legend/components/legend-row-menu/legend-row-menu.tsx +68 -0
  84. package/src/legend/components/legend-row-menu/legend-zoom-to.tsx +35 -0
  85. package/src/legend/components/legend-swatch/legend-swatch.test.tsx +109 -0
  86. package/src/legend/components/legend-swatch/legend-swatch.tsx +237 -0
  87. package/src/legend/components/legend-visibility-toggle/legend-visibility-toggle.tsx +127 -0
  88. package/src/legend/index.ts +121 -0
  89. package/src/legend/provider/index.ts +6 -0
  90. package/src/legend/provider/labels.ts +59 -0
  91. package/src/legend/provider/legend-config-context.ts +15 -0
  92. package/src/legend/provider/legend-provider.tsx +102 -0
  93. package/src/legend/stores/index.ts +46 -0
  94. package/src/legend/stores/legend-context.ts +17 -0
  95. package/src/legend/stores/legend-store-registry.ts +639 -0
  96. package/src/legend/stores/legend-store.test.ts +457 -0
  97. package/src/legend/stores/selectors.ts +65 -0
  98. package/src/legend/stores/types.ts +347 -0
@@ -0,0 +1,639 @@
1
+ import { useStore, type StoreApi } from 'zustand'
2
+ import { createStore } from 'zustand/vanilla'
3
+ import { devtools } from 'zustand/middleware'
4
+ import { useShallow } from 'zustand/react/shallow'
5
+ import type {
6
+ LegendConfigSection,
7
+ LegendGroup,
8
+ LegendGroupInput,
9
+ LegendInit,
10
+ LegendLayer,
11
+ LegendLayerInput,
12
+ LegendState,
13
+ LegendStoreApi,
14
+ } from './types'
15
+
16
+ // Per-panel legend store registry. One store per `<Legend.Provider id>`.
17
+ // Mirrors the widgets-v2 registry pattern (keyed Map + refcounted mounts),
18
+ // without the chart-pipeline machinery.
19
+ const legendStores = new Map<string, LegendStoreApi>()
20
+ const legendMountCounts = new Map<string, number>()
21
+ const warnedDuplicates = new Set<string>()
22
+
23
+ const isDev = (): boolean => {
24
+ try {
25
+ return Boolean(
26
+ (import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV,
27
+ )
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
33
+ const clamp01 = (n: number): number => Math.min(1, Math.max(0, n))
34
+
35
+ // ─── Pure state helpers ─────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Visibility↔collapse coupling. Hiding snapshots the current collapsed state
39
+ * and collapses; showing restores the snapshot (manual choices persist) and
40
+ * defaults to expanded. Idempotent: returns `null` when `visible` is already
41
+ * the requested value, so a double-hide can't overwrite the real snapshot.
42
+ */
43
+ function visibilityPatch(
44
+ entity: {
45
+ visible: boolean
46
+ collapsed: boolean
47
+ collapsedBeforeHide?: boolean
48
+ },
49
+ visible: boolean,
50
+ ): {
51
+ visible: boolean
52
+ collapsed: boolean
53
+ collapsedBeforeHide: boolean | undefined
54
+ } | null {
55
+ if (entity.visible === visible) return null
56
+ return visible
57
+ ? // Showing: restore the pre-hide collapsed state (default expand).
58
+ {
59
+ visible,
60
+ collapsed: entity.collapsedBeforeHide ?? false,
61
+ collapsedBeforeHide: undefined,
62
+ }
63
+ : // Hiding: snapshot, then collapse.
64
+ {
65
+ visible,
66
+ collapsed: true,
67
+ collapsedBeforeHide: entity.collapsed,
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Manual collapse. While hidden, the snapshot follows the manual choice so
73
+ * the next show keeps it.
74
+ */
75
+ function collapsePatch(
76
+ entity: { visible: boolean },
77
+ collapsed: boolean,
78
+ ): { collapsed: boolean; collapsedBeforeHide?: boolean } {
79
+ return entity.visible
80
+ ? { collapsed }
81
+ : { collapsed, collapsedBeforeHide: collapsed }
82
+ }
83
+
84
+ function updateLayer(
85
+ state: LegendState,
86
+ id: string,
87
+ patch: Partial<LegendLayer>,
88
+ ): Partial<LegendState> {
89
+ const layer = state.layers[id]
90
+ if (!layer) return {}
91
+ return { layers: { ...state.layers, [id]: { ...layer, ...patch } } }
92
+ }
93
+
94
+ function updateGroup(
95
+ state: LegendState,
96
+ id: string,
97
+ patch: Partial<LegendGroup>,
98
+ ): Partial<LegendState> {
99
+ const group = state.groups[id]
100
+ if (!group) return {}
101
+ return { groups: { ...state.groups, [id]: { ...group, ...patch } } }
102
+ }
103
+
104
+ /**
105
+ * Merge incoming config-select sections while preserving the user's picked
106
+ * `active` for any section id that still exists and whose pick is still among
107
+ * the new `options` (the structure — ids, titles, options, order — always
108
+ * follows the props). Returns the previous array when the merge lands on the
109
+ * same content, so unchanged syncs keep entity identity (bail-out).
110
+ */
111
+ function mergeSections(
112
+ prev: LegendConfigSection[] | undefined,
113
+ next: LegendConfigSection[] | undefined,
114
+ ): LegendConfigSection[] | undefined {
115
+ if (!next || !prev || prev === next) return next
116
+ const merged = next.map((section) => {
117
+ const prevSection = prev.find((p) => p.id === section.id)
118
+ return prevSection &&
119
+ prevSection.active !== section.active &&
120
+ section.options.includes(prevSection.active)
121
+ ? { ...section, active: prevSection.active }
122
+ : section
123
+ })
124
+ const sameAsPrev =
125
+ prev.length === merged.length &&
126
+ merged.every((m, i) => {
127
+ const p = prev[i]!
128
+ return (
129
+ p.id === m.id &&
130
+ p.title === m.title &&
131
+ p.options === m.options &&
132
+ p.active === m.active
133
+ )
134
+ })
135
+ return sameAsPrev ? prev : merged
136
+ }
137
+
138
+ /** Shallow equality over the union of both objects' keys (`Object.is` per value). */
139
+ function shallowEqualEntity<T extends object>(a: T, b: T): boolean {
140
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof T>
141
+ for (const key of keys) {
142
+ if (!Object.is(a[key], b[key])) return false
143
+ }
144
+ return true
145
+ }
146
+
147
+ /**
148
+ * Reassign `order` to dense indexes `0..n-1` within each sibling bucket,
149
+ * sorted by current order with ties broken by input position. This is the
150
+ * only boundary where external (possibly duplicated) orders enter, so the
151
+ * stored orders stay dense and unique within each bucket.
152
+ */
153
+ function normalizeOrders<T extends { order: number }>(
154
+ entries: { merged: T; index: number }[],
155
+ bucketOf: (e: T) => string | undefined,
156
+ ): void {
157
+ const buckets = new Map<string | undefined, { merged: T; index: number }[]>()
158
+ for (const entry of entries) {
159
+ const key = bucketOf(entry.merged)
160
+ const bucket = buckets.get(key)
161
+ if (bucket) bucket.push(entry)
162
+ else buckets.set(key, [entry])
163
+ }
164
+ for (const bucket of buckets.values()) {
165
+ bucket
166
+ .sort((a, b) => a.merged.order - b.merged.order || a.index - b.index)
167
+ .forEach((entry, i) => {
168
+ entry.merged.order = i
169
+ })
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Seed/merge one layer: static fields follow the input; interactive state
175
+ * (`visible`/`opacity`/`collapsed`/`order`/`collapsedBeforeHide` + section
176
+ * actives) sticks to the store entry when present. Shared by `_sync` (per
177
+ * entry) and the imperative `setLegendLayer`.
178
+ */
179
+ function mergeLayerEntity(
180
+ prev: LegendLayer | undefined,
181
+ input: LegendLayerInput,
182
+ fallbackOrder: number,
183
+ ): LegendLayer {
184
+ return {
185
+ ...input,
186
+ visible: prev?.visible ?? input.visible ?? true,
187
+ opacity: prev?.opacity ?? input.opacity ?? 1,
188
+ collapsed: prev?.collapsed ?? input.collapsed ?? false,
189
+ collapsedBeforeHide: prev?.collapsedBeforeHide,
190
+ sections: mergeSections(prev?.sections, input.sections),
191
+ order: prev?.order ?? input.order ?? fallbackOrder,
192
+ } as LegendLayer
193
+ }
194
+
195
+ /** Group counterpart of {@link mergeLayerEntity}. */
196
+ function mergeGroupEntity(
197
+ prev: LegendGroup | undefined,
198
+ input: LegendGroupInput,
199
+ fallbackOrder: number,
200
+ ): LegendGroup {
201
+ return {
202
+ ...input,
203
+ collapsed: prev?.collapsed ?? input.collapsed ?? false,
204
+ collapsedBeforeHide: prev?.collapsedBeforeHide,
205
+ order: prev?.order ?? input.order ?? fallbackOrder,
206
+ } as LegendGroup
207
+ }
208
+
209
+ /**
210
+ * Merge incoming definitions while preserving interactive state of known
211
+ * entities: static fields (name, subtitle, variables, attributes, …) follow
212
+ * the props; `visible`/`opacity`/`collapsed`/`order` stick to what's in the
213
+ * store. New entities are seeded; vanished ones are dropped. Sibling orders
214
+ * are normalized to dense indexes.
215
+ *
216
+ * Unchanged entities keep their previous object identity (`variables`
217
+ * compared by reference), and when nothing changed at all the sync is a
218
+ * no-op (`{}`), so stable consumer props never produce a state update.
219
+ */
220
+ function syncState(
221
+ state: LegendState,
222
+ layerInputs: LegendLayerInput[],
223
+ groupInputs: LegendGroupInput[],
224
+ ): Partial<LegendState> {
225
+ const layerEntries = layerInputs.map((input, index) => ({
226
+ index,
227
+ merged: mergeLayerEntity(state.layers[input.id], input, index),
228
+ }))
229
+ normalizeOrders(layerEntries, (l) => l.groupId)
230
+
231
+ const groupEntries = groupInputs.map((input, index) => ({
232
+ index,
233
+ merged: mergeGroupEntity(state.groups[input.id], input, index),
234
+ }))
235
+ normalizeOrders(groupEntries, () => undefined)
236
+
237
+ // Reference preservation: reuse the previous entity object when nothing
238
+ // about it changed, so subscribers see stable identities.
239
+ let layersChanged = layerEntries.length !== Object.keys(state.layers).length
240
+ const layers: Record<string, LegendLayer> = {}
241
+ for (const { merged } of layerEntries) {
242
+ const prev = state.layers[merged.id]
243
+ if (prev && shallowEqualEntity(prev, merged)) {
244
+ layers[merged.id] = prev
245
+ } else {
246
+ layers[merged.id] = merged
247
+ layersChanged = true
248
+ }
249
+ }
250
+
251
+ let groupsChanged = groupEntries.length !== Object.keys(state.groups).length
252
+ const groups: Record<string, LegendGroup> = {}
253
+ for (const { merged } of groupEntries) {
254
+ const prev = state.groups[merged.id]
255
+ if (prev && shallowEqualEntity(prev, merged)) {
256
+ groups[merged.id] = prev
257
+ } else {
258
+ groups[merged.id] = merged
259
+ groupsChanged = true
260
+ }
261
+ }
262
+
263
+ if (!layersChanged && !groupsChanged) return {}
264
+ return {
265
+ layers: layersChanged ? layers : state.layers,
266
+ groups: groupsChanged ? groups : state.groups,
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Rebuild a layer/group record from a candidate map, normalizing dense order
272
+ * per bucket and preserving the previous object identity for entries that are
273
+ * unchanged. Returns `null` when nothing changed (so callers can bail with
274
+ * `{}`).
275
+ */
276
+ function rebuildRecord<
277
+ T extends { id: string; order: number; groupId?: string },
278
+ >(
279
+ prev: Record<string, T>,
280
+ next: Record<string, T>,
281
+ bucketOf: (e: T) => string | undefined,
282
+ ): Record<string, T> | null {
283
+ const entries = Object.values(next).map((e, index) => ({
284
+ index,
285
+ merged: { ...e },
286
+ }))
287
+ normalizeOrders(entries, bucketOf)
288
+ let changed = entries.length !== Object.keys(prev).length
289
+ const out: Record<string, T> = {}
290
+ for (const { merged } of entries) {
291
+ const before = prev[merged.id]
292
+ if (before && shallowEqualEntity(before, merged)) {
293
+ out[merged.id] = before
294
+ } else {
295
+ out[merged.id] = merged
296
+ changed = true
297
+ }
298
+ }
299
+ return changed ? out : null
300
+ }
301
+
302
+ /** Add or update a single layer (imperative `setLegendLayer`). */
303
+ function upsertLayerState(
304
+ state: LegendState,
305
+ input: LegendLayerInput,
306
+ ): Partial<LegendState> {
307
+ const siblingCount = Object.values(state.layers).filter(
308
+ (l) => l.groupId === input.groupId && l.id !== input.id,
309
+ ).length
310
+ const merged = mergeLayerEntity(state.layers[input.id], input, siblingCount)
311
+ const layers = rebuildRecord(
312
+ state.layers,
313
+ { ...state.layers, [input.id]: merged },
314
+ (l) => l.groupId,
315
+ )
316
+ return layers ? { layers } : {}
317
+ }
318
+
319
+ /** Remove a single layer, re-densifying its sibling orders. */
320
+ function removeLayerState(
321
+ state: LegendState,
322
+ id: string,
323
+ ): Partial<LegendState> {
324
+ if (!state.layers[id]) return {}
325
+ const next = { ...state.layers }
326
+ delete next[id]
327
+ const layers = rebuildRecord(state.layers, next, (l) => l.groupId)
328
+ return layers ? { layers } : {}
329
+ }
330
+
331
+ /** Add or update a single group (imperative `setLegendGroup`). */
332
+ function upsertGroupState(
333
+ state: LegendState,
334
+ input: LegendGroupInput,
335
+ ): Partial<LegendState> {
336
+ const count = Object.values(state.groups).filter(
337
+ (g) => g.id !== input.id,
338
+ ).length
339
+ const merged = mergeGroupEntity(state.groups[input.id], input, count)
340
+ const groups = rebuildRecord(
341
+ state.groups,
342
+ { ...state.groups, [input.id]: merged },
343
+ () => undefined,
344
+ )
345
+ return groups ? { groups } : {}
346
+ }
347
+
348
+ /**
349
+ * Remove a group; its member layers are un-grouped (`groupId` cleared) so they
350
+ * survive as ungrouped rows. Re-densifies both group and layer orders.
351
+ */
352
+ function removeGroupState(
353
+ state: LegendState,
354
+ id: string,
355
+ ): Partial<LegendState> {
356
+ if (!state.groups[id]) return {}
357
+ const nextGroups = { ...state.groups }
358
+ delete nextGroups[id]
359
+ const groups = rebuildRecord(state.groups, nextGroups, () => undefined)
360
+
361
+ const members = Object.values(state.layers).filter((l) => l.groupId === id)
362
+ if (members.length === 0) return groups ? { groups } : {}
363
+
364
+ const nextLayers = { ...state.layers }
365
+ for (const member of members) {
366
+ nextLayers[member.id] = { ...member, groupId: undefined }
367
+ }
368
+ const layers = rebuildRecord(state.layers, nextLayers, (l) => l.groupId)
369
+ return {
370
+ ...(groups ? { groups } : {}),
371
+ ...(layers ? { layers } : {}),
372
+ }
373
+ }
374
+
375
+ // ─── Store factory ────────────────────────────────────────────────────────────
376
+
377
+ /**
378
+ * @internal — used by Provider; not part of the public API.
379
+ *
380
+ * @experimental This API is new and may change in a future release.
381
+ */
382
+ export function createLegendStore(
383
+ id: string,
384
+ init: LegendInit = {},
385
+ ): LegendStoreApi {
386
+ // Seed through the same merge/normalize path as `_sync`, so a later sync
387
+ // with identical input is a guaranteed no-op (stable identities).
388
+ const seeded = syncState(
389
+ { layers: {}, groups: {} } as LegendState,
390
+ init.layers ?? [],
391
+ init.groups ?? [],
392
+ )
393
+
394
+ return createStore<LegendState>()(
395
+ devtools(
396
+ (set) => ({
397
+ layers: seeded.layers ?? {},
398
+ groups: seeded.groups ?? {},
399
+
400
+ setVisibility: (lid, visible) =>
401
+ set(
402
+ (s) => {
403
+ const layer = s.layers[lid]
404
+ const patch = layer && visibilityPatch(layer, visible)
405
+ return patch ? updateLayer(s, lid, patch) : {}
406
+ },
407
+ false,
408
+ 'setVisibility',
409
+ ),
410
+ setOpacity: (lid, opacity) =>
411
+ set(
412
+ (s) => updateLayer(s, lid, { opacity: clamp01(opacity) }),
413
+ false,
414
+ 'setOpacity',
415
+ ),
416
+ setCollapsed: (lid, collapsed) =>
417
+ set(
418
+ (s) => {
419
+ const layer = s.layers[lid]
420
+ return layer
421
+ ? updateLayer(s, lid, collapsePatch(layer, collapsed))
422
+ : {}
423
+ },
424
+ false,
425
+ 'setCollapsed',
426
+ ),
427
+ setSectionValue: (lid, sectionId, value) =>
428
+ set(
429
+ (s) => {
430
+ const layer = s.layers[lid]
431
+ const section = layer?.sections?.find((x) => x.id === sectionId)
432
+ if (
433
+ !layer?.sections ||
434
+ !section ||
435
+ section.active === value ||
436
+ !section.options.includes(value)
437
+ ) {
438
+ return {}
439
+ }
440
+ const sections = layer.sections.map((x) =>
441
+ x.id === sectionId ? { ...x, active: value } : x,
442
+ )
443
+ return updateLayer(s, lid, { sections })
444
+ },
445
+ false,
446
+ 'setSectionValue',
447
+ ),
448
+
449
+ setGroupVisibility: (gid, visible) =>
450
+ set(
451
+ (s) => {
452
+ const group = s.groups[gid]
453
+ if (!group) return {}
454
+ const members = Object.values(s.layers).filter(
455
+ (l) => l.groupId === gid,
456
+ )
457
+ const anyVisible = members.some((l) => l.visible)
458
+ // Idempotence on the derived state: hide needs a visible member,
459
+ // show needs a fully-hidden group (mixed acts in both directions
460
+ // except show-from-mixed still shows the hidden remainder).
461
+ if (members.length === 0 || (!visible && !anyVisible)) return {}
462
+
463
+ // Cascade: each member runs the standard visibility↔collapse
464
+ // coupling, so per-layer collapse snapshots survive a group
465
+ // hide/show; members already at the target are untouched.
466
+ const layers = { ...s.layers }
467
+ let layersChanged = false
468
+ for (const member of members) {
469
+ const patch = visibilityPatch(member, visible)
470
+ if (patch) {
471
+ layers[member.id] = { ...member, ...patch }
472
+ layersChanged = true
473
+ }
474
+ }
475
+ if (!layersChanged) return {}
476
+
477
+ // Group-level collapse coupling, only on real derived
478
+ // transitions: any-visible→hidden snapshots + collapses;
479
+ // hidden→visible restores the snapshot.
480
+ let groupPatch: Partial<LegendGroup> | null = null
481
+ if (!visible && anyVisible) {
482
+ groupPatch = {
483
+ collapsed: true,
484
+ collapsedBeforeHide: group.collapsed,
485
+ }
486
+ } else if (visible && !anyVisible) {
487
+ groupPatch = {
488
+ collapsed: group.collapsedBeforeHide ?? false,
489
+ collapsedBeforeHide: undefined,
490
+ }
491
+ }
492
+ return {
493
+ layers,
494
+ ...(groupPatch
495
+ ? {
496
+ groups: {
497
+ ...s.groups,
498
+ [gid]: { ...group, ...groupPatch },
499
+ },
500
+ }
501
+ : {}),
502
+ }
503
+ },
504
+ false,
505
+ 'setGroupVisibility',
506
+ ),
507
+ setGroupCollapsed: (gid, collapsed) =>
508
+ set(
509
+ (s) => {
510
+ const group = s.groups[gid]
511
+ if (!group) return {}
512
+ // Group visibility is derived from members; while the group is
513
+ // hidden the snapshot follows the manual choice (layer parity).
514
+ const members = Object.values(s.layers).filter(
515
+ (l) => l.groupId === gid,
516
+ )
517
+ const hidden =
518
+ members.length > 0 && members.every((l) => !l.visible)
519
+ return updateGroup(
520
+ s,
521
+ gid,
522
+ hidden
523
+ ? { collapsed, collapsedBeforeHide: collapsed }
524
+ : { collapsed },
525
+ )
526
+ },
527
+ false,
528
+ 'setGroupCollapsed',
529
+ ),
530
+
531
+ setLegendLayer: (input) =>
532
+ set((s) => upsertLayerState(s, input), false, 'setLegendLayer'),
533
+ removeLegendLayer: (lid) =>
534
+ set((s) => removeLayerState(s, lid), false, 'removeLegendLayer'),
535
+ setLegendGroup: (input) =>
536
+ set((s) => upsertGroupState(s, input), false, 'setLegendGroup'),
537
+ removeLegendGroup: (gid) =>
538
+ set((s) => removeGroupState(s, gid), false, 'removeLegendGroup'),
539
+
540
+ _sync: (layerInputs, groupInputs) =>
541
+ set((s) => syncState(s, layerInputs, groupInputs), false, '_sync'),
542
+ }),
543
+ { name: `legend-${id}`, enabled: isDev() },
544
+ ),
545
+ )
546
+ }
547
+
548
+ // ─── Registry ───────────────────────────────────────────────────────────────
549
+
550
+ /** @experimental This API is new and may change in a future release. */
551
+ export function getLegendStore(id: string): LegendStoreApi {
552
+ const store = legendStores.get(id)
553
+ if (!store) throw new Error(`[legend] Legend store "${id}" not found.`)
554
+ return store
555
+ }
556
+
557
+ /** @experimental This API is new and may change in a future release. */
558
+ export function hasLegendStore(id: string): boolean {
559
+ return legendStores.has(id)
560
+ }
561
+
562
+ /** @experimental This API is new and may change in a future release. */
563
+ export function deleteLegendStore(id: string): void {
564
+ legendStores.delete(id)
565
+ }
566
+
567
+ /** @experimental This API is new and may change in a future release. */
568
+ export function clearAllLegendStores(): void {
569
+ legendStores.clear()
570
+ legendMountCounts.clear()
571
+ warnedDuplicates.clear()
572
+ }
573
+
574
+ /**
575
+ * @internal — used by Provider's lazy init so children can resolve the store first render.
576
+ *
577
+ * @experimental This API is new and may change in a future release.
578
+ */
579
+ export function setLegendStoreEntry(id: string, store: LegendStoreApi): void {
580
+ legendStores.set(id, store)
581
+ }
582
+
583
+ /**
584
+ * @internal — registers a Provider mount and warns on duplicate ids in dev.
585
+ *
586
+ * @experimental This API is new and may change in a future release.
587
+ */
588
+ export function registerLegendStore(id: string, store: LegendStoreApi): void {
589
+ legendStores.set(id, store)
590
+ const count = (legendMountCounts.get(id) ?? 0) + 1
591
+ legendMountCounts.set(id, count)
592
+ if (count > 1 && isDev() && !warnedDuplicates.has(id)) {
593
+ // eslint-disable-next-line no-console
594
+ console.warn(
595
+ `[legend] Duplicate <Legend.Provider id="${id}"> detected. ` +
596
+ `Multiple providers sharing an id will race on prop sync. ` +
597
+ `Use unique ids per legend panel.`,
598
+ )
599
+ warnedDuplicates.add(id)
600
+ }
601
+ }
602
+
603
+ /**
604
+ * @internal — Provider unmount counterpart; removes the entry on last unmount.
605
+ *
606
+ * @experimental This API is new and may change in a future release.
607
+ */
608
+ export function unregisterLegendStore(
609
+ id: string,
610
+ options: { keepAlive: boolean },
611
+ ): void {
612
+ const count = (legendMountCounts.get(id) ?? 1) - 1
613
+ if (count <= 0) {
614
+ legendMountCounts.delete(id)
615
+ if (!options.keepAlive) legendStores.delete(id)
616
+ } else {
617
+ legendMountCounts.set(id, count)
618
+ }
619
+ }
620
+
621
+ // ─── Hooks ────────────────────────────────────────────────────────────────────
622
+
623
+ /** @experimental This API is new and may change in a future release. */
624
+ export function useLegendStore<T>(
625
+ id: string,
626
+ selector: (state: LegendState) => T,
627
+ ): T {
628
+ const store = getLegendStore(id) as unknown as StoreApi<LegendState>
629
+ return useStore(store, selector)
630
+ }
631
+
632
+ /** @experimental This API is new and may change in a future release. */
633
+ export function useLegendShallow<T>(
634
+ id: string,
635
+ selector: (state: LegendState) => T,
636
+ ): T {
637
+ const store = getLegendStore(id) as unknown as StoreApi<LegendState>
638
+ return useStore(store, useShallow(selector))
639
+ }