@uwdata/mosaic-plot 0.7.1 → 0.9.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 (37) hide show
  1. package/dist/mosaic-plot.js +4702 -5648
  2. package/dist/mosaic-plot.min.js +14 -14
  3. package/package.json +5 -5
  4. package/src/index.js +1 -0
  5. package/src/interactors/Highlight.js +6 -3
  6. package/src/interactors/Interval1D.js +14 -12
  7. package/src/interactors/Interval2D.js +13 -16
  8. package/src/interactors/Nearest.js +80 -36
  9. package/src/interactors/PanZoom.js +7 -9
  10. package/src/interactors/Toggle.js +29 -37
  11. package/src/interactors/util/patchScreenCTM.js +2 -0
  12. package/src/legend.js +150 -29
  13. package/src/marks/ConnectedMark.js +6 -0
  14. package/src/marks/ContourMark.js +36 -16
  15. package/src/marks/DenseLineMark.js +9 -5
  16. package/src/marks/Density1DMark.js +22 -13
  17. package/src/marks/Density2DMark.js +33 -18
  18. package/src/marks/ErrorBarMark.js +50 -0
  19. package/src/marks/GeoMark.js +7 -8
  20. package/src/marks/Grid2DMark.js +58 -28
  21. package/src/marks/HexbinMark.js +10 -2
  22. package/src/marks/Mark.js +56 -16
  23. package/src/marks/RasterMark.js +61 -23
  24. package/src/marks/RasterTileMark.js +39 -20
  25. package/src/marks/RegressionMark.js +69 -34
  26. package/src/marks/util/grid.js +94 -86
  27. package/src/marks/util/handle-param.js +10 -11
  28. package/src/marks/util/is-constant-option.js +2 -1
  29. package/src/marks/util/permute.js +10 -0
  30. package/src/marks/util/stats.js +121 -1
  31. package/src/marks/util/to-data-columns.js +71 -0
  32. package/src/plot-attributes.js +11 -3
  33. package/src/plot-renderer.js +28 -9
  34. package/src/plot.js +20 -0
  35. package/src/transforms/bin.js +3 -1
  36. package/src/marks/util/interpolate.js +0 -205
  37. package/src/marks/util/to-data-array.js +0 -50
package/src/legend.js CHANGED
@@ -1,48 +1,48 @@
1
+ import { scale } from '@observablehq/plot';
2
+ import { Interval1D } from './interactors/Interval1D.js';
1
3
  import { Toggle } from './interactors/Toggle.js';
2
4
 
