@sqlrooms/deck 0.29.0-rc.10 → 0.29.0-rc.11

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 (118) hide show
  1. package/README.md +42 -6
  2. package/dist/DeckJsonMap.d.ts.map +1 -1
  3. package/dist/DeckJsonMap.js +2 -6
  4. package/dist/DeckJsonMap.js.map +1 -1
  5. package/dist/DeckJsonMapSpec.d.ts +875 -53
  6. package/dist/DeckJsonMapSpec.d.ts.map +1 -1
  7. package/dist/DeckJsonMapSpec.js +7 -0
  8. package/dist/DeckJsonMapSpec.js.map +1 -1
  9. package/dist/DeckMapDefaultStylesProvider.d.ts +3 -4
  10. package/dist/DeckMapDefaultStylesProvider.d.ts.map +1 -1
  11. package/dist/DeckMapDefaultStylesProvider.js +13 -7
  12. package/dist/DeckMapDefaultStylesProvider.js.map +1 -1
  13. package/dist/MapSettings.d.ts.map +1 -1
  14. package/dist/MapSettings.js +25 -11
  15. package/dist/MapSettings.js.map +1 -1
  16. package/dist/ai.d.ts +19 -5
  17. package/dist/ai.d.ts.map +1 -1
  18. package/dist/ai.js +60 -112
  19. package/dist/ai.js.map +1 -1
  20. package/dist/aiNormalize.d.ts +31 -0
  21. package/dist/aiNormalize.d.ts.map +1 -0
  22. package/dist/aiNormalize.js +629 -0
  23. package/dist/aiNormalize.js.map +1 -0
  24. package/dist/dashboard.d.ts.map +1 -1
  25. package/dist/dashboard.js +72 -8
  26. package/dist/dashboard.js.map +1 -1
  27. package/dist/dashboardConfig.d.ts +10 -1
  28. package/dist/dashboardConfig.d.ts.map +1 -1
  29. package/dist/dashboardConfig.js +55 -22
  30. package/dist/dashboardConfig.js.map +1 -1
  31. package/dist/datasets/PreparedDatasetStore.d.ts.map +1 -1
  32. package/dist/datasets/PreparedDatasetStore.js +31 -2
  33. package/dist/datasets/PreparedDatasetStore.js.map +1 -1
  34. package/dist/datasets/wrapGeometryAsWkb.d.ts +20 -0
  35. package/dist/datasets/wrapGeometryAsWkb.d.ts.map +1 -0
  36. package/dist/datasets/wrapGeometryAsWkb.js +65 -0
  37. package/dist/datasets/wrapGeometryAsWkb.js.map +1 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/json/compileGeoArrowAccessor.d.ts +5 -0
  43. package/dist/json/compileGeoArrowAccessor.d.ts.map +1 -1
  44. package/dist/json/compileGeoArrowAccessor.js +17 -3
  45. package/dist/json/compileGeoArrowAccessor.js.map +1 -1
  46. package/dist/json/createDeckJsonConfiguration.d.ts.map +1 -1
  47. package/dist/json/createDeckJsonConfiguration.js +85 -18
  48. package/dist/json/createDeckJsonConfiguration.js.map +1 -1
  49. package/dist/json/extractColorScaleLegends.d.ts.map +1 -1
  50. package/dist/json/extractColorScaleLegends.js +21 -7
  51. package/dist/json/extractColorScaleLegends.js.map +1 -1
  52. package/dist/json/layers/DeckH3HexagonLayer.d.ts.map +1 -1
  53. package/dist/json/layers/DeckH3HexagonLayer.js +9 -14
  54. package/dist/json/layers/DeckH3HexagonLayer.js.map +1 -1
  55. package/dist/json/layers/h3IndexFormat.d.ts +6 -0
  56. package/dist/json/layers/h3IndexFormat.d.ts.map +1 -0
  57. package/dist/json/layers/h3IndexFormat.js +17 -0
  58. package/dist/json/layers/h3IndexFormat.js.map +1 -0
  59. package/dist/json/layers/index.d.ts +1 -0
  60. package/dist/json/layers/index.d.ts.map +1 -1
  61. package/dist/json/layers/index.js +1 -0
  62. package/dist/json/layers/index.js.map +1 -1
  63. package/dist/json/rewriteGeoArrowAccessors.d.ts.map +1 -1
  64. package/dist/json/rewriteGeoArrowAccessors.js +9 -0
  65. package/dist/json/rewriteGeoArrowAccessors.js.map +1 -1
  66. package/dist/json/scaleFunction.d.ts +30 -0
  67. package/dist/json/scaleFunction.d.ts.map +1 -0
  68. package/dist/json/scaleFunction.js +126 -0
  69. package/dist/json/scaleFunction.js.map +1 -0
  70. package/dist/mapAiConfig.d.ts +2 -0
  71. package/dist/mapAiConfig.d.ts.map +1 -1
  72. package/dist/mapAiConfig.js +2 -1
  73. package/dist/mapAiConfig.js.map +1 -1
  74. package/dist/mapAiSharedInstructions.d.ts +3 -0
  75. package/dist/mapAiSharedInstructions.d.ts.map +1 -0
  76. package/dist/mapAiSharedInstructions.js +16 -0
  77. package/dist/mapAiSharedInstructions.js.map +1 -0
  78. package/dist/mapConfig.d.ts +6 -0
  79. package/dist/mapConfig.d.ts.map +1 -1
  80. package/dist/mapConfig.js.map +1 -1
  81. package/dist/mapConfigUtils.d.ts.map +1 -1
  82. package/dist/mapConfigUtils.js +2 -0
  83. package/dist/mapConfigUtils.js.map +1 -1
  84. package/dist/mapFit.d.ts +16 -0
  85. package/dist/mapFit.d.ts.map +1 -1
  86. package/dist/mapFit.js +208 -30
  87. package/dist/mapFit.js.map +1 -1
  88. package/dist/mapLayerConfigUtils.d.ts +9 -1
  89. package/dist/mapLayerConfigUtils.d.ts.map +1 -1
  90. package/dist/mapLayerConfigUtils.js +62 -12
  91. package/dist/mapLayerConfigUtils.js.map +1 -1
  92. package/dist/mapResourceAuthoring.d.ts +2 -0
  93. package/dist/mapResourceAuthoring.d.ts.map +1 -1
  94. package/dist/mapResourceAuthoring.js +402 -16
  95. package/dist/mapResourceAuthoring.js.map +1 -1
  96. package/dist/mosaic.d.ts +1 -1
  97. package/dist/mosaic.d.ts.map +1 -1
  98. package/dist/mosaic.js.map +1 -1
  99. package/dist/prepare/geoarrow.js.map +1 -1
  100. package/dist/prepare/wkbDecoder.d.ts +10 -0
  101. package/dist/prepare/wkbDecoder.d.ts.map +1 -1
  102. package/dist/prepare/wkbDecoder.js +93 -18
  103. package/dist/prepare/wkbDecoder.js.map +1 -1
  104. package/dist/prepare/wkbParser.d.ts +7 -0
  105. package/dist/prepare/wkbParser.d.ts.map +1 -1
  106. package/dist/prepare/wkbParser.js +21 -0
  107. package/dist/prepare/wkbParser.js.map +1 -1
  108. package/dist/selectStarAsWkbCollision.d.ts +3 -0
  109. package/dist/selectStarAsWkbCollision.d.ts.map +1 -0
  110. package/dist/selectStarAsWkbCollision.js +78 -0
  111. package/dist/selectStarAsWkbCollision.js.map +1 -0
  112. package/dist/types.d.ts +1 -1
  113. package/dist/types.d.ts.map +1 -1
  114. package/dist/types.js.map +1 -1
  115. package/dist/useDeckMapFitToBounds.d.ts.map +1 -1
  116. package/dist/useDeckMapFitToBounds.js +1 -1
  117. package/dist/useDeckMapFitToBounds.js.map +1 -1
  118. package/package.json +15 -15
@@ -1,5 +1,8 @@
1
+ import { binnedNumericSchemes, categoricalSchemes, continuousDivergingSchemes, continuousSequentialSchemes, formatColorSchemePromptLists, } from '@sqlrooms/color-scales/colorSchemeNames';
1
2
  import { DeckJsonMapSpec } from './DeckJsonMapSpec';
2
3
  import { isDeckMapSqlDatasetSource, isDeckMapTableDatasetSource, } from './mapConfig';
4
+ import { hasSelectStarAsWkbCollision } from './selectStarAsWkbCollision';
5
+ import { getDeckMapSharedAiContractRules } from './mapAiSharedInstructions';
3
6
  /** Error raised before an invalid map resource can be durably written. */
4
7
  export class DeckMapResourceConfigError extends Error {
5
8
  issues;
@@ -11,12 +14,102 @@ export class DeckMapResourceConfigError extends Error {
11
14
  this.issues = issues;
12
15
  }
13
16
  }
17
+ const BINNED_SCHEME_NAMES = new Set(binnedNumericSchemes);
18
+ const SEQUENTIAL_SCHEME_NAMES = new Set(continuousSequentialSchemes);
19
+ const DIVERGING_SCHEME_NAMES = new Set(continuousDivergingSchemes);
20
+ const CATEGORICAL_SCHEME_NAMES = new Set(categoricalSchemes);
21
+ /** Issue when colorScale `type` and `scheme` disagree or are incomplete. */
22
+ export function getColorScaleTypeSchemeIssue(type, scheme) {
23
+ if (typeof type !== 'string' || !type.trim()) {
24
+ return 'colorScale requires a "type" (sequential, diverging, quantize, quantile, threshold, or categorical)';
25
+ }
26
+ if (typeof scheme !== 'string' || !scheme.trim()) {
27
+ return 'colorScale requires a "scheme" (exact name such as Viridis, YlOrRd, Blues, or Tableau10)';
28
+ }
29
+ if (type !== type.trim()) {
30
+ return 'colorScale type must not have leading or trailing whitespace';
31
+ }
32
+ if (scheme !== scheme.trim()) {
33
+ return 'colorScale scheme must not have leading or trailing whitespace';
34
+ }
35
+ const scaleType = type;
36
+ const schemeName = scheme;
37
+ const allowedForType = (allowed, label) => {
38
+ if (allowed.has(schemeName))
39
+ return undefined;
40
+ if (SEQUENTIAL_SCHEME_NAMES.has(schemeName)) {
41
+ return `scheme "${schemeName}" requires type "sequential" (not "${scaleType}") — use type "sequential", or pick a ${label}`;
42
+ }
43
+ if (DIVERGING_SCHEME_NAMES.has(schemeName)) {
44
+ return `scheme "${schemeName}" requires type "diverging" (not "${scaleType}") — use type "diverging", or pick a ${label}`;
45
+ }
46
+ if (CATEGORICAL_SCHEME_NAMES.has(schemeName)) {
47
+ return `scheme "${schemeName}" requires type "categorical" (not "${scaleType}") — use type "categorical", or pick a ${label}`;
48
+ }
49
+ return `scheme "${schemeName}" is not valid for type "${scaleType}" — use a ${label}`;
50
+ };
51
+ switch (scaleType) {
52
+ case 'quantile':
53
+ case 'quantize':
54
+ case 'threshold':
55
+ return allowedForType(BINNED_SCHEME_NAMES, 'ColorBrewer binned ramp such as YlOrRd, Blues, Greens, or RdYlBu');
56
+ case 'sequential':
57
+ return allowedForType(SEQUENTIAL_SCHEME_NAMES, 'sequential scheme such as Viridis, Plasma, Blues, or YlOrRd');
58
+ case 'diverging':
59
+ return allowedForType(DIVERGING_SCHEME_NAMES, 'diverging scheme such as RdBu, Spectral, or BrBG');
60
+ case 'categorical':
61
+ return allowedForType(CATEGORICAL_SCHEME_NAMES, 'categorical scheme such as Tableau10, Set2, or Category10');
62
+ default:
63
+ return `colorScale type "${scaleType}" is not supported — use sequential, diverging, quantize, quantile, threshold, or categorical`;
64
+ }
65
+ }
66
+ function getColorScaleThresholdsIssue(type, thresholds) {
67
+ if (type !== 'threshold')
68
+ return undefined;
69
+ if (!Array.isArray(thresholds) ||
70
+ thresholds.length === 0 ||
71
+ !thresholds.every((value) => typeof value === 'number' && Number.isFinite(value))) {
72
+ return 'colorScale type "threshold" requires a non-empty numeric "thresholds" array';
73
+ }
74
+ return undefined;
75
+ }
14
76
  function isRecord(value) {
15
77
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
16
78
  }
17
79
  function hasEntries(value) {
18
80
  return isRecord(value) && Object.keys(value).length > 0;
19
81
  }
82
+ /** True for invalid `ST_MakeLine(ST_Point(...) ORDER BY ...)` (ORDER BY belongs in LIST). */
83
+ function hasBadStMakeLinePointOrderBy(sql) {
84
+ const re = /\bST_MakeLine\s*\(/gi;
85
+ let match;
86
+ while ((match = re.exec(sql)) !== null) {
87
+ let i = match.index + match[0].length;
88
+ while (i < sql.length && /\s/.test(sql[i]))
89
+ i += 1;
90
+ if (/^LIST\s*\(/i.test(sql.slice(i)))
91
+ continue;
92
+ const pointMatch = sql.slice(i).match(/^ST_Point\s*\(/i);
93
+ if (!pointMatch)
94
+ continue;
95
+ i += pointMatch[0].length;
96
+ let depth = 1;
97
+ while (i < sql.length && depth > 0) {
98
+ const ch = sql[i];
99
+ if (ch === '(')
100
+ depth += 1;
101
+ else if (ch === ')')
102
+ depth -= 1;
103
+ i += 1;
104
+ }
105
+ if (depth !== 0)
106
+ continue;
107
+ if (/^\s+ORDER\s+BY\b/i.test(sql.slice(i))) {
108
+ return true;
109
+ }
110
+ }
111
+ return false;
112
+ }
20
113
  function mergeOptionalRecord(existing, incoming) {
21
114
  if (isRecord(existing) && isRecord(incoming)) {
22
115
  return { ...existing, ...incoming };
@@ -81,6 +174,22 @@ function mergeSpecPatch(existingSpec, incomingSpec, options) {
81
174
  ...(layers ? { layers } : {}),
82
175
  };
83
176
  }
177
+ /** True when a bare `{tableName}` patch would wipe existing geometry SQL. */
178
+ function isSourceDowngrade(existingSource, incomingSource) {
179
+ if (!existingSource || !incomingSource)
180
+ return false;
181
+ const incomingIsBareTableName = isDeckMapTableDatasetSource(incomingSource) &&
182
+ !('transformSql' in incomingSource &&
183
+ typeof incomingSource.transformSql === 'string' &&
184
+ incomingSource.transformSql.trim().length > 0);
185
+ if (!incomingIsBareTableName)
186
+ return false;
187
+ if (isDeckMapSqlDatasetSource(existingSource))
188
+ return true;
189
+ return (isDeckMapTableDatasetSource(existingSource) &&
190
+ typeof existingSource.transformSql === 'string' &&
191
+ existingSource.transformSql.trim().length > 0);
192
+ }
84
193
  function mergeDatasetRegistry(existingDatasets, incomingDatasets, replaceDatasets) {
85
194
  if (replaceDatasets)
86
195
  return incomingDatasets;
@@ -89,13 +198,18 @@ function mergeDatasetRegistry(existingDatasets, incomingDatasets, replaceDataset
89
198
  const datasets = { ...existingDatasets };
90
199
  for (const [datasetId, incomingDataset] of Object.entries(incomingDatasets)) {
91
200
  const existingDataset = existingDatasets[datasetId];
92
- datasets[datasetId] = existingDataset
93
- ? {
94
- ...existingDataset,
95
- ...incomingDataset,
96
- source: incomingDataset.source ?? existingDataset.source,
97
- }
98
- : incomingDataset;
201
+ if (!existingDataset) {
202
+ datasets[datasetId] = incomingDataset;
203
+ continue;
204
+ }
205
+ const resolvedSource = isSourceDowngrade(existingDataset.source, incomingDataset.source)
206
+ ? existingDataset.source
207
+ : (incomingDataset.source ?? existingDataset.source);
208
+ datasets[datasetId] = {
209
+ ...existingDataset,
210
+ ...incomingDataset,
211
+ source: resolvedSource,
212
+ };
99
213
  }
100
214
  return datasets;
101
215
  }
@@ -143,6 +257,27 @@ function parseSpec(config) {
143
257
  issues: [],
144
258
  };
145
259
  }
260
+ const DECK_MAP_LAYER_CLASS_ALIASES = {
261
+ ScatterplotLayer: 'GeoArrowScatterplotLayer',
262
+ HeatmapLayer: 'GeoArrowHeatmapLayer',
263
+ ColumnLayer: 'GeoArrowColumnLayer',
264
+ PathLayer: 'GeoArrowPathLayer',
265
+ PolygonLayer: 'GeoArrowPolygonLayer',
266
+ SolidPolygonLayer: 'GeoArrowSolidPolygonLayer',
267
+ ArcLayer: 'GeoArrowArcLayer',
268
+ TripsLayer: 'GeoArrowTripsLayer',
269
+ H3HexagonLayer: 'GeoArrowH3HexagonLayer',
270
+ };
271
+ const COLOR_SCALE_ACCESSOR_PROPS = [
272
+ 'getFillColor',
273
+ 'getLineColor',
274
+ 'getColor',
275
+ 'getSourceColor',
276
+ 'getTargetColor',
277
+ ];
278
+ function isPlainObject(value) {
279
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
280
+ }
146
281
  /**
147
282
  * Validates the post-merge invariants of a renderable, resource-native map.
148
283
  * Patch inputs may be sparse, but the durable result must have supported
@@ -190,6 +325,17 @@ export function getDeckMapResourceConfigIssues(config, options = {}) {
190
325
  : 'must define source.tableName or source.sqlQuery',
191
326
  });
192
327
  }
328
+ const sqlParts = [
329
+ isDeckMapTableDatasetSource(source) ? source.transformSql : undefined,
330
+ isDeckMapSqlDatasetSource(source) ? source.sqlQuery : undefined,
331
+ ].filter((part) => typeof part === 'string' && part.trim().length > 0);
332
+ const sql = sqlParts.join('\n');
333
+ if (sql && hasSelectStarAsWkbCollision(sql)) {
334
+ issues.push({
335
+ path: `datasets.${datasetId}.source`,
336
+ message: 'Do not use SELECT *, ST_AsWKB(col) AS col — DuckDB keeps the original column and the WKB alias collides, producing empty maps. Use SELECT * EXCLUDE (col), ST_AsWKB(col) AS col, or omit transformSql when the geometry column already exists.',
337
+ });
338
+ }
193
339
  }
194
340
  if (parsedSpec.layers.length === 0) {
195
341
  issues.push({
@@ -205,6 +351,58 @@ export function getDeckMapResourceConfigIssues(config, options = {}) {
205
351
  message: 'must name a Deck JSON layer class',
206
352
  });
207
353
  }
354
+ else {
355
+ const aliased = DECK_MAP_LAYER_CLASS_ALIASES[layerType];
356
+ if (aliased) {
357
+ issues.push({
358
+ path: `spec.layers.${index}.@@type`,
359
+ message: `use "${aliased}" — plain "${layerType}" is not a registered Deck JSON layer class`,
360
+ });
361
+ }
362
+ }
363
+ for (const prop of COLOR_SCALE_ACCESSOR_PROPS) {
364
+ const value = layer[prop];
365
+ if (!isPlainObject(value))
366
+ continue;
367
+ if (value['@@type'] === 'ColorScale' &&
368
+ value['@@function'] !== 'colorScale') {
369
+ issues.push({
370
+ path: `spec.layers.${index}.${prop}`,
371
+ message: 'use {"@@function":"colorScale","field":"<column>","type":"...","scheme":"...","domain":"auto"} — {"@@type":"ColorScale","column":"..."} is not valid colorScale syntax',
372
+ });
373
+ continue;
374
+ }
375
+ if (value['@@function'] === 'colorScale') {
376
+ if (typeof value.field !== 'string' || !value.field.trim()) {
377
+ if (typeof value.column === 'string' && value.column.trim()) {
378
+ issues.push({
379
+ path: `spec.layers.${index}.${prop}`,
380
+ message: 'colorScale uses "field" for the column name, not "column" — rename column → field',
381
+ });
382
+ }
383
+ else {
384
+ issues.push({
385
+ path: `spec.layers.${index}.${prop}`,
386
+ message: 'colorScale requires a non-empty string "field" with the exact column name',
387
+ });
388
+ }
389
+ }
390
+ const schemeIssue = getColorScaleTypeSchemeIssue(value.type, value.scheme);
391
+ if (schemeIssue) {
392
+ issues.push({
393
+ path: `spec.layers.${index}.${prop}`,
394
+ message: schemeIssue,
395
+ });
396
+ }
397
+ const thresholdsIssue = getColorScaleThresholdsIssue(value.type, value.thresholds);
398
+ if (thresholdsIssue) {
399
+ issues.push({
400
+ path: `spec.layers.${index}.${prop}`,
401
+ message: thresholdsIssue,
402
+ });
403
+ }
404
+ }
405
+ }
208
406
  const binding = layer._sqlroomsBinding && typeof layer._sqlroomsBinding === 'object'
209
407
  ? layer._sqlroomsBinding
210
408
  : undefined;
@@ -218,12 +416,191 @@ export function getDeckMapResourceConfigIssues(config, options = {}) {
218
416
  message: `references unknown dataset "${boundDataset}"`,
219
417
  });
220
418
  }
221
- return;
222
419
  }
223
- issues.push({
224
- path: `spec.layers.${index}._sqlroomsBinding.dataset`,
225
- message: 'must bind the layer to a config.datasets entry; layer data references and implicit bindings are not durable resource bindings',
226
- });
420
+ else {
421
+ issues.push({
422
+ path: `spec.layers.${index}._sqlroomsBinding.dataset`,
423
+ message: 'must bind the layer to a config.datasets entry; layer data references and implicit bindings are not durable resource bindings',
424
+ });
425
+ }
426
+ if (layerType === 'GeoArrowHeatmapLayer' && 'getWeight' in layer) {
427
+ // Basic: omit getWeight. Custom: numeric constant only.
428
+ const getWeight = layer.getWeight;
429
+ if (config.configMode !== 'custom') {
430
+ issues.push({
431
+ path: `spec.layers.${index}.getWeight`,
432
+ message: 'omit getWeight for default density — the basic settings panel has no weight-column control; do not bind a column',
433
+ });
434
+ }
435
+ else if (isPlainObject(getWeight) ||
436
+ (typeof getWeight === 'string' && getWeight.trim().length > 0)) {
437
+ issues.push({
438
+ path: `spec.layers.${index}.getWeight`,
439
+ message: 'omit getWeight for default density, or use a numeric constant — column / object accessors are not supported',
440
+ });
441
+ }
442
+ }
443
+ // Basic mode: numeric sizes (elevation may use @@=col / scale).
444
+ if (config.configMode !== 'custom') {
445
+ const rejectNonNumericSize = (prop, value, message) => {
446
+ if (value === undefined)
447
+ return;
448
+ if (typeof value === 'number' && Number.isFinite(value))
449
+ return;
450
+ issues.push({
451
+ path: `spec.layers.${index}.${prop}`,
452
+ message,
453
+ });
454
+ };
455
+ if (layerType === 'GeoArrowScatterplotLayer') {
456
+ rejectNonNumericSize('getRadius', layer.getRadius, 'use a positive number (e.g. 4) with radiusUnits "pixels" — string/object size accessors are not supported in basic mode');
457
+ }
458
+ const LINE_LAYER_TYPES = new Set([
459
+ 'GeoArrowPathLayer',
460
+ 'GeoArrowArcLayer',
461
+ 'GeoArrowTripsLayer',
462
+ ]);
463
+ if (typeof layerType === 'string' && LINE_LAYER_TYPES.has(layerType)) {
464
+ rejectNonNumericSize('getWidth', layer.getWidth, 'use a number (e.g. 2) with widthUnits "pixels" — string/object size accessors are not supported in basic mode');
465
+ }
466
+ if (layerType === 'GeoArrowHeatmapLayer') {
467
+ rejectNonNumericSize('radiusPixels', layer.radiusPixels, 'use a positive number (e.g. 30) — string/object size accessors are not supported in basic mode');
468
+ }
469
+ if (layerType === 'GeoArrowColumnLayer') {
470
+ rejectNonNumericSize('radius', layer.radius, 'use a positive number in meters (e.g. 50) — string/object size accessors are not supported in basic mode');
471
+ }
472
+ const ELEVATION_LAYER_TYPES = new Set([
473
+ 'GeoArrowPolygonLayer',
474
+ 'GeoArrowSolidPolygonLayer',
475
+ 'GeoArrowColumnLayer',
476
+ 'GeoArrowH3HexagonLayer',
477
+ ]);
478
+ if (typeof layerType === 'string' &&
479
+ ELEVATION_LAYER_TYPES.has(layerType) &&
480
+ layer.getElevation !== undefined) {
481
+ const elev = layer.getElevation;
482
+ if (typeof elev === 'string') {
483
+ const isColumnAccessor = /^@@=[A-Za-z_][\w]*$/.test(elev.trim());
484
+ if (!isColumnAccessor) {
485
+ issues.push({
486
+ path: `spec.layers.${index}.getElevation`,
487
+ message: 'in basic mode use a number (0 for flat), a column accessor "@@=columnName", or {"@@function":"scale","field":"...","type":"linear","domain":"auto","range":[0,200]} — free-form string expressions require configMode "custom"',
488
+ });
489
+ }
490
+ }
491
+ else if (isPlainObject(elev)) {
492
+ const fn = elev['@@function'];
493
+ const field = typeof elev.field === 'string' ? elev.field.trim() : '';
494
+ const typeOk = elev.type === undefined ||
495
+ elev.type === 'linear' ||
496
+ (typeof elev.type === 'string' && elev.type.trim() === 'linear');
497
+ if ((fn !== 'scale' && fn !== 'scaleLinear') || !field || !typeOk) {
498
+ issues.push({
499
+ path: `spec.layers.${index}.getElevation`,
500
+ message: 'in basic mode use {"@@function":"scale","field":"...","type":"linear","domain":"auto","range":[0,200]} (or a column accessor "@@=columnName")',
501
+ });
502
+ }
503
+ }
504
+ else if (!(typeof elev === 'number' && Number.isFinite(elev))) {
505
+ issues.push({
506
+ path: `spec.layers.${index}.getElevation`,
507
+ message: 'in basic mode use a number (0 for flat), a column accessor "@@=columnName", or {"@@function":"scale","field":"...","type":"linear","domain":"auto","range":[0,200]}',
508
+ });
509
+ }
510
+ }
511
+ }
512
+ if (layerType === 'GeoArrowH3HexagonLayer') {
513
+ const getHexagon = layer.getHexagon;
514
+ if (isPlainObject(getHexagon)) {
515
+ issues.push({
516
+ path: `spec.layers.${index}.getHexagon`,
517
+ message: 'use getHexagon as a deck.gl attribute string "@@=h3_column_name" (or set _sqlroomsBinding.hexagonColumn) — object accessors like {"@@function":"...","column":"..."} are not valid',
518
+ });
519
+ }
520
+ else {
521
+ const hasHexagonBinding = typeof binding?.hexagonColumn === 'string' &&
522
+ binding.hexagonColumn.trim().length > 0;
523
+ const getHexagonAccessor = typeof getHexagon === 'string' ? getHexagon.trim() : '';
524
+ const hasGetHexagonAccessor = /^@@=[A-Za-z_][\w]*$/.test(getHexagonAccessor);
525
+ if (getHexagonAccessor &&
526
+ !hasGetHexagonAccessor &&
527
+ !hasHexagonBinding) {
528
+ issues.push({
529
+ path: `spec.layers.${index}.getHexagon`,
530
+ message: 'use getHexagon as "@@=h3_column_name" (a simple column accessor) or set _sqlroomsBinding.hexagonColumn — bare names and expressions are not valid',
531
+ });
532
+ }
533
+ else if (!hasGetHexagonAccessor && !hasHexagonBinding) {
534
+ issues.push({
535
+ path: `spec.layers.${index}.getHexagon`,
536
+ message: 'GeoArrowH3HexagonLayer requires getHexagon (e.g. "@@=h3_column_name") or _sqlroomsBinding.hexagonColumn set to the H3 index column',
537
+ });
538
+ }
539
+ }
540
+ }
541
+ if (layerType === 'GeoArrowArcLayer') {
542
+ if (layer.getSourcePosition !== undefined) {
543
+ issues.push({
544
+ path: `spec.layers.${index}.getSourcePosition`,
545
+ message: 'do not set getSourcePosition — bind the source geometry via _sqlroomsBinding.sourceGeometryColumn only',
546
+ });
547
+ }
548
+ if (layer.getTargetPosition !== undefined) {
549
+ issues.push({
550
+ path: `spec.layers.${index}.getTargetPosition`,
551
+ message: 'do not set getTargetPosition — bind the target geometry via _sqlroomsBinding.targetGeometryColumn only',
552
+ });
553
+ }
554
+ const hasSource = typeof binding?.sourceGeometryColumn === 'string' &&
555
+ binding.sourceGeometryColumn.trim().length > 0;
556
+ const hasTarget = typeof binding?.targetGeometryColumn === 'string' &&
557
+ binding.targetGeometryColumn.trim().length > 0;
558
+ if (!hasSource) {
559
+ issues.push({
560
+ path: `spec.layers.${index}._sqlroomsBinding.sourceGeometryColumn`,
561
+ message: 'GeoArrowArcLayer requires _sqlroomsBinding.sourceGeometryColumn set to the source geometry column name',
562
+ });
563
+ }
564
+ if (!hasTarget) {
565
+ issues.push({
566
+ path: `spec.layers.${index}._sqlroomsBinding.targetGeometryColumn`,
567
+ message: 'GeoArrowArcLayer requires _sqlroomsBinding.targetGeometryColumn set to the target geometry column name',
568
+ });
569
+ }
570
+ }
571
+ if (layerType === 'GeoArrowTripsLayer') {
572
+ const hasTimestampColumn = typeof binding?.timestampColumn === 'string' &&
573
+ binding.timestampColumn.trim().length > 0;
574
+ if (!hasTimestampColumn) {
575
+ issues.push({
576
+ path: `spec.layers.${index}._sqlroomsBinding.timestampColumn`,
577
+ message: 'GeoArrowTripsLayer requires _sqlroomsBinding.timestampColumn set to the timestamps list column, e.g. "timestamps"',
578
+ });
579
+ }
580
+ }
581
+ if (layerType === 'GeoArrowTripsLayer' ||
582
+ layerType === 'GeoArrowPathLayer') {
583
+ // LIST(...)/ST_MakeLine needs GROUP BY path/trip id.
584
+ if (boundDataset && config.datasets[boundDataset]) {
585
+ const source = config.datasets[boundDataset]?.source;
586
+ const sql = `${source?.transformSql ?? ''} ${source?.sqlQuery ?? ''}`;
587
+ if (hasBadStMakeLinePointOrderBy(sql)) {
588
+ issues.push({
589
+ path: `datasets.${boundDataset}.source`,
590
+ message: 'ST_MakeLine is a scalar that takes a LIST of points. Use ST_MakeLine(LIST(ST_Point(lon, lat) ORDER BY waypoint_order)) — never ST_MakeLine(ST_Point(...) ORDER BY ...). ORDER BY is only valid inside LIST.',
591
+ });
592
+ }
593
+ const usesListAgg = /\bST_MakeLine\s*\(\s*LIST\s*\(/i.test(sql) ||
594
+ /\bLIST\s*\([^)]*ORDER\s+BY/i.test(sql);
595
+ const hasGroupBy = /\bGROUP\s+BY\b/i.test(sql);
596
+ if (usesListAgg && !hasGroupBy) {
597
+ issues.push({
598
+ path: `datasets.${boundDataset}.source`,
599
+ message: `${layerType} waypoint aggregation using LIST(...)/ST_MakeLine must include GROUP BY the trip/path/route id column so each trip becomes one linestring. Without GROUP BY, waypoints are not split per trip.`,
600
+ });
601
+ }
602
+ }
603
+ }
227
604
  });
228
605
  const fitDataset = config.fitToData?.dataset;
229
606
  if (fitDataset && !datasetIdSet.has(fitDataset)) {
@@ -232,6 +609,13 @@ export function getDeckMapResourceConfigIssues(config, options = {}) {
232
609
  message: `references unknown dataset "${fitDataset}"`,
233
610
  });
234
611
  }
612
+ if (typeof config.mapStyle === 'string' &&
613
+ /^mapbox:/i.test(config.mapStyle.trim())) {
614
+ issues.push({
615
+ path: 'mapStyle',
616
+ message: 'mapbox:// styles are not supported (MapLibre cannot fetch that URL scheme). Omit mapStyle to use the host basemap, or use a token-free MapLibre-compatible https:// style URL',
617
+ });
618
+ }
235
619
  return issues;
236
620
  }
237
621
  /** Rejects invalid durable map writes after sparse patches have been merged. */
@@ -251,13 +635,15 @@ When authoring a worksheet map config, use the resource-native Deck JSON contrac
251
635
  - A new map must contain at least one config.datasets entry and at least one spec.layers entry.
252
636
  - Every dataset must define source.tableName, source.tableName plus source.transformSql, or source.sqlQuery. Never put sql directly on the dataset object.
253
637
  - Bind every layer to a dataset with _sqlroomsBinding.dataset. Never use data: "@@#datasetId" or an implicit single-dataset binding as a durable resource binding.
254
- - Use supported Deck JSON layer classes such as GeoArrowScatterplotLayer, GeoArrowHeatmapLayer, GeoArrowPolygonLayer, GeoArrowPathLayer, GeoArrowTripsLayer, GeoArrowArcLayer, GeoArrowColumnLayer, GeoArrowH3HexagonLayer, or GeoJsonLayer.
638
+ - Use supported Deck JSON layer classes such as GeoArrowScatterplotLayer, GeoArrowHeatmapLayer, GeoArrowPolygonLayer, GeoArrowSolidPolygonLayer, GeoArrowPathLayer, GeoArrowTripsLayer, GeoArrowArcLayer, GeoArrowColumnLayer, GeoArrowH3HexagonLayer, or GeoJsonLayer. Prefer typed GeoArrow* layers when geometry type is known; GeoJsonLayer is valid for table-backed WKB/GeoJSON via _sqlroomsBinding.
639
+ ${getDeckMapSharedAiContractRules()}
255
640
  - For table-backed datasets, also pass the same table through the tool's top-level tableName field. A selected table does not replace the required dataset source.
256
641
  - transformSql must be a single SELECT and must read from __sqlrooms_source. Use source.sqlQuery only for a standalone pinned query.
257
- - Use configMode "basic" for a straightforward single-layer map. Use "custom" only for advanced properties the basic settings cannot represent; custom mode does not relax dataset-source or layer-binding requirements.
258
- - For a point geometry column, prefer GeoArrowScatterplotLayer with dataset.geometryColumn and _sqlroomsBinding.geometryColumn set to the exact geometry column. For longitude/latitude columns, use source.transformSql to produce WKB geometry and bind that output column.
642
+ - Use configMode "basic" for a straightforward single-layer map (including extruded column/polygon maps with one elevation column via "@@=col" or a scale accessor). Use "custom" only for advanced properties the basic settings cannot represent; custom mode disables the settings panel and does not relax dataset-source or layer-binding requirements.
643
+ - Prefer data-driven color when a useful varying column exists: getFillColor (or getColor/getSourceColor/getTargetColor for arcs) with {"@@function":"colorScale","field":"<column>","type":"sequential"|"quantile"|"categorical","scheme":"<name>","domain":"auto"}. Use quantile for skewed numeric, sequential for uniform, categorical for strings. Skip numeric columns where min = max (or all zeros) unless the user names that column; otherwise use flat fill. Exact scheme names (case-sensitive): ${formatColorSchemePromptLists()} — do not invent names. Viridis/Plasma/Inferno require type "sequential"; quantile/quantize schemes must be ColorBrewer ramps (YlOrRd, Blues, …).
259
644
  - For updates, sparse config patches are allowed because they are merged with the existing resource. For creates, never send empty datasets or layers.
260
- - To remove existing layers or datasets, set replaceLayers and/or replaceDatasets to true and send the complete desired list or registry. Omit them for additive sparse updates.
645
+ - When updating only visual properties (color scale, scheme, radius, width, elevation scale, visibility, opacity), send datasets as an empty object {} so the tool schema stays valid and merge keeps the existing dataset registry. Do not omit datasets entirely. Never re-send bare source.tableName without transformSql/sqlQuery — that overwrites geometry SQL and breaks the map. Include real dataset entries only when changing a data source.
646
+ - To remove existing layers or datasets, set replaceLayers and/or replaceDatasets to true and send the complete desired list or registry. Omit them for additive sparse updates. For layer-type switches, set replaceLayers: true with only the new layer — do not keep the old layer as visible: false.
261
647
  - If a map write reports an invalid resource config, repair the reported paths and retry the same direct map operation; do not replace it with a dashboard-backed map.
262
648
 
263
649
  Minimal table-backed point map shape: