@sqlrooms/kepler 0.29.0-rc.0 → 0.29.0-rc.10

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 (90) hide show
  1. package/LICENSE.md +2 -1
  2. package/README.md +133 -6
  3. package/dist/KeplerSlice.d.ts +85 -11
  4. package/dist/KeplerSlice.d.ts.map +1 -1
  5. package/dist/KeplerSlice.js +391 -123
  6. package/dist/KeplerSlice.js.map +1 -1
  7. package/dist/components/CustomDndContext.d.ts.map +1 -1
  8. package/dist/components/CustomDndContext.js +5 -2
  9. package/dist/components/CustomDndContext.js.map +1 -1
  10. package/dist/components/CustomFilterManager.d.ts.map +1 -1
  11. package/dist/components/CustomFilterManager.js +20 -5
  12. package/dist/components/CustomFilterManager.js.map +1 -1
  13. package/dist/components/CustomFilterPanelHeader.d.ts.map +1 -1
  14. package/dist/components/CustomFilterPanelHeader.js +5 -5
  15. package/dist/components/CustomFilterPanelHeader.js.map +1 -1
  16. package/dist/components/CustomInteractionManager.d.ts.map +1 -1
  17. package/dist/components/CustomInteractionManager.js +4 -1
  18. package/dist/components/CustomInteractionManager.js.map +1 -1
  19. package/dist/components/CustomLayerManager.d.ts.map +1 -1
  20. package/dist/components/CustomLayerManager.js +51 -17
  21. package/dist/components/CustomLayerManager.js.map +1 -1
  22. package/dist/components/CustomMapDrawPanel.d.ts +8 -0
  23. package/dist/components/CustomMapDrawPanel.d.ts.map +1 -0
  24. package/dist/components/CustomMapDrawPanel.js +28 -0
  25. package/dist/components/CustomMapDrawPanel.js.map +1 -0
  26. package/dist/components/CustomMapLegend.d.ts +4 -1
  27. package/dist/components/CustomMapLegend.d.ts.map +1 -1
  28. package/dist/components/CustomMapLegend.js +39 -12
  29. package/dist/components/CustomMapLegend.js.map +1 -1
  30. package/dist/components/CustomMapLegendPanel.d.ts +3 -3
  31. package/dist/components/CustomMapLegendPanel.d.ts.map +1 -1
  32. package/dist/components/CustomMapLegendPanel.js +27 -4
  33. package/dist/components/CustomMapLegendPanel.js.map +1 -1
  34. package/dist/components/CustomSourceDataSelector.d.ts +18 -0
  35. package/dist/components/CustomSourceDataSelector.d.ts.map +1 -0
  36. package/dist/components/CustomSourceDataSelector.js +130 -0
  37. package/dist/components/CustomSourceDataSelector.js.map +1 -0
  38. package/dist/components/KeplerAddDataDialog.d.ts +2 -3
  39. package/dist/components/KeplerAddDataDialog.d.ts.map +1 -1
  40. package/dist/components/KeplerAddDataDialog.js +19 -8
  41. package/dist/components/KeplerAddDataDialog.js.map +1 -1
  42. package/dist/components/KeplerImageExport.d.ts.map +1 -1
  43. package/dist/components/KeplerImageExport.js +26 -8
  44. package/dist/components/KeplerImageExport.js.map +1 -1
  45. package/dist/components/KeplerInjector.d.ts.map +1 -1
  46. package/dist/components/KeplerInjector.js +5 -1
  47. package/dist/components/KeplerInjector.js.map +1 -1
  48. package/dist/components/KeplerMapContainer.d.ts.map +1 -1
  49. package/dist/components/KeplerMapContainer.js +45 -6
  50. package/dist/components/KeplerMapContainer.js.map +1 -1
  51. package/dist/components/KeplerPlotContainer.d.ts +1 -1
  52. package/dist/components/KeplerPlotContainer.d.ts.map +1 -1
  53. package/dist/components/KeplerPlotContainer.js +3 -1
  54. package/dist/components/KeplerPlotContainer.js.map +1 -1
  55. package/dist/components/KeplerProvider.d.ts.map +1 -1
  56. package/dist/components/KeplerProvider.js +3 -1
  57. package/dist/components/KeplerProvider.js.map +1 -1
  58. package/dist/components/KeplerTableSourceSelector.d.ts +20 -0
  59. package/dist/components/KeplerTableSourceSelector.d.ts.map +1 -0
  60. package/dist/components/KeplerTableSourceSelector.js +21 -0
  61. package/dist/components/KeplerTableSourceSelector.js.map +1 -0
  62. package/dist/components/SplitMapIndexContext.d.ts +2 -0
  63. package/dist/components/SplitMapIndexContext.d.ts.map +1 -0
  64. package/dist/components/SplitMapIndexContext.js +3 -0
  65. package/dist/components/SplitMapIndexContext.js.map +1 -0
  66. package/dist/hooks/useDndEffects.d.ts +1 -1
  67. package/dist/hooks/useDndEffects.d.ts.map +1 -1
  68. package/dist/hooks/useDndEffects.js +4 -0
  69. package/dist/hooks/useDndEffects.js.map +1 -1
  70. package/dist/hooks/useDndLayers.d.ts +1 -1
  71. package/dist/hooks/useDndLayers.d.ts.map +1 -1
  72. package/dist/hooks/useDndLayers.js +4 -0
  73. package/dist/hooks/useDndLayers.js.map +1 -1
  74. package/dist/hooks/useKeplerStateActions.d.ts +7 -6
  75. package/dist/hooks/useKeplerStateActions.d.ts.map +1 -1
  76. package/dist/hooks/useKeplerStateActions.js +1 -1
  77. package/dist/hooks/useKeplerStateActions.js.map +1 -1
  78. package/dist/index.d.ts +10 -3
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +5 -2
  81. package/dist/index.js.map +1 -1
  82. package/dist/keplerTableSelection.d.ts +142 -0
  83. package/dist/keplerTableSelection.d.ts.map +1 -0
  84. package/dist/keplerTableSelection.js +232 -0
  85. package/dist/keplerTableSelection.js.map +1 -0
  86. package/dist/styles/theme.d.ts +12 -0
  87. package/dist/styles/theme.d.ts.map +1 -1
  88. package/dist/styles/theme.js +12 -0
  89. package/dist/styles/theme.js.map +1 -1
  90. package/package.json +43 -37