5
+ const TOGGLE_SELECTOR = ':scope > div, :scope > span';
6
+ const SWATCH = 'swatch';
7
+ const RAMP = 'ramp';
8
+
3
9
  export class Legend {
4
10
  constructor(channel, options) {
5
- const { as, ...rest } = options;
11
+ const { as, field, ...rest } = options;
6
12
  this.channel = channel;
7
13
  this.options = { label: null, ...rest };
14
+ this.type = null;
15
+ this.handler = null;
8
16
  this.selection = as;
17
+ this.field = field;
18
+ this.legend = null;
9
19
 
10
20
  this.element = document.createElement('div');
11
21
  this.element.setAttribute('class', 'legend');
12
- this.element.value = this;
22
+ Object.defineProperty(this.element, 'value', { value: this });
13
23
  }
14
24
 
15
25
  setPlot(plot) {
16
- const { channel, selection } = this;
17
- const mark = findMark(plot, channel);
18
- if (this.selection && mark) {
19
- this.handler = new Toggle(mark, { selection, channels: [channel] });
20
- this.selection.addEventListener('value', () => this.update());
21
- }
26
+ this.plot = plot;
22
27
  }
23
28
 
24
29
  init(svg) {
25
- const { channel, options, handler } = this;
26
- const scale = svg.scale(channel);
27
- const opt = scale.type === 'ordinal'
28
- ? options
29
- : { marginTop: 1, tickSize: 2, height: 28, ...options };
30
- this.legend = svg.legend(channel, opt);
31
-
32
- if (handler) {
33
- handler.init(this.legend, ':scope > div', el => [el.__data__]);
34
- this.update();
35
- }
36
-
37
- this.element.replaceChildren(this.legend);
30
+ // createLegend sets this.legend, may set this.handler
31
+ const el = createLegend(this, svg);
32
+ this.element.replaceChildren(el);
38
33
  return this.element;
39
34
  }
40
35
 
41
36
  update() {
42
37
  if (!this.legend) return;
43
- const { value } = this.selection;
44
- const curr = value && value.length ? new Set(value.map(v => v[0])) : null;
45
- const nodes = this.legend.querySelectorAll(':scope > div');
38
+ const { selection, handler } = this;
39
+ const { single, value } = selection;
40
+
41
+ // extract currently selected values
42
+ const vals = single ? value : selection.valueFor(handler);
43
+ const curr = vals && vals.length ? new Set(vals.map(v => v[0])) : null;
44
+
45
+ const nodes = this.legend.querySelectorAll(TOGGLE_SELECTOR);
46
46
  for (const node of nodes) {
47
47
  const selected = curr ? curr.has(node.__data__) : true;
48
48
  node.style.opacity = selected ? 1 : 0.2;
@@ -50,17 +50,138 @@ export class Legend {
50
50
  }
51
51
  }
52
52
 
53
- function findMark({ marks }, channel) {
53
+ function createLegend(legend, svg) {
54
+ const { channel, options, selection } = legend;
55
+ const scale = svg.scale(channel);
56
+ const type = scale.type === 'ordinal' ? SWATCH : RAMP;
57
+
58
+ // labels for swatch legends are not yet supported by Plot
59
+ // track here: https://github.com/observablehq/plot/issues/834
60
+ // for consistent layout, adjust sizing when there is no label
61
+ const opt = type === SWATCH ? options
62
+ : options.label ? { tickSize: 2, ...options }
63
+ : { tickSize: 2, marginTop: 1, height: 29, ...options };
64
+
65
+ // instantiate new legend element, bind to Legend class
66
+ const el = svg.legend(channel, opt);
67
+ legend.legend = el;
68
+
69
+ // if this is an interactive legend, add a scale lookup function
70
+ // this allows interval interactors to access encoding information
71
+ let interactive = !!selection;
72
+ if (interactive && type === RAMP) {
73
+ const width = opt.width ?? 240; // 240 is default ramp length
74
+ const spatial = spatialScale(scale, width);
75
+ if (spatial) {
76
+ el.scale = function(type) {
77
+ return type === 'x' ? { range: [0, width] }
78
+ : type === 'y' ? { range: [-10, 0] }
79
+ : type === channel ? spatial
80
+ : undefined;
81
+ };
82
+ } else {
83
+ // spatial scale construction failed, disable interaction
84
+ interactive = false;
85
+ }
86
+ }
87
+
88
+ // initialize interactors to use updated legend element
89
+ if (interactive) {
90
+ const handler = getInteractor(legend, type);
91
+ if (type === SWATCH) {
92
+ handler.init(el, TOGGLE_SELECTOR, el => [el.__data__]);
93
+ legend.update();
94
+ } else {
95
+ handler.init(el, el.querySelector('g:last-of-type'));
96
+ }
97
+ }
98
+
99
+ return el;
100
+ }
101
+
102
+ function getInteractor(legend, type) {
103
+ const { channel, handler, selection } = legend;
104
+
105
+ // exit early if already instantiated
106
+ if (handler) return handler;
107
+
108
+ // otherwise instantiate an appropriate interactor
109
+ const mark = interactorMark(legend);
110
+ if (type === SWATCH) {
111
+ legend.handler = new Toggle(mark, {
112
+ selection,
113
+ channels: [channel],
114
+ peers: false
115
+ });
116
+ selection.addEventListener('value', () => legend.update());
117
+ } else {
118
+ legend.handler = new Interval1D(mark, {
119
+ selection,
120
+ channel,
121
+ brush: { fill: 'none', stroke: 'currentColor' },
122
+ peers: false
123
+ });
124
+ }
125
+
126
+ return legend.handler;
127
+ }
128
+
129
+ // generate a faux mark to pass to an interactor
130
+ function interactorMark(legend) {
131
+ const { channel, plot } = legend;
132
+ const field = legend.field ?? findField(plot.marks, channel) ?? 'value';
133
+ if (field) {
134
+ const f = { field };
135
+ return { plot, channelField: c => channel === c ? f : undefined };
136
+ }
137
+ }
138
+
139
+ // search marks for a backing data field for the legend
140
+ function findField(marks, channel) {
54
141
  const channels = channel === 'color' ? ['fill', 'stroke']
55
142
  : channel === 'opacity' ? ['opacity', 'fillOpacity', 'strokeOpacity']
56
143
  : null;
57
144
  if (channels == null) return null;
58
145
  for (let i = marks.length - 1; i > -1; --i) {
59
- for (const channel of channels) {
60
- if (marks[i].channelField(channel, { exact: true })) {
61
- return marks[i];
62
- }
146
+ for (const c of channels) {
147
+ const field = marks[i].channelField(c, { exact: true });
148
+ if (field) return field.field;
63
149
  }
64
150
  }
65
151
  return null;
66
152
  }
153
+
154
+ // generate a spatial scale to brush within color or opacity ramps
155
+ function spatialScale(sourceScale, width) {
156
+ // separate out reusable parts of the scale definition
157
+ // eslint-disable-next-line no-unused-vars
158
+ const { apply, invert, interpolate, ...rest } = sourceScale;
159
+
160
+ // extract basic source scale type
161
+ let src = sourceScale.type;
162
+ if (src.startsWith('diverging-')) src = src.slice(11);
163
+
164
+ // determine spatial scale type
165
+ let type;
166
+ switch (src) {
167
+ case 'log':
168
+ case 'pow':
169
+ case 'sqrt':
170
+ case 'symlog':
171
+ type = src;
172
+ break;
173
+ case 'threshold':
174
+ case 'quantize':
175
+ case 'quantile':
176
+ // these scales do not expose an invert method
177
+ // the legends use color ramps with discrete swatches
178
+ // in the future we could try to support toggle-style
179
+ // interactions that map to threshold range selections
180
+ console.warn(`Legends do not yet support ${src} scales.`);
181
+ return null;
182
+ default:
183
+ type = 'linear';
184
+ }
185
+
186
+ return scale({ x: { ...rest, type, range: [0, width] } });
187
+ }
@@ -11,6 +11,11 @@ export class ConnectedMark extends Mark {
11
11
  this.dim = dim;
12
12
  }
13
13
 
14
+ /**
15
+ * Return a query specifying the data needed by this Mark client.
16
+ * @param {*} [filter] The filtering criteria to apply in the query.
17
+ * @returns {*} The client query
18
+ */
14
19
  query(filter = []) {
15
20
  const { plot, dim, source } = this;
16
21
  const { optimize = true } = source.options || {};
@@ -28,6 +33,7 @@ export class ConnectedMark extends Mark {
28
33
  const [lo, hi] = filteredExtent(filter, field) || [min, max];
29
34
  const [expr] = binExpr(this, dim, size, [lo, hi], 1, as);
30
35
  const cols = q.select()
36
+ // @ts-ignore
31
37
  .map(c => c.as)
32
38
  .filter(c => c !== as && c !== value);
33
39
  return m4(q, expr, as, value, cols);
@@ -13,8 +13,11 @@ export class ContourMark extends Grid2DMark {
13
13
  pixelSize: 2,
14
14
  ...channels
15
15
  });
16
- handleParam(this, 'thresholds', thresholds, () => {
17
- return this.grids ? this.contours().update() : null
16
+
17
+ /** @type {number|number[]} */
18
+ this.thresholds = handleParam(thresholds, value => {
19
+ this.thresholds = value;
20
+ return this.grids ? this.contours().update() : null;
18
21
  });
19
22
  }
20
23
 
@@ -23,12 +26,16 @@ export class ContourMark extends Grid2DMark {
23
26
  }
24
27
 
25
28
  contours() {
26
- const { bins, densityMap, kde, thresholds, plot } = this;
29
+ const { bins, densityMap, grids, thresholds, plot } = this;
30
+ const { numRows, columns } = grids;
27
31
 
28
- let tz = thresholds;
29
- if (!Array.isArray(tz)) {
30
- const [, hi] = gridDomainContinuous(kde, 'density');
31
- tz = Array.from({length: tz - 1}, (_, i) => (hi * (i + 1)) / tz);
32
+ let t = thresholds;
33
+ let tz;
34
+ if (Array.isArray(t)) {
35
+ tz = t;
36
+ } else {
37
+ const [, hi] = gridDomainContinuous(columns.density);
38
+ tz = Array.from({length: t - 1}, (_, i) => (hi * (i + 1)) / t);
32
39
  }
33
40
 
34
41
  if (densityMap.fill || densityMap.stroke) {
@@ -51,18 +58,27 @@ export class ContourMark extends Grid2DMark {
51
58
  const contour = contours().size(bins);
52
59
 
53
60
  // generate contours
54
- this.data = kde.flatMap(cell => tz.map(t => {
55
- return Object.assign(
56
- transform(contour.contour(cell.density, t), x, y),
57
- { ...cell, density: t }
58
- );
59
- }));
61
+ const data = this.contourData = Array(numRows * tz.length);
62
+ const { density, ...groupby } = columns;
63
+ const groups = Object.entries(groupby);
64
+ for (let i = 0, k = 0; i < numRows; ++i) {
65
+ const grid = density[i];
66
+ const rest = groups.reduce((o, [name, col]) => (o[name] = col[i], o), {});
67
+ for (let j = 0; j < tz.length; ++j, ++k) {
68
+ // annotate contour geojson with cell groupby fields
69
+ // d3-contour already adds a threshold "value" property
70
+ data[k] = Object.assign(
71
+ transform(contour.contour(grid, tz[j]), x, y),
72
+ rest
73
+ );
74
+ }
75
+ }
60
76
 
61
77
  return this;
62
78
  }
63
79
 
64
80
  plotSpecs() {
65
- const { type, channels, densityMap, data } = this;
81
+ const { type, channels, densityMap, contourData: data } = this;
66
82
  const options = {};
67
83
  for (const c of channels) {
68
84
  const { channel } = c;
@@ -70,8 +86,12 @@ export class ContourMark extends Grid2DMark {
70
86
  options[channel] = channelOption(c);
71
87
  }
72
88
  }
73
- if (densityMap.fill) options.fill = 'density';
74
- if (densityMap.stroke) options.stroke = 'density';
89
+ // d3-contour adds a threshold "value" property
90
+ // here we ensure requested density values are encoded
91
+ for (const channel in densityMap) {
92
+ if (!densityMap[channel]) continue;
93
+ options[channel] = channelOption({ channel, as: 'value' });
94
+ }
75
95
  return [{ type, data, options }];
76
96
  }
77
97
  }
@@ -8,14 +8,18 @@ export class DenseLineMark extends RasterMark {
8
8
  constructor(source, options) {
9
9
  const { normalize = true, ...rest } = options;
10
10
  super(source, rest);
11
- handleParam(this, 'normalize', normalize);
11
+
12
+ /** @type {boolean} */
13
+ this.normalize = handleParam(normalize, value => {
14
+ return (this.normalize = value, this.requestUpdate());
15
+ });
12
16
  }
13
17
 
14
18
  query(filter = []) {
15
- const { channels, normalize, source, binPad } = this;
16
- const [nx, ny] = this.bins = this.binDimensions(this);
17
- const [x] = binExpr(this, 'x', nx, extentX(this, filter), binPad);
18
- const [y] = binExpr(this, 'y', ny, extentY(this, filter), binPad);
19
+ const { channels, normalize, source, pad } = this;
20
+ const [nx, ny] = this.bins = this.binDimensions();
21
+ const [x] = binExpr(this, 'x', nx, extentX(this, filter), pad);
22
+ const [y] = binExpr(this, 'y', ny, extentY(this, filter), pad);
19
23
 
20
24
  const q = Query
21
25
  .from(source.table)
@@ -6,6 +6,7 @@ import { extentX, extentY, xext, yext } from './util/extent.js';
6
6
  import { grid1d } from './util/grid.js';
7
7
  import { handleParam } from './util/handle-param.js';
8
8
  import { Mark, channelOption, markQuery } from './Mark.js';
9
+ import { toDataColumns } from './util/to-data-columns.js';
9
10
 
10
11
  export class Density1DMark extends Mark {
11
12
  constructor(type, source, options) {
@@ -15,9 +16,15 @@ export class Density1DMark extends Mark {
15
16
  super(type, source, channels, dim === 'x' ? xext : yext);
16
17
  this.dim = dim;
17
18
 
18
- handleParam(this, 'bins', bins);
19
- handleParam(this, 'bandwidth', bandwidth, () => {
20
- return this.grid ? this.convolve().update() : null
19
+ /** @type {number} */
20
+ this.bins = handleParam(bins, value => {
21
+ return (this.bins = value, this.requestUpdate());
22
+ });
23
+
24
+ /** @type {number} */
25
+ this.bandwidth = handleParam(bandwidth, value => {
26
+ this.bandwidth = value;
27
+ return this.grid ? this.convolve().update() : null;
21
28
  });
22
29
  }
23
30
 
@@ -39,7 +46,8 @@ export class Density1DMark extends Mark {
39
46
  }
40
47
 
41
48
  queryResult(data) {
42
- this.grid = grid1d(this.bins, data);
49
+ const { columns: { index, density } } = toDataColumns(data);
50
+ this.grid = grid1d(this.bins, index, density);
43
51
  return this.convolve();
44
52
  }
45
53
 
@@ -53,29 +61,30 @@ export class Density1DMark extends Mark {
53
61
  const result = dericheConv1d(config, grid, bins);
54
62
 
55
63
  // map smoothed grid values to sample data points
56
- const points = this.data = [];
57
64
  const v = dim === 'x' ? 'y' : 'x';
58
65
  const b = this.channelField(dim).as;
59
66
  const b0 = +lo;
60
67
  const delta = (hi - b0) / (bins - 1);
61
68
  const scale = 1 / delta;
69
+
70
+ const _b = new Float64Array(bins);
71
+ const _v = new Float64Array(bins);
62
72
  for (let i = 0; i < bins; ++i) {
63
- points.push({
64
- [b]: b0 + i * delta,
65
- [v]: result[i] * scale
66
- });
73
+ _b[i] = b0 + i * delta;
74
+ _v[i] = result[i] * scale;
67
75
  }
76
+ this.data = { numRows: bins, columns: { [b]: _b, [v]: _v } };
68
77
 
69
78
  return this;
70
79
  }
71
80
 
72
81
  plotSpecs() {
73
- const { type, data, channels, dim } = this;
74
- const options = dim === 'x' ? { y: 'y' } : { x: 'x' };
82
+ const { type, data: { numRows: length, columns }, channels, dim } = this;
83
+ const options = dim === 'x' ? { y: columns.y } : { x: columns.x };
75
84
  for (const c of channels) {
76
- options[c.channel] = channelOption(c);
85
+ options[c.channel] = channelOption(c, columns);
77
86
  }
78
- return [{ type, data, options }];
87
+ return [{ type, data: { length }, options }];
79
88
  }
80
89
  }
81
90
 
@@ -26,46 +26,61 @@ export class Density2DMark extends Grid2DMark {
26
26
  const deltaY = (y1 - y0) / (ny - pad);
27
27
  const offset = pad ? 0 : 0.5;
28
28
  this.data = points(
29
- this.kde, bins, x0, y0, deltaX, deltaY,
29
+ this.grids, bins, x0, y0, deltaX, deltaY,
30
30
  scaleX.invert, scaleY.invert, offset
31
31
  );
32
32
  return this;
33
33
  }
34
34
 
35
35
  plotSpecs() {
36
- const { type, channels, densityMap, data } = this;
36
+ const { type, channels, densityMap, data: { numRows: length, columns } } = this;
37
37
  const options = {};
38
38
  for (const c of channels) {
39
39
  const { channel } = c;
40
40
  options[channel] = (channel === 'x' || channel === 'y')
41
- ? channel // use generated x/y data fields
42
- : channelOption(c);
41
+ ? columns[channel] // use generated x/y data fields
42
+ : channelOption(c, columns);
43
43
  }
44
44
  for (const channel in densityMap) {
45
45
  if (densityMap[channel]) {
46
- options[channel] = 'density';
46
+ options[channel] = columns.density;
47
47
  }
48
48
  }
49
- return [{ type, data, options }];
49
+ return [{ type, data: { length }, options }];
50
50
  }
51
51
  }
52
52
 
53
- function points(kde, bins, x0, y0, deltaX, deltaY, invertX, invertY, offset) {
53
+ function points(data, bins, x0, y0, deltaX, deltaY, invertX, invertY, offset) {
54
54
  const scale = 1 / (deltaX * deltaY);
55
55
  const [nx, ny] = bins;
56
- const data = [];
57
- for (const cell of kde) {
58
- const grid = cell.density;
56
+ const batch = nx * ny;
57
+ const numRows = batch * data.numRows;
58
+
59
+ const x = new Float64Array(numRows);
60
+ const y = new Float64Array(numRows);
61
+ const density = new Float64Array(numRows);
62
+ const columns = { x, y, density };
63
+ const { density: grids, ...rest } = data.columns;
64
+ for (const name in rest) {
65
+ columns[name] = new rest[name].constructor(numRows);
66
+ }
67
+
68
+ let r = 0;
69
+ for (let row = 0; row < data.numRows; ++row) {
70
+ // copy repeated values in batch
71
+ for (const name in rest) {
72
+ columns[name].fill(rest[name][row], r, r + batch);
73
+ }
74
+ // copy individual grid values
75
+ const grid = grids[row];
59
76
  for (let k = 0, j = 0; j < ny; ++j) {
60
- for (let i = 0; i < nx; ++i, ++k) {
61
- data.push({
62
- ...cell,
63
- x: invertX(x0 + (i + offset) * deltaX),
64
- y: invertY(y0 + (j + offset) * deltaY),
65
- density: grid[k] * scale
66
- });
77
+ for (let i = 0; i < nx; ++i, ++r, ++k) {
78
+ x[r] = invertX(x0 + (i + offset) * deltaX);
79
+ y[r] = invertY(y0 + (j + offset) * deltaY);
80
+ density[r] = grid[k] * scale;
67
81
  }
68
82
  }
69
83
  }
70
- return data;
84
+
85
+ return { numRows, columns };
71
86
  }
@@ -0,0 +1,50 @@
1
+ import { avg, count, stddev } from '@uwdata/mosaic-sql';
2
+ import { erfinv } from './util/stats.js';
3
+ import { Mark, markPlotSpec, markQuery } from './Mark.js';
4
+ import { handleParam } from './util/handle-param.js';
5
+ import { toDataColumns } from './util/to-data-columns.js';
6
+
7
+ export class ErrorBarMark extends Mark {
8
+ constructor(type, source, options) {
9
+ const dim = type.endsWith('X') ? 'y' : 'x';
10
+ const { ci = 0.95, ...channels } = options;
11
+ super(type, source, channels);
12
+ this.dim = dim;
13
+ this.field = this.channelField(dim).field;
14
+ this.channels = this.channels.filter(c => c.channel !== dim);
15
+
16
+ /** @type {number} */
17
+ this.ci = handleParam(ci, value => {
18
+ return (this.ci = value, this.update());
19
+ });
20
+ }
21
+
22
+ query(filter = []) {
23
+ const { channels, field, source: { table } } = this;
24
+ const fields = channels.concat([
25
+ { field: avg(field), as: '__avg__' },
26
+ { field: count(field), as: '__n__', },
27
+ { field: stddev(field), as: '__sd__' }
28
+ ]);
29
+ return markQuery(fields, table).where(filter);
30
+ }
31
+
32
+ queryResult(data) {
33
+ this.data = toDataColumns(data);
34
+ return this;
35
+ }
36
+
37
+ plotSpecs() {
38
+ const { type, dim, detail, data, ci, channels } = this;
39
+
40
+ // compute confidence interval channels
41
+ const p = Math.SQRT2 * erfinv(ci);
42
+ const { columns: { __avg__: u, __sd__: s, __n__: n } } = data;
43
+ const options = {
44
+ [`${dim}1`]: u.map((u, i) => u - p * s[i] / Math.sqrt(n[i])),
45
+ [`${dim}2`]: u.map((u, i) => u + p * s[i] / Math.sqrt(n[i]))
46
+ };
47
+
48
+ return markPlotSpec(type, detail, channels, data, options);
49
+ }
50
+ }
@@ -18,16 +18,15 @@ export class GeoMark extends Mark {
18
18
  }
19
19
 
20
20
  queryResult(data) {
21
- super.queryResult(data);
21
+ super.queryResult(data); // map to columns, set this.data
22
22
 
23
- // parse GeoJSON strings to JSON objects
23
+ // look for an explicit geometry field
24
24
  const geom = this.channelField('geometry')?.as;
25
- if (geom && this.data) {
26
- this.data.forEach(data => {
27
- if (typeof data[geom] === 'string') {
28
- data[geom] = JSON.parse(data[geom]);
29
- }
30
- });
25
+ if (geom) {
26
+ const { columns } = this.data;
27
+ if (typeof columns[geom][0] === 'string') {
28
+ columns[geom] = columns[geom].map(s => JSON.parse(s));
29
+ }
31
30
  }
32
31
 
33
32
  return this;