@mwater/visualization 5.6.0 → 5.6.1

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 (92) hide show
  1. package/lib/ColorComponent.js +2 -2
  2. package/lib/TranslationsTabComponent.d.ts +34 -0
  3. package/lib/TranslationsTabComponent.js +256 -0
  4. package/lib/dashboards/DashboardComponent.js +1 -1
  5. package/lib/dashboards/ServerDashboardDataSource.d.ts +0 -1
  6. package/lib/dashboards/ServerDashboardDataSource.js +0 -15
  7. package/lib/dashboards/SettingsModalComponent.js +9 -233
  8. package/lib/datagrids/DatagridComponent.js +5 -0
  9. package/lib/datagrids/DatagridViewComponent.js +30 -4
  10. package/lib/maps/BufferLayer.d.ts +0 -13
  11. package/lib/maps/BufferLayer.js +12 -237
  12. package/lib/maps/BufferLayerDesignerComponent.d.ts +1 -1
  13. package/lib/maps/BufferLayerDesignerComponent.js +0 -5
  14. package/lib/maps/ChoroplethLayer.d.ts +1 -16
  15. package/lib/maps/ChoroplethLayer.js +13 -358
  16. package/lib/maps/ClusterLayer.d.ts +0 -9
  17. package/lib/maps/ClusterLayer.js +0 -250
  18. package/lib/maps/DirectMapDataSource.js +1 -38
  19. package/lib/maps/GridLayer.d.ts +0 -15
  20. package/lib/maps/GridLayer.js +0 -212
  21. package/lib/maps/Layer.d.ts +1 -26
  22. package/lib/maps/Layer.js +0 -13
  23. package/lib/maps/MapComponent.d.ts +19 -35
  24. package/lib/maps/MapComponent.js +135 -76
  25. package/lib/maps/MapControlComponent.d.ts +4 -5
  26. package/lib/maps/MapControlComponent.js +5 -12
  27. package/lib/maps/MapDesign.d.ts +8 -0
  28. package/lib/maps/MapDesignerComponent.d.ts +2 -0
  29. package/lib/maps/MapDesignerComponent.js +7 -2
  30. package/lib/maps/MapLayerDataSource.d.ts +0 -4
  31. package/lib/maps/MapLayerViewDesignerComponent.d.ts +3 -1
  32. package/lib/maps/MapLayerViewDesignerComponent.js +5 -1
  33. package/lib/maps/MapLayersDesignerComponent.d.ts +2 -0
  34. package/lib/maps/MapLayersDesignerComponent.js +2 -1
  35. package/lib/maps/MapTranslationsTab.d.ts +15 -0
  36. package/lib/maps/MapTranslationsTab.js +47 -0
  37. package/lib/maps/MapUtils.d.ts +11 -0
  38. package/lib/maps/MapUtils.js +47 -0
  39. package/lib/maps/MapViewComponent.d.ts +1 -1
  40. package/lib/maps/MapViewComponent.js +1 -8
  41. package/lib/maps/MarkersLayer.d.ts +1 -14
  42. package/lib/maps/MarkersLayer.js +71 -252
  43. package/lib/maps/MarkersLayerDesign.d.ts +4 -0
  44. package/lib/maps/MarkersLayerDesignerComponent.d.ts +20 -16
  45. package/lib/maps/MarkersLayerDesignerComponent.js +77 -23
  46. package/lib/maps/ServerMapDataSource.d.ts +0 -1
  47. package/lib/maps/ServerMapDataSource.js +0 -15
  48. package/lib/maps/SwitchableTileUrlLayer.d.ts +0 -2
  49. package/lib/maps/SwitchableTileUrlLayer.js +0 -9
  50. package/lib/maps/TileUrlLayer.d.ts +0 -1
  51. package/lib/maps/TileUrlLayer.js +0 -5
  52. package/lib/maps/VectorMapViewComponent.js +12 -1
  53. package/lib/maps/vectorMaps.d.ts +5 -6
  54. package/lib/maps/vectorMaps.js +13 -9
  55. package/lib/widgets/MapWidget.js +2 -1
  56. package/package.json +2 -2
  57. package/src/ColorComponent.tsx +2 -2
  58. package/src/TranslationsTabComponent.tsx +429 -0
  59. package/src/dashboards/DashboardComponent.tsx +1 -1
  60. package/src/dashboards/ServerDashboardDataSource.ts +0 -19
  61. package/src/dashboards/SettingsModalComponent.tsx +27 -383
  62. package/src/datagrids/DatagridComponent.tsx +6 -0
  63. package/src/datagrids/DatagridViewComponent.tsx +41 -5
  64. package/src/maps/BufferLayer.ts +16 -262
  65. package/src/maps/BufferLayerDesignerComponent.tsx +0 -6
  66. package/src/maps/ChoroplethLayer.ts +16 -393
  67. package/src/maps/ClusterLayer.ts +0 -274
  68. package/src/maps/DirectMapDataSource.ts +2 -49
  69. package/src/maps/GridLayer.ts +0 -224
  70. package/src/maps/Layer.ts +1 -35
  71. package/src/maps/MapComponent.tsx +448 -0
  72. package/src/maps/MapControlComponent.tsx +41 -0
  73. package/src/maps/MapDesign.ts +6 -0
  74. package/src/maps/MapDesignerComponent.tsx +18 -1
  75. package/src/maps/MapLayerDataSource.ts +0 -5
  76. package/src/maps/MapLayerViewDesignerComponent.ts +9 -2
  77. package/src/maps/MapLayersDesignerComponent.ts +4 -1
  78. package/src/maps/MapTranslationsTab.tsx +53 -0
  79. package/src/maps/MapUtils.ts +48 -0
  80. package/src/maps/MapViewComponent.tsx +2 -8
  81. package/src/maps/MarkersLayer.ts +79 -270
  82. package/src/maps/MarkersLayerDesign.ts +6 -0
  83. package/src/maps/MarkersLayerDesignerComponent.tsx +114 -38
  84. package/src/maps/ServerMapDataSource.ts +0 -19
  85. package/src/maps/SwitchableTileUrlLayer.tsx +0 -11
  86. package/src/maps/TileUrlLayer.tsx +0 -6
  87. package/src/maps/VectorMapViewComponent.tsx +13 -2
  88. package/src/maps/vectorMaps.tsx +12 -9
  89. package/src/widgets/MapWidget.tsx +2 -0
  90. package/src/maps/MapComponent.ts +0 -311
  91. package/src/maps/MapControlComponent.ts +0 -46
  92. package/src/maps/RasterMapViewComponent.ts +0 -345