@@ -1,4 +1,4 @@
1
- import { addDataToMap, addLayer as addLayerAction, deleteEntry, ActionTypes as KeplerActionTypes, registerEntry, removeDataset, requestMapStyles, wrapTo, } from '@kepler.gl/actions';
1
+ import { addDataToMap, addFilter as addFilterAction, addLayer as addLayerAction, deleteEntry, interactionConfigChange, ActionTypes as KeplerActionTypes, registerEntry, removeDataset, requestMapStyles, setFilter as setFilterAction, setFilterAnimationTime as setFilterAnimationTimeAction, setFilterAnimationWindow as setFilterAnimationWindowAction, setFilterView as setFilterViewAction, toggleLayerForMap as toggleLayerForMapAction, toggleSplitMap as toggleSplitMapAction, wrapTo, } from '@kepler.gl/actions';
2
2
  import { ALL_FIELD_TYPES } from '@kepler.gl/constants';
3
3
  import { castDuckDBTypesForKepler, getDuckDBColumnTypes, getDuckDBColumnTypesMap, restoreGeoarrowMetadata, setGeoArrowWKBExtension, } from '@kepler.gl/duckdb';
4
4
  import { arrowSchemaToFields } from '@kepler.gl/processors';
@@ -7,11 +7,15 @@ import { KeplerGLSchemaClass } from '@kepler.gl/schemas';
7
7
  import { KeplerTable } from '@kepler.gl/table';
8
8
  import { initApplicationConfig, } from '@kepler.gl/utils';
9
9
  import { createId } from '@paralleldrive/cuid2';
10
+ import { KeplerSliceConfig } from '@sqlrooms/kepler-config';
10
11
  import { createSlice, useBaseRoomShellStore, } from '@sqlrooms/room-shell';
12
+ import { getTheme } from '@sqlrooms/ui';
11
13
  import { produce, setAutoFreeze } from 'immer';
12
14
  import { taskMiddleware } from 'react-palm/tasks';
13
15
  import { compose } from 'redux';
14
16
  import { createLogger } from 'redux-logger';
17
+ import { getTableIdentity, getUnqualifiedSqlIdentifier, } from '@sqlrooms/duckdb-core';
18
+ import { findKeplerTableForDatasetId, getKeplerDatasetIdForTable, getKeplerTableLabel, } from './keplerTableSelection';
15
19
  setAutoFreeze(false); // Kepler attempts to mutate redux state, so we need to disable immer's auto freeze to avoid errors
16
20
  const KeplerGLSchemaManager = new KeplerGLSchemaClass();
17
21
  class DesktopKeplerTable extends KeplerTable {
@@ -20,9 +24,40 @@ class DesktopKeplerTable extends KeplerTable {
20
24
  return (d) => d;
21
25
  };
22
26
  }
27
+ function createDefaultMapKeplerState(resolvedTheme) {
28
+ return {
29
+ mapStyle: {
30
+ styleType: resolvedTheme === 'dark' ? 'dark-matter' : 'positron',
31
+ },
32
+ uiState: {
33
+ ...INITIAL_UI_STATE,
34
+ activeSidePanel: null,
35
+ currentModal: null,
36
+ mapControls: {
37
+ visibleLayers: INITIAL_UI_STATE.mapControls.visibleLayers,
38
+ splitMap: {
39
+ show: true,
40
+ active: false,
41
+ },
42
+ mapLegend: {
43
+ show: true,
44
+ active: false,
45
+ },
46
+ toggle3d: {
47
+ show: true,
48
+ active: false,
49
+ },
50
+ mapDraw: {
51
+ show: true,
52
+ active: false,
53
+ },
54
+ },
55
+ },
56
+ };
57
+ }
23
58
  export function createDefaultKeplerConfig(props) {
24
59
  const mapId = createId();
25
- return {
60
+ const config = {
26
61
  maps: [
27
62
  {
28
63
  id: mapId,
@@ -31,10 +66,9 @@ export function createDefaultKeplerConfig(props) {
31
66
  lastOpenedAt: Date.now(),
32
67
  },
33
68
  ],
34
- currentMapId: mapId,
35
- openTabs: [mapId],
36
69
  ...props,
37
70
  };
71
+ return KeplerSliceConfig.parse(config);
38
72
  }
39
73
  export function hasMapId(action) {
40
74
  return (typeof action.payload === 'object' &&
@@ -44,6 +78,25 @@ export function hasMapId(action) {
44
78
  action.payload.meta !== null &&
45
79
  '_id_' in action.payload.meta);
46
80
  }
81
+ function normalizeAddTableToMapParams(paramsOrMapId, tableName, options = {}, config = {}, loadOptions = {}) {
82
+ if (typeof paramsOrMapId === 'object') {
83
+ return {
84
+ options: {},
85
+ config: {},
86
+ ...paramsOrMapId,
87
+ };
88
+ }
89
+ if (!tableName) {
90
+ throw new Error('addTableToMap requires a tableName.');
91
+ }
92
+ return {
93
+ mapId: paramsOrMapId,
94
+ tableName,
95
+ options,
96
+ config,
97
+ datasetId: loadOptions.datasetId,
98
+ };
99
+ }
47
100
  // Auto save will be triggered in middleware on every kepler action
48
101
  // skip these actions to avoid unnecessary save
49
102
  const SKIP_AUTO_SAVE_ACTIONS = [
@@ -51,37 +104,60 @@ const SKIP_AUTO_SAVE_ACTIONS = [
51
104
  KeplerActionTypes.UPDATE_MAP,
52
105
  KeplerActionTypes.MOUSE_MOVE,
53
106
  ];
54
- export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfigProps, initialKeplerState = {
55
- mapStyle: { styleType: 'positron' },
56
- uiState: {
57
- ...INITIAL_UI_STATE,
58
- currentModal: null,
59
- mapControls: {
60
- visibleLayers: INITIAL_UI_STATE.mapControls.visibleLayers,
61
- mapLegend: {
62
- show: true,
63
- active: false,
64
- },
65
- toggle3d: {
66
- show: true,
67
- active: false,
68
- },
69
- },
70
- },
71
- }, actionLogging = false, middlewares: additionalMiddlewares = [], applicationConfig, onAction, } = {}) {
107
+ export function createKeplerSlice({ basicKeplerProps = {}, keplerTheme, modalPortalTarget = 'container', config: initialConfigProps, createInitialMapKeplerState, actionLogging = false, middlewares: additionalMiddlewares = [], applicationConfig, tableSelection = {}, onAction, } = {}) {
72
108
  const initialConfig = createDefaultKeplerConfig(initialConfigProps);
73
109
  initApplicationConfig({
74
110
  table: DesktopKeplerTable,
75
111
  ...applicationConfig,
76
112
  });
113
+ let syncKeplerPromise = null;
114
+ let configRestoreDepth = 0;
115
+ const configRestorePromises = new Set();
116
+ // When a caller arrives while a sync is already in-flight, the in-flight run
117
+ // may have captured a stale snapshot of db.tables/kepler.map (missing maps or
118
+ // tables added by createMap, duplicateMap, etc.). Setting this flag tells the
119
+ // active run to loop once more with a fresh snapshot after it finishes, so
120
+ // late mutations are never silently dropped.
121
+ let pendingKeplerSync = false;
77
122
  const updateMapLastOpenedAt = (maps, mapId, now = Date.now()) => {
78
123
  const map = maps.find((item) => item.id === mapId);
79
124
  if (map) {
80
125
  map.lastOpenedAt = now;
81
126
  }
82
127
  };
83
- return createSlice((set, get) => {
84
- const keplerReducer = keplerGlReducer.initialState(initialKeplerState);
128
+ return createSlice((set, get, _store) => {
129
+ function getDefaultDbSchema() {
130
+ const currentDatabase = get().db.currentDatabase;
131
+ const currentSchema = get().db.currentSchema;
132
+ if (!currentDatabase || !currentSchema) {
133
+ return undefined;
134
+ }
135
+ return {
136
+ database: currentDatabase,
137
+ schema: currentSchema,
138
+ };
139
+ }
140
+ const resolvedTableSelection = {
141
+ ...tableSelection,
142
+ };
143
+ if (!resolvedTableSelection.defaultDbSchema) {
144
+ resolvedTableSelection.defaultDbSchema = getDefaultDbSchema;
145
+ }
146
+ function resolveInitialMapKeplerState(context) {
147
+ const resolvedTheme = getTheme();
148
+ const defaultInitialMapKeplerState = createDefaultMapKeplerState(resolvedTheme);
149
+ return (createInitialMapKeplerState?.({
150
+ ...context,
151
+ defaultInitialMapKeplerState,
152
+ }) ?? defaultInitialMapKeplerState);
153
+ }
154
+ function createKeplerReducer(context) {
155
+ return keplerGlReducer.initialState(resolveInitialMapKeplerState(context));
156
+ }
157
+ function registerMapEntry(id) {
158
+ return registerEntry({ id, ...basicKeplerProps });
159
+ }
160
+ const keplerReducer = createKeplerReducer({ reason: 'initialize' });
85
161
  const middlewares = [
86
162
  taskMiddleware,
87
163
  saveKeplerConfigMiddleware,
@@ -111,23 +187,37 @@ export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfig
111
187
  kepler: {
112
188
  config: initialConfig,
113
189
  basicKeplerProps,
190
+ keplerTheme,
191
+ modalPortalTarget,
192
+ tableSelection: resolvedTableSelection,
114
193
  map: {},
194
+ isRestoringConfig: false,
115
195
  dispatchAction: () => { },
116
196
  __reduxProviderStore: undefined,
117
197
  forwardDispatch: {},
118
198
  setConfig: (config) => {
119
- set((state) => produce(state, (draft) => {
120
- draft.kepler.config = config;
121
- }));
122
- updateMapReduxStates();
123
- updateForwardDispatch();
124
- updateMapConfigs();
125
- get().kepler.syncKeplerDatasets();
199
+ void get()
200
+ .kepler.withConfigPersistencePaused(async () => {
201
+ const nextConfig = KeplerSliceConfig.parse(config);
202
+ set((state) => produce(state, (draft) => {
203
+ draft.kepler.config = nextConfig;
204
+ }));
205
+ updateMapReduxStates();
206
+ updateForwardDispatch();
207
+ updateMapConfigs();
208
+ await get().kepler.syncKeplerDatasets();
209
+ })
210
+ .catch((error) => {
211
+ console.error('setConfig: failed to restore Kepler config', error);
212
+ });
126
213
  },
127
214
  async initialize() {
128
215
  const config = get().kepler.config;
129
- const currentMapId = config.currentMapId;
130
- const keplerInitialState = keplerReducer(undefined, registerEntry({ id: currentMapId }));
216
+ const keplerInitialState = config.maps.reduce((mapState, map) => createKeplerReducer({
217
+ reason: 'initialize',
218
+ mapId: map.id,
219
+ name: map.name,
220
+ })(mapState, registerMapEntry(map.id)), {});
131
221
  set({
132
222
  kepler: {
133
223
  ...get().kepler,
@@ -150,24 +240,101 @@ export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfig
150
240
  // @ts-expect-error - Symbol.observable is not defined in the Redux type definitions
151
241
  [Symbol.observable]: () => { },
152
242
  },
243
+ isRestoringConfig: get().kepler.isRestoringConfig,
153
244
  },
154
245
  });
155
246
  updateForwardDispatch();
156
- updateMapConfigs();
157
247
  await get().kepler.syncKeplerDatasets();
158
- requestMapStyle(config.currentMapId);
248
+ updateMapConfigs();
249
+ for (const mapId of Object.keys(get().kepler.map)) {
250
+ requestMapStyle(mapId);
251
+ }
252
+ },
253
+ async destroy() {
254
+ // no-op
159
255
  },
160
256
  addLayer: (mapId, layer, datasetId) => {
161
257
  get().kepler.registerKeplerMapIfNotExists(mapId);
162
258
  get().kepler.dispatchAction(mapId, addLayerAction(layer, datasetId));
163
259
  },
164
- addTableToMap: async (mapId, tableName, options = {}) => {
260
+ addFilter: (mapId, datasetId) => {
261
+ get().kepler.registerKeplerMapIfNotExists(mapId);
262
+ get().kepler.dispatchAction(mapId, addFilterAction(datasetId));
263
+ },
264
+ setFilter: (mapId, filterIdx, prop, value, valueIndex) => {
265
+ get().kepler.registerKeplerMapIfNotExists(mapId);
266
+ get().kepler.dispatchAction(mapId, setFilterAction(filterIdx, prop, value, valueIndex));
267
+ },
268
+ setFilterAnimationTime: (mapId, filterIdx, prop, value) => {
269
+ get().kepler.registerKeplerMapIfNotExists(mapId);
270
+ get().kepler.dispatchAction(mapId, setFilterAnimationTimeAction(filterIdx, prop, value));
271
+ },
272
+ setFilterAnimationWindow: (mapId, filterId, animationWindow) => {
273
+ get().kepler.registerKeplerMapIfNotExists(mapId);
274
+ get().kepler.dispatchAction(mapId, setFilterAnimationWindowAction({ id: filterId, animationWindow }));
275
+ },
276
+ setFilterView: (mapId, filterIdx, view) => {
277
+ get().kepler.registerKeplerMapIfNotExists(mapId);
278
+ get().kepler.dispatchAction(mapId, setFilterViewAction(filterIdx, view));
279
+ },
280
+ updateTooltipFields: (mapId, datasetId, fieldNames) => {
281
+ get().kepler.registerKeplerMapIfNotExists(mapId);
282
+ const mapState = get().kepler.map[mapId];
283
+ const tooltipConfig = mapState?.visState?.interactionConfig?.tooltip;
284
+ if (!tooltipConfig)
285
+ return;
286
+ const currentFieldsToShow = tooltipConfig.config?.fieldsToShow || {};
287
+ const existingFields = currentFieldsToShow[datasetId] || [];
288
+ const existingByName = new Map(existingFields.map((field) => [
289
+ field.name,
290
+ field,
291
+ ]));
292
+ const nextFields = Array.from(new Set(fieldNames)).map((name) => existingByName.get(name) ?? { name, format: null });
293
+ if (nextFields.length === existingFields.length &&
294
+ nextFields.every((field, index) => field.name === existingFields[index]?.name &&
295
+ field.format === existingFields[index]?.format)) {
296
+ return;
297
+ }
298
+ get().kepler.dispatchAction(mapId, interactionConfigChange({
299
+ ...tooltipConfig,
300
+ config: {
301
+ ...tooltipConfig.config,
302
+ fieldsToShow: {
303
+ ...currentFieldsToShow,
304
+ [datasetId]: nextFields,
305
+ },
306
+ },
307
+ }));
308
+ },
309
+ toggleSplitMap: (mapId, index) => {
310
+ get().kepler.registerKeplerMapIfNotExists(mapId);
311
+ get().kepler.dispatchAction(mapId, toggleSplitMapAction(index));
312
+ },
313
+ toggleLayerForMap: (mapId, mapIndex, layerId) => {
314
+ get().kepler.registerKeplerMapIfNotExists(mapId);
315
+ get().kepler.dispatchAction(mapId, toggleLayerForMapAction(mapIndex, layerId));
316
+ },
317
+ addTableToMap: (async (paramsOrMapId, legacyTableName, legacyOptions, legacyConfig, legacyLoadOptions) => {
318
+ const { mapId, tableName, options, config, datasetId: explicitDatasetId, } = normalizeAddTableToMapParams(paramsOrMapId, legacyTableName, legacyOptions, legacyConfig, legacyLoadOptions);
319
+ const tableSelection = get().kepler.tableSelection;
320
+ const table = findKeplerTableForDatasetId(get().db.tables, tableName, tableSelection);
321
+ const sqlTableName = table
322
+ ? getTableIdentity(table.table)
323
+ : tableName;
324
+ let datasetId = explicitDatasetId;
325
+ if (!datasetId && table) {
326
+ datasetId = getKeplerDatasetIdForTable(table, tableSelection);
327
+ }
328
+ if (!datasetId) {
329
+ datasetId =
330
+ getUnqualifiedSqlIdentifier(String(sqlTableName)) ?? tableName;
331
+ }
165
332
  const connector = await get().db.getConnector();
166
333
  const duckDbColumns = await getDuckDBColumnTypes({
167
334
  query: (query) => connector.query(query).result,
168
- }, tableName);
335
+ }, sqlTableName);
169
336
  const tableDuckDBTypes = getDuckDBColumnTypesMap(duckDbColumns);
170
- const adjustedQuery = castDuckDBTypesForKepler(tableName, duckDbColumns);
337
+ const adjustedQuery = castDuckDBTypesForKepler(sqlTableName, duckDbColumns);
171
338
  const arrowResult = await connector.query(adjustedQuery).result;
172
339
  setGeoArrowWKBExtension(arrowResult, duckDbColumns);
173
340
  // TODO remove once DuckDB doesn't drop geoarrow metadata
@@ -175,98 +342,169 @@ export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfig
175
342
  const fields = arrowSchemaToFields(arrowResult, tableDuckDBTypes);
176
343
  const cols = Array.from({ length: arrowResult.numCols }, (_, i) => arrowResult.getChildAt(i)).filter((col) => col);
177
344
  if (fields && cols) {
345
+ let label = tableName;
346
+ if (table) {
347
+ label = getKeplerTableLabel(table, tableSelection);
348
+ }
349
+ else {
350
+ label =
351
+ getUnqualifiedSqlIdentifier(String(sqlTableName)) ?? tableName;
352
+ }
178
353
  const datasets = {
179
354
  data: { fields, cols, rows: [], arrowTable: arrowResult },
180
- info: { label: tableName, id: tableName },
181
- metadata: { tableName },
355
+ info: { label, id: datasetId },
356
+ metadata: { tableName: sqlTableName },
182
357
  };
183
- get().kepler.dispatchAction(mapId, addDataToMap({ datasets, options }));
358
+ get().kepler.dispatchAction(mapId, addDataToMap({ datasets, options, config }));
184
359
  }
185
- },
186
- getCurrentMap: () => {
187
- return get().kepler.config.maps.find((map) => map.id === get().kepler.config.currentMapId);
188
- },
189
- setCurrentMapId: (mapId) => {
190
- return set((state) => produce(state, (draft) => {
191
- const now = Date.now();
192
- draft.kepler.config.currentMapId = mapId;
193
- updateMapLastOpenedAt(draft.kepler.config.maps, mapId, now);
194
- }));
195
- },
196
- createMap: (name) => {
197
- const mapId = createId();
360
+ }),
361
+ ensureMap: (mapId, name) => {
198
362
  const now = Date.now();
199
363
  set((state) => produce(state, (draft) => {
364
+ const existing = draft.kepler.config.maps.find((map) => map.id === mapId);
365
+ if (existing) {
366
+ if (name && existing.name !== name) {
367
+ existing.name = name;
368
+ }
369
+ return;
370
+ }
200
371
  draft.kepler.config.maps.push({
201
372
  id: mapId,
202
373
  name: name ?? 'Untitled Map',
203
374
  lastOpenedAt: now,
204
375
  });
205
- draft.kepler.config.openTabs.push(mapId);
206
- draft.kepler.map = keplerReducer(draft.kepler.map, registerEntry({ id: mapId }));
376
+ draft.kepler.map = createKeplerReducer({
377
+ reason: 'create-map',
378
+ mapId,
379
+ name,
380
+ })(draft.kepler.map, registerMapEntry(mapId));
207
381
  draft.kepler.forwardDispatch[mapId] = getForwardDispatch(mapId);
208
382
  }));
383
+ if (!get().kepler.map[mapId]) {
384
+ get().kepler.registerKeplerMapIfNotExists(mapId);
385
+ }
209
386
  requestMapStyle(mapId);
210
387
  get().kepler.syncKeplerDatasets();
388
+ },
389
+ createMap: (name, options) => {
390
+ const mapId = options?.id ?? createId();
391
+ get().kepler.ensureMap(mapId, name);
211
392
  return mapId;
212
393
  },
213
394
  async syncKeplerDatasets() {
214
- for (const mapId of Object.keys(get().kepler.map)) {
215
- const keplerDatasets = get().kepler.map[mapId]?.visState.datasets;
216
- for (const { table } of get().db.tables) {
217
- // TODO: remove this once getDuckDBColumnTypesMap can handle qualified table names
218
- // const qualifiedTable = table.toString();
219
- if (
220
- // !table.schema?.startsWith('__') && // skip internal schemas
221
- // !keplerDatasets?.[qualifiedTable]
222
- table.schema === 'main' &&
223
- !keplerDatasets?.[table.table]) {
224
- await get().kepler.addTableToMap(mapId, table.table, {
225
- autoCreateLayers: false,
226
- centerMap: false,
227
- });
395
+ if (syncKeplerPromise) {
396
+ pendingKeplerSync = true;
397
+ return syncKeplerPromise;
398
+ }
399
+ syncKeplerPromise = (async () => {
400
+ // Loop until no new sync was requested during the current pass.
401
+ // Each iteration reads a fresh snapshot of db.tables and kepler.map,
402
+ // so tables/maps added concurrently are picked up on the next pass.
403
+ do {
404
+ pendingKeplerSync = false;
405
+ for (const mapId of Object.keys(get().kepler.map)) {
406
+ const mapState = get().kepler.map[mapId];
407
+ if (!mapState)
408
+ continue;
409
+ const keplerDatasets = mapState.visState.datasets;
410
+ // Only sync tables that are referenced by existing layers or filters
411
+ const referencedDataIds = new Set();
412
+ for (const layer of mapState.visState.layers ?? []) {
413
+ if (layer.config.dataId) {
414
+ referencedDataIds.add(layer.config.dataId);
415
+ }
416
+ }
417
+ for (const filter of mapState.visState.filters ?? []) {
418
+ for (const dataId of filter.dataId ?? []) {
419
+ referencedDataIds.add(dataId);
420
+ }
421
+ }
422
+ const availableTables = get().db.tables;
423
+ for (const dataId of referencedDataIds) {
424
+ if (keplerDatasets?.[dataId]) {
425
+ continue;
426
+ }
427
+ const table = findKeplerTableForDatasetId(availableTables, dataId, get().kepler.tableSelection);
428
+ if (!table) {
429
+ continue;
430
+ }
431
+ try {
432
+ await get().kepler.addTableToMap({
433
+ mapId,
434
+ tableName: dataId,
435
+ options: {
436
+ autoCreateLayers: false,
437
+ centerMap: false,
438
+ },
439
+ datasetId: dataId,
440
+ });
441
+ }
442
+ catch (e) {
443
+ console.error('syncKeplerDatasets: addTableToMap failed', {
444
+ dataId,
445
+ e,
446
+ });
447
+ }
448
+ }
228
449
  }
229
- }
450
+ } while (pendingKeplerSync);
451
+ })();
452
+ try {
453
+ await syncKeplerPromise;
454
+ }
455
+ finally {
456
+ syncKeplerPromise = null;
230
457
  }
231
458
  },
232
459
  deleteMap: (mapId) => {
233
460
  set((state) => produce(state, (draft) => {
234
- const openTabs = draft.kepler.config.openTabs;
235
461
  const maps = draft.kepler.config.maps;
236
- const wasCurrentMap = draft.kepler.config.currentMapId === mapId;
237
- const deletingIndex = openTabs.indexOf(mapId);
238
- // Remove from maps and openTabs
239
462
  draft.kepler.config.maps = maps.filter((map) => map.id !== mapId);
240
- draft.kepler.config.openTabs = openTabs.filter((id) => id !== mapId);
241
- // If we deleted the current map, select another one
242
- if (wasCurrentMap) {
243
- const newOpenTabs = draft.kepler.config.openTabs;
244
- const remainingMaps = draft.kepler.config.maps;
245
- if (newOpenTabs.length > 0) {
246
- // Select from remaining open tabs
247
- const newIndex = deletingIndex === 0
248
- ? 0
249
- : Math.min(deletingIndex - 1, newOpenTabs.length - 1);
250
- const newSelectedId = newOpenTabs[newIndex];
251
- if (newSelectedId) {
252
- draft.kepler.config.currentMapId = newSelectedId;
253
- updateMapLastOpenedAt(draft.kepler.config.maps, newSelectedId, Date.now());
254
- }
255
- }
256
- else if (remainingMaps.length > 0) {
257
- // No open tabs left, open a closed map
258
- const mapToOpen = remainingMaps[0];
259
- if (mapToOpen) {
260
- draft.kepler.config.openTabs.push(mapToOpen.id);
261
- draft.kepler.config.currentMapId = mapToOpen.id;
262
- updateMapLastOpenedAt(draft.kepler.config.maps, mapToOpen.id, Date.now());
263
- }
264
- }
265
- }
266
463
  draft.kepler.map = keplerReducer(draft.kepler.map, deleteEntry(mapId));
267
464
  delete draft.kepler.forwardDispatch[mapId];
268
465
  }));
269
466
  },
467
+ duplicateMap: async (mapId) => {
468
+ // Ensure the map's redux state is registered, consistent with other kepler actions
469
+ get().kepler.registerKeplerMapIfNotExists(mapId);
470
+ const sourceMap = get().kepler.config.maps.find((m) => m.id === mapId);
471
+ const sourceMapState = get().kepler.map[mapId];
472
+ if (!sourceMap || !sourceMapState) {
473
+ return {
474
+ success: false,
475
+ message: 'Unable to duplicate map: source map or state not found',
476
+ code: 'source-map-not-found',
477
+ };
478
+ }
479
+ const newMapId = createId();
480
+ const now = Date.now();
481
+ // Save the source map state using Kepler's schema manager
482
+ const savedConfig = KeplerGLSchemaManager.getConfigToSave(sourceMapState);
483
+ set((state) => produce(state, (draft) => {
484
+ draft.kepler.config.maps.push({
485
+ id: newMapId,
486
+ name: `Copy of ${sourceMap.name}`,
487
+ config: savedConfig,
488
+ lastOpenedAt: now,
489
+ });
490
+ // Register the new map with empty state, then load the config
491
+ draft.kepler.map = createKeplerReducer({
492
+ reason: 'duplicate-map',
493
+ mapId: newMapId,
494
+ name: `Copy of ${sourceMap.name}`,
495
+ })(draft.kepler.map, registerMapEntry(newMapId));
496
+ draft.kepler.forwardDispatch[newMapId] =
497
+ getForwardDispatch(newMapId);
498
+ }));
499
+ // Load the saved config into the new map
500
+ get().kepler.addConfigToMap(newMapId, savedConfig);
501
+ requestMapStyle(newMapId);
502
+ get().kepler.syncKeplerDatasets();
503
+ return {
504
+ success: true,
505
+ message: 'Map duplicated successfully',
506
+ };
507
+ },
270
508
  renameMap: (mapId, name) => {
271
509
  set((state) => produce(state, (draft) => {
272
510
  const map = draft.kepler.config.maps.find((map) => map.id === mapId);
@@ -275,21 +513,6 @@ export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfig
275
513
  }
276
514
  }));
277
515
  },
278
- closeMap: (mapId) => {
279
- set((state) => produce(state, (draft) => {
280
- const openTabs = draft.kepler.config.openTabs;
281
- // Don't close if it's the last open tab (defensive check, TabStrip also prevents this)
282
- if (openTabs.length <= 1)
283
- return;
284
- // Just remove from openTabs - TabStrip handles selection via onSelect before calling onClose
285
- draft.kepler.config.openTabs = openTabs.filter((id) => id !== mapId);
286
- }));
287
- },
288
- setOpenTabs: (tabIds) => {
289
- set((state) => produce(state, (draft) => {
290
- draft.kepler.config.openTabs = tabIds;
291
- }));
292
- },
293
516
  addDataToMap: (mapId, data) => {
294
517
  get().kepler.registerKeplerMapIfNotExists(mapId);
295
518
  get().kepler.dispatchAction(mapId, addDataToMap(data));
@@ -338,12 +561,53 @@ export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfig
338
561
  }
339
562
  get().kepler.dispatchAction(mapId, addDataToMap({ config: parsedConfig, datasets: [] }));
340
563
  },
564
+ withConfigPersistencePaused: async (fn) => {
565
+ configRestoreDepth += 1;
566
+ if (configRestoreDepth === 1) {
567
+ set((state) => produce(state, (draft) => {
568
+ draft.kepler.isRestoringConfig = true;
569
+ }));
570
+ }
571
+ const restorePromise = (async () => {
572
+ try {
573
+ return await fn();
574
+ }
575
+ finally {
576
+ // Kepler restore actions can enqueue follow-up work through
577
+ // react-palm; let those actions settle before persisted config
578
+ // writes are allowed again.
579
+ await new Promise((resolve) => setTimeout(resolve, 0));
580
+ await new Promise((resolve) => setTimeout(resolve, 0));
581
+ configRestoreDepth -= 1;
582
+ if (configRestoreDepth === 0) {
583
+ set((state) => produce(state, (draft) => {
584
+ draft.kepler.isRestoringConfig = false;
585
+ }));
586
+ }
587
+ }
588
+ })();
589
+ configRestorePromises.add(restorePromise);
590
+ try {
591
+ return await restorePromise;
592
+ }
593
+ finally {
594
+ configRestorePromises.delete(restorePromise);
595
+ }
596
+ },
597
+ waitForConfigRestore: async () => {
598
+ while (configRestorePromises.size > 0) {
599
+ await Promise.allSettled(Array.from(configRestorePromises));
600
+ }
601
+ },
341
602
  registerKeplerMapIfNotExists(mapId) {
342
603
  if (!get().kepler.map[mapId]) {
343
604
  set({
344
605
  kepler: {
345
606
  ...get().kepler,
346
- map: keplerReducer(get().kepler.map, registerEntry({ id: mapId })),
607
+ map: createKeplerReducer({
608
+ reason: 'register-map',
609
+ mapId,
610
+ })(get().kepler.map, registerMapEntry(mapId)),
347
611
  forwardDispatch: {
348
612
  ...get().kepler.forwardDispatch,
349
613
  [mapId]: getForwardDispatch(mapId),
@@ -377,7 +641,10 @@ export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfig
377
641
  if (!mapId)
378
642
  throw new Error('Map ID not found in action payload');
379
643
  const result = next(action);
380
- if (!SKIP_AUTO_SAVE_ACTIONS.includes(action.type) && mapId) {
644
+ if (configRestoreDepth === 0 &&
645
+ !get().kepler.isRestoringConfig &&
646
+ !SKIP_AUTO_SAVE_ACTIONS.includes(action.type) &&
647
+ mapId) {
381
648
  // save kepler config to store
382
649
  set((state) => produce(state, (draft) => {
383
650
  const mapToSave = draft.kepler.config.maps.find((map) => map.id === mapId);
@@ -410,7 +677,11 @@ export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfig
410
677
  // Register redux state of maps that are not in the config
411
678
  for (const map of draft.kepler.config.maps) {
412
679
  if (!draft.kepler.map[map.id]) {
413
- draft.kepler.map = keplerReducer(draft.kepler.map, registerEntry({ id: map.id }));
680
+ draft.kepler.map = createKeplerReducer({
681
+ reason: 'sync-config',
682
+ mapId: map.id,
683
+ name: map.name,
684
+ })(draft.kepler.map, registerMapEntry(map.id));
414
685
  draft.kepler.forwardDispatch[map.id] = getForwardDispatch(map.id);
415
686
  }
416
687
  }
@@ -418,10 +689,7 @@ export function createKeplerSlice({ basicKeplerProps = {}, config: initialConfig
418
689
  }
419
690
  function updateForwardDispatch() {
420
691
  const config = get().kepler.config;
421
- const currentMapId = config.currentMapId;
422
- const forwardDispatch = {
423
- [currentMapId]: getForwardDispatch(currentMapId),
424
- };
692
+ const forwardDispatch = {};
425
693
  if (config) {
426
694
  for (const { id } of config.maps) {
427
695
  forwardDispatch[id] = getForwardDispatch(id);