@@ -8,6 +8,7 @@ import LayerFactory from "./LayerFactory"
8
8
  import { MapDesign } from "./MapDesign"
9
9
  import { produce } from "immer"
10
10
  import { HoverOverItem } from "./maps"
11
+ import { Axis } from "../axes/Axis"
11
12
 
12
13
  export interface MapScope {
13
14
  name: string
@@ -172,6 +173,53 @@ export function getTranslatableStrings(design: MapDesign, schema: Schema): strin
172
173
  return _.uniq(strings)
173
174
  }
174
175
 
176
+ /**
177
+ * Get translatable strings from an axis's categoryLabels and nullLabel.
178
+ * Always includes "None" since it's the default label for null values in AxisBuilder.
179
+ */
180
+ export function getTranslatableStringsFromAxis(axis: Axis | null | undefined): string[] {
181
+ const strings: string[] = []
182
+ if (axis?.categoryLabels) {
183
+ strings.push(...Object.values(axis.categoryLabels))
184
+ }
185
+ if (axis?.nullLabel) {
186
+ strings.push(axis.nullLabel)
187
+ } else {
188
+ // Always include "None" as it's the default nullLabel in AxisBuilder
189
+ strings.push("None")
190
+ }
191
+ return strings
192
+ }
193
+
194
+ /**
195
+ * Translate an axis's category labels and null label.
196
+ * If no nullLabel is set, translates the default "None" and sets it so AxisBuilder uses it.
197
+ */
198
+ export function translateAxis(
199
+ axis: Axis | null | undefined,
200
+ translate: (input: string) => string
201
+ ): Axis | null | undefined {
202
+ if (!axis) return axis
203
+
204
+ return produce(axis, draft => {
205
+ if (draft.categoryLabels) {
206
+ for (const key in draft.categoryLabels) {
207
+ draft.categoryLabels[key] = translate(draft.categoryLabels[key])
208
+ }
209
+ }
210
+ if (draft.nullLabel) {
211
+ draft.nullLabel = translate(draft.nullLabel)
212
+ } else {
213
+ // Translate the default "None" and set it so AxisBuilder uses it
214
+ // instead of falling back to T`None` which uses the global locale
215
+ const translatedNone = translate("None")
216
+ if (translatedNone !== "None") {
217
+ draft.nullLabel = translatedNone
218
+ }
219
+ }
220
+ })
221
+ }
222
+
175
223
  /**
176
224
  * Convenience function to get hover over data for a map given an id and a list of hover over items
177
225
  */
@@ -6,8 +6,6 @@ import { MapDesign } from "./MapDesign"
6
6
  import { MapDataSource } from "./MapDataSource"
7
7
  import { VectorMapViewComponent } from "./VectorMapViewComponent"
8
8
  import { MapScope } from "./MapUtils"
9
- import RasterMapViewComponent from "./RasterMapViewComponent"
10
- import { areVectorMapsEnabled } from "./vectorMaps"
11
9
 
12
10
  export interface MapViewComponentProps {
13
11
  schema: Schema
@@ -50,7 +48,7 @@ export interface MapViewComponentProps {
50
48
  /** Locale to use. Overrides map design locale */
51
49
  locale?: string
52
50
 
53
- /** Translate function to use for display. TODO: implement this */
51
+ /** Translate function to use for display */
54
52
  translate?: (input: string) => string
55
53
 
56
54
  /** Increment to force refresh */
@@ -62,9 +60,5 @@ export interface MapViewComponentProps {
62
60
 
63
61
  /** Component that displays just the map */
64
62
  export function MapViewComponent(props: MapViewComponentProps) {
65
- if (areVectorMapsEnabled()) {
66
- return <VectorMapViewComponent {...props} />
67
- } else {
68
- return <RasterMapViewComponent {...props} />
69
- }
63
+ return <VectorMapViewComponent {...props} />
70
64
  }
@@ -20,7 +20,7 @@ import Widget from "../widgets/Widget"
20
20
  import { WidgetDataSource } from "../widgets/WidgetDataSource"
21
21
  import BlocksLayoutManager from "../layouts/blocks/BlocksLayoutManager"
22
22
  import { getTranslatableStringsFromLayoutManager } from "../dashboards/DashboardUtils"
23
- import { getSimpleHoverOverData } from "./MapUtils"
23
+ import { getSimpleHoverOverData, getTranslatableStringsFromAxis, translateAxis } from "./MapUtils"
24
24
 
25
25
  export default class MarkersLayer extends Layer<MarkersLayerDesign> {
26
26
  /** Gets the type of layer definition */
@@ -138,6 +138,53 @@ export default class MarkersLayer extends Layer<MarkersLayerDesign> {
138
138
  })
139
139
  }
140
140
 
141
+ // Add labels layer if label axis is defined (points only)
142
+ if (design.axes.label) {
143
+ // Determine text-anchor and text-offset based on labelPosition
144
+ let textAnchor: "top" | "bottom" | "left" | "right"
145
+ let textOffset: [number, number]
146
+ switch (design.labelPosition) {
147
+ case "top":
148
+ textAnchor = "bottom"
149
+ textOffset = [0, -0.8]
150
+ break
151
+ case "left":
152
+ textAnchor = "right"
153
+ textOffset = [-0.8, 0]
154
+ break
155
+ case "right":
156
+ textAnchor = "left"
157
+ textOffset = [0.8, 0]
158
+ break
159
+ case "bottom":
160
+ default:
161
+ textAnchor = "top"
162
+ textOffset = [0, 0.8]
163
+ break
164
+ }
165
+
166
+ mapLayers.push({
167
+ id: `${sourceId}:labels`,
168
+ type: "symbol",
169
+ source: sourceId,
170
+ "source-layer": "main",
171
+ layout: {
172
+ "text-field": ["to-string", ["get", "label"]],
173
+ "text-size": 10,
174
+ "text-anchor": textAnchor,
175
+ "text-offset": textOffset,
176
+ "text-allow-overlap": false
177
+ },
178
+ paint: {
179
+ "text-color": compileColorToMapbox("#000000", design.axes.color?.excludedValues),
180
+ "text-halo-color": compileColorToMapbox("rgba(255, 255, 255, 0.8)", design.axes.color?.excludedValues),
181
+ "text-halo-width": 1.5,
182
+ "text-opacity": opacity
183
+ },
184
+ filter: addFilter(["==", ["geometry-type"], "Point"])
185
+ })
186
+ }
187
+
141
188
  return {
142
189
  sourceLayers: [{ id: "main", jsonql }],
143
190
  ctes: [],
@@ -180,6 +227,12 @@ export default class MarkersLayer extends Layer<MarkersLayerDesign> {
180
227
  basequery.selects.push({ type: "select", expr: colorExpr, alias: "color" })
181
228
  }
182
229
 
230
+ // Add label select if label axis
231
+ if (design.axes.label) {
232
+ const labelExpr = axisBuilder.compileAxis({ axis: design.axes.label, tableAlias: "basequery" })
233
+ basequery.selects.push({ type: "select", expr: labelExpr, alias: "label" })
234
+ }
235
+
183
236
  // Create filters
184
237
  let whereClauses: JsonQLExpr[] = []
185
238
 
@@ -209,269 +262,6 @@ export default class MarkersLayer extends Layer<MarkersLayerDesign> {
209
262
  return markersQuery
210
263
  }
211
264
 
212
- // Gets the layer definition as JsonQL + CSS in format:
213
- // {
214
- // layers: array of { id: layer id, jsonql: jsonql that includes "the_webmercator_geom" as a column }
215
- // css: carto css
216
- // interactivity: (optional) { layer: id of layer, fields: array of field names }
217
- // }
218
- // arguments:
219
- // design: design of layer
220
- // schema: schema to use
221
- // filters: array of filters to apply. Each is { table: table id, jsonql: jsonql condition with {alias} for tableAlias. Use injectAlias to put in table alias
222
- getJsonQLCss(design: MarkersLayerDesign, schema: Schema, filters: JsonQLFilter[]) {
223
- // Create design
224
- const layerDef = {
225
- layers: [
226
- {
227
- id: "layer0",
228
- jsonql: this.createMapnikJsonQL(design, schema, filters)
229
- }
230
- ],
231
- css: this.createCss(design),
232
- interactivity: {
233
- layer: "layer0",
234
- fields: ["id"]
235
- }
236
- }
237
-
238
- return layerDef
239
- }
240
-
241
- createMapnikJsonQL(design: MarkersLayerDesign, schema: Schema, filters: JsonQLFilter[]): JsonQLQuery {
242
- const axisBuilder = new AxisBuilder({ schema })
243
- const exprCompiler = new ExprCompiler(schema)
244
-
245
- // Compile geometry axis
246
- let geometryExpr = axisBuilder.compileAxis({ axis: design.axes.geometry, tableAlias: "innerquery" })
247
-
248
- // row_number() over (partition by round(ST_XMin(location)/!(pixel_width!*5)), round(ST_YMin(location)/(!pixel_height!*5))) AS r
249
- const cluster: JsonQLSelect = {
250
- type: "select",
251
- expr: {
252
- type: "op",
253
- op: "row_number",
254
- exprs: [],
255
- over: {
256
- partitionBy: [
257
- {
258
- type: "op",
259
- op: "round",
260
- exprs: [
261
- {
262
- type: "op",
263
- op: "/",
264
- exprs: [
265
- { type: "op", op: "ST_XMin", exprs: [geometryExpr] },
266
- { type: "op", op: "*", exprs: [{ type: "token", token: "!pixel_width!" }, 5] }
267
- ]
268
- }
269
- ]
270
- },
271
- {
272
- type: "op",
273
- op: "round",
274
- exprs: [
275
- {
276
- type: "op",
277
- op: "/",
278
- exprs: [
279
- { type: "op", op: "ST_YMin", exprs: [geometryExpr] },
280
- { type: "op", op: "*", exprs: [{ type: "token", token: "!pixel_height!" }, 5] }
281
- ]
282
- }
283
- ]
284
- }
285
- ]
286
- }
287
- },
288
- alias: "r"
289
- }
290
-
291
- // Select _id, location and clustered row number
292
- const innerquery: JsonQLSelectQuery = {
293
- type: "query",
294
- selects: [
295
- {
296
- type: "select",
297
- expr: { type: "field", tableAlias: "innerquery", column: schema.getTable(design.table)!.primaryKey },
298
- alias: "id"
299
- }, // main primary key as id
300
- { type: "select", expr: geometryExpr, alias: "the_geom_webmercator" }, // geometry as the_geom_webmercator
301
- cluster
302
- ],
303
- from: exprCompiler.compileTable(design.table, "innerquery")
304
- }
305
-
306
- // Add color select if color axis
307
- if (design.axes.color) {
308
- const colorExpr = axisBuilder.compileAxis({ axis: design.axes.color, tableAlias: "innerquery" })
309
- innerquery.selects.push({ type: "select", expr: colorExpr, alias: "color" })
310
- }
311
-
312
- // Create filters. First limit to bounding box
313
- let whereClauses: JsonQLExpr[] = [
314
- {
315
- type: "op",
316
- op: "&&",
317
- exprs: [geometryExpr, { type: "token", token: "!bbox!" }]
318
- }
319
- ]
320
-
321
- // Then add filters baked into layer
322
- if (design.filter) {
323
- whereClauses.push(exprCompiler.compileExpr({ expr: design.filter, tableAlias: "innerquery" }))
324
- }
325
-
326
- // Then add extra filters passed in, if relevant
327
- // Get relevant filters
328
- const relevantFilters = _.where(filters, { table: design.table })
329
- for (let filter of relevantFilters) {
330
- whereClauses.push(injectTableAlias(filter.jsonql, "innerquery"))
331
- }
332
-
333
- whereClauses = _.compact(whereClauses)
334
-
335
- // Wrap if multiple
336
- if (whereClauses.length > 1) {
337
- innerquery.where = { type: "op", op: "and", exprs: whereClauses }
338
- } else {
339
- innerquery.where = whereClauses[0]
340
- }
341
-
342
- // Create outer query which takes where r <= 3 to limit # of points in a cluster
343
- const outerquery: JsonQLQuery = {
344
- type: "query",
345
- selects: [
346
- {
347
- type: "select",
348
- expr: { type: "field", tableAlias: "innerquery", column: "id" },
349
- alias: "id"
350
- }, // innerquery._id as id
351
- {
352
- type: "select",
353
- expr: { type: "field", tableAlias: "innerquery", column: "the_geom_webmercator" },
354
- alias: "the_geom_webmercator"
355
- }, // innerquery.the_geom_webmercator as the_geom_webmercator
356
- {
357
- type: "select",
358
- expr: {
359
- type: "op",
360
- op: "ST_GeometryType",
361
- exprs: [{ type: "field", tableAlias: "innerquery", column: "the_geom_webmercator" }]
362
- },
363
- alias: "geometry_type"
364
- } // ST_GeometryType(innerquery.the_geom_webmercator) as geometry_type
365
- ],
366
- from: { type: "subquery", query: innerquery, alias: "innerquery" },
367
- where: { type: "op", op: "<=", exprs: [{ type: "field", tableAlias: "innerquery", column: "r" }, 3] }
368
- }
369
-
370
- // Add color select if color axis
371
- if (design.axes.color) {
372
- outerquery.selects.push({
373
- type: "select",
374
- expr: { type: "field", tableAlias: "innerquery", column: "color" },
375
- alias: "color"
376
- }) // innerquery.color as color
377
- }
378
-
379
- return outerquery
380
- }
381
-
382
- // Creates CartoCSS
383
- createCss(design: MarkersLayerDesign) {
384
- let stroke, symbol
385
- let css = ""
386
-
387
- if (design.symbol) {
388
- symbol = `marker-file: url(${design.symbol});`
389
- stroke = "marker-line-width: 60;"
390
- } else {
391
- symbol = "marker-type: ellipse;"
392
- stroke = "marker-line-width: 1;"
393
- }
394
-
395
- // Should only display markers when it is a point geometry
396
- css +=
397
- `\
398
- #layer0[geometry_type='ST_Point'] {
399
- marker-fill: ` +
400
- (design.color || "#666666") +
401
- `;
402
- marker-width: ` +
403
- (design.markerSize || 10) +
404
- `;
405
- marker-line-color: white;\
406
- ` +
407
- stroke +
408
- `\
409
- marker-line-opacity: 0.6;
410
- marker-placement: point;\
411
- ` +
412
- symbol +
413
- `\
414
- marker-allow-overlap: true;
415
- }
416
- #layer0 {
417
- line-color: ` +
418
- (design.color || "#666666") +
419
- `;
420
- line-width: ` +
421
- (design.lineWidth != null ? design.lineWidth : "3") +
422
- `;
423
- }
424
- #layer0[geometry_type='ST_Polygon'],#layer0[geometry_type='ST_MultiPolygon'] {
425
- polygon-fill: ` +
426
- (design.color || "#666666") +
427
- `;
428
- polygon-opacity: ${design.polygonFillOpacity ?? 0.25};
429
- }
430
- \
431
- `
432
-
433
- // If color axes, add color conditions
434
- if (design.axes.color && design.axes.color.colorMap) {
435
- for (let item of design.axes.color.colorMap) {
436
- // If invisible
437
- if (_.includes(design.axes.color.excludedValues || [], item.value)) {
438
- css +=
439
- `\
440
- #layer0[color=` +
441
- JSON.stringify(item.value) +
442
- `] { line-opacity: 0; marker-line-opacity: 0; marker-fill-opacity: 0; polygon-opacity: 0; }\
443
- `
444
- } else {
445
- css +=
446
- `\
447
- #layer0[color=` +
448
- JSON.stringify(item.value) +
449
- "] { line-color: " +
450
- item.color +
451
- ` }
452
- #layer0[color=` +
453
- JSON.stringify(item.value) +
454
- "][geometry_type='ST_Point'] { marker-fill: " +
455
- item.color +
456
- ` }
457
- #layer0[color=` +
458
- JSON.stringify(item.value) +
459
- "][geometry_type='ST_Polygon'],#layer0[color=" +
460
- JSON.stringify(item.value) +
461
- `][geometry_type='ST_MultiPolygon'] {
462
- polygon-fill: ` +
463
- item.color +
464
- `;\
465
- ${design.polygonBorderColor ? "line-color: " + design.polygonBorderColor + ";" : ""}\
466
- }\
467
- `
468
- }
469
- }
470
- }
471
-
472
- return css
473
- }
474
-
475
265
  // same as onGridClick but handles hover over
476
266
  onGridHoverOver(
477
267
  ev: { data: any; event: any },
@@ -652,6 +442,15 @@ ${design.polygonBorderColor ? "line-color: " + design.polygonBorderColor + ";" :
652
442
  }
653
443
 
654
444
  const axisBuilder = new AxisBuilder({ schema })
445
+
446
+ // Clean and translate axis
447
+ const axis = translateAxis(axisBuilder.cleanAxis({
448
+ axis: design.axes.color || null,
449
+ table: design.table,
450
+ types: ["enum", "text", "boolean", "date"],
451
+ aggrNeed: "none"
452
+ }), translate)
453
+
655
454
  return React.createElement(LayerLegendComponent, {
656
455
  schema,
657
456
  defaultColor: design.color,
@@ -659,12 +458,7 @@ ${design.polygonBorderColor ? "line-color: " + design.polygonBorderColor + ";" :
659
458
  markerSize: design.markerSize,
660
459
  name: translate(name),
661
460
  filters: _.compact(_filters),
662
- axis: axisBuilder.cleanAxis({
663
- axis: design.axes.color || null,
664
- table: design.table,
665
- types: ["enum", "text", "boolean", "date"],
666
- aggrNeed: "none"
667
- }),
461
+ axis,
668
462
  locale
669
463
  })
670
464
  }
@@ -739,6 +533,12 @@ ${design.polygonBorderColor ? "line-color: " + design.polygonBorderColor + ";" :
739
533
  types: ["enum", "text", "boolean", "date"],
740
534
  aggrNeed: "none"
741
535
  })!
536
+ draft.axes.label = axisBuilder.cleanAxis({
537
+ axis: draft.axes.label ? original(draft.axes.label) || null : null,
538
+ table: design.table,
539
+ types: ["text", "number"],
540
+ aggrNeed: "none"
541
+ }) || undefined
742
542
 
743
543
  draft.filter = exprCleaner.cleanExpr(design.filter || null, { table: draft.table })
744
544
 
@@ -783,6 +583,12 @@ ${design.polygonBorderColor ? "line-color: " + design.polygonBorderColor + ";" :
783
583
  return error
784
584
  }
785
585
 
586
+ // Validate label
587
+ error = axisBuilder.validateAxis({ axis: design.axes.label || null })
588
+ if (error) {
589
+ return error
590
+ }
591
+
786
592
  // Check that doesn't compile to null (persistent bug that haven't been able to track down)
787
593
  if (!axisBuilder.compileAxis({ axis: design.axes.geometry, tableAlias: "innerquery" })) {
788
594
  return "Null geometry axis"
@@ -801,6 +607,9 @@ ${design.polygonBorderColor ? "line-color: " + design.polygonBorderColor + ";" :
801
607
  getTranslatableStrings(design: MarkersLayerDesign, schema: Schema): string[] {
802
608
  const strings: string[] = []
803
609
 
610
+ // Add strings from axis category labels and null labels
611
+ strings.push(...getTranslatableStringsFromAxis(design.axes.color))
612
+
804
613
  // Add strings from hoverOver items
805
614
  if (design.hoverOver && design.hoverOver.items) {
806
615
  for (const item of design.hoverOver.items) {
@@ -16,8 +16,14 @@ export interface MarkersLayerDesign {
16
16
 
17
17
  /** Color axis (to split into series based on a color) */
18
18
  color?: Axis
19
+
20
+ /** Label expression to display on/near markers (points only) */
21
+ label?: Axis
19
22
  }
20
23
 
24
+ /** Position of label relative to marker. Default "bottom" */
25
+ labelPosition?: "top" | "bottom" | "left" | "right"
26
+
21
27
  /** Optional logical expression to filter by */
22
28
  filter?: Expr
23
29