gladly-plot 0.0.2 → 0.0.4

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.
package/README.md CHANGED
@@ -17,6 +17,7 @@ Gladly combines WebGL rendering (via regl) with D3.js for interactive axes and z
17
17
  - 🎨 Supports all standard colorscales
18
18
  - 🔗 Subplot axis linking
19
19
  - 🌈 Axis to coloring or filtering linking
20
+ - 🌎 Basemap layer with XYZ,WMS and WMTS support and CRS reprojection
20
21
 
21
22
  ## Documentation
22
23
 
package/package.json CHANGED
@@ -1,30 +1,38 @@
1
1
  {
2
2
  "name": "gladly-plot",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "GPU-powered multi-axis plotting library with regl + d3",
5
5
  "type": "module",
6
-
7
6
  "exports": {
8
7
  ".": "./src/index.js"
9
8
  },
10
-
11
- "files": ["src"],
12
-
9
+ "files": [
10
+ "src"
11
+ ],
13
12
  "scripts": {
13
+ "prepare": "node scripts/fix-numjs-wasm.js",
14
14
  "dev": "parcel serve example/index.html --open",
15
15
  "build:example": "parcel build example/index.html --dist-dir dist-example --public-url ./",
16
16
  "preview": "npm run build:example && npx serve dist-example"
17
17
  },
18
-
19
18
  "dependencies": {
20
19
  "d3": "^7.8.5",
21
20
  "proj4": "^2.15.0",
22
21
  "projnames": "^0.0.2",
23
22
  "regl": "^2.1.0"
24
23
  },
24
+ "browserslist": [
25
+ "last 2 Chrome versions",
26
+ "last 2 Edge versions",
27
+ "last 2 Firefox versions",
28
+ "last 2 Safari versions"
29
+ ],
25
30
 
26
31
  "devDependencies": {
32
+ "@jayce789/numjs": "^2.2.6",
33
+ "@json-editor/json-editor": "^2.15.1",
27
34
  "parcel": "^2.9.0",
28
- "@json-editor/json-editor": "^2.15.1"
35
+ "process": "^0.11.10",
36
+ "url": "^0.11.4"
29
37
  }
30
38
  }
@@ -41,7 +41,7 @@ export const colorbarLayerType = new LayerType({
41
41
  float r0 = color_scale_type > 0.5 ? log(color_range.x) : color_range.x;
42
42
  float r1 = color_scale_type > 0.5 ? log(color_range.y) : color_range.y;
43
43
  float v = r0 + tval * (r1 - r0);
44
- gl_FragColor = map_color(colorscale, vec2(r0, r1), v);
44
+ gl_FragColor = gladly_apply_color(map_color(colorscale, vec2(r0, r1), v));
45
45
  }
46
46
  `,
47
47
 
@@ -38,11 +38,14 @@ export function buildColorGlsl() {
38
38
  parts.push(' return vec4(0.5, 0.5, 0.5, 1.0);')
39
39
  parts.push('}')
40
40
 
41
- parts.push('vec4 map_color_s(int cs, vec2 range, float v, float scaleType) {')
41
+ parts.push('vec4 map_color_s(int cs, vec2 range, float v, float scaleType, float useAlpha) {')
42
42
  parts.push(' float vt = scaleType > 0.5 ? log(v) : v;')
43
43
  parts.push(' float r0 = scaleType > 0.5 ? log(range.x) : range.x;')
44
44
  parts.push(' float r1 = scaleType > 0.5 ? log(range.y) : range.y;')
45
- parts.push(' return map_color(cs, vec2(r0, r1), vt);')
45
+ parts.push(' float t = clamp((vt - r0) / (r1 - r0), 0.0, 1.0);')
46
+ parts.push(' vec4 color = map_color(cs, vec2(r0, r1), vt);')
47
+ parts.push(' if (useAlpha > 0.5) color.a = t;')
48
+ parts.push(' return gladly_apply_color(color);')
46
49
  parts.push('}')
47
50
 
48
51
  return parts.join('\n')
@@ -24,7 +24,7 @@ export const filterbarLayerType = new LayerType({
24
24
  `,
25
25
  frag: `
26
26
  precision mediump float;
27
- void main() { gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0); }
27
+ void main() { gl_FragColor = gladly_apply_color(vec4(0.0, 0.0, 0.0, 0.0)); }
28
28
  `,
29
29
 
30
30
  schema: () => ({
package/src/LayerType.js CHANGED
@@ -11,6 +11,44 @@ function buildSpatialGlsl() {
11
11
  }`
12
12
  }
13
13
 
14
+ function buildApplyColorGlsl() {
15
+ return `uniform float u_pickingMode;
16
+ uniform float u_pickLayerIndex;
17
+ varying float v_pickId;
18
+ vec4 gladly_apply_color(vec4 color) {
19
+ if (u_pickingMode > 0.5) {
20
+ float layerIdx = u_pickLayerIndex + 1.0;
21
+ float dataIdx = floor(v_pickId + 0.5);
22
+ return vec4(
23
+ layerIdx / 255.0,
24
+ floor(dataIdx / 65536.0) / 255.0,
25
+ floor(mod(dataIdx, 65536.0) / 256.0) / 255.0,
26
+ mod(dataIdx, 256.0) / 255.0
27
+ );
28
+ }
29
+ return color;
30
+ }`
31
+ }
32
+
33
+ function injectPickIdAssignment(src) {
34
+ const lastBrace = src.lastIndexOf('}')
35
+ if (lastBrace === -1) return src
36
+ return src.slice(0, lastBrace) + ' v_pickId = a_pickId;\n}'
37
+ }
38
+
39
+ function injectInto(src, helpers) {
40
+ const injected = helpers.filter(Boolean).join('\n')
41
+ if (!injected) return src
42
+ const versionRe = /^[ \t]*#version[^\n]*\n?/
43
+ const versionMatch = src.match(versionRe)
44
+ const version = versionMatch ? versionMatch[0] : ''
45
+ const rest = version ? src.slice(version.length) : src
46
+ const precisionRe = /^\s*precision\s+\S+\s+\S+\s*;\s*$/mg
47
+ const precisions = rest.match(precisionRe) ?? []
48
+ const body = rest.replace(precisionRe, '')
49
+ return version + precisions.join('\n') + '\n' + injected + '\n' + body
50
+ }
51
+
14
52
  export class LayerType {
15
53
  constructor({
16
54
  name,
@@ -48,19 +86,29 @@ export class LayerType {
48
86
  // Build a single-entry uniform object with renamed key reading from the internal prop name.
49
87
  const u = (internalName) => ({ [shaderName(internalName)]: regl.prop(internalName) })
50
88
 
51
- const attributes = Object.fromEntries(
52
- Object.entries(layer.attributes).map(([key, buffer]) => {
53
- const divisor = layer.attributeDivisors[key]
54
- const attrObj = divisor !== undefined ? { buffer, divisor } : { buffer }
55
- return [shaderName(key), attrObj]
56
- })
57
- )
89
+ const isInstanced = layer.instanceCount !== null
90
+ const pickCount = isInstanced ? layer.instanceCount : (layer.vertexCount ?? layer.attributes.x?.length ?? 0)
91
+ const pickIds = new Float32Array(pickCount)
92
+ for (let i = 0; i < pickCount; i++) pickIds[i] = i
93
+
94
+ const attributes = {
95
+ ...Object.fromEntries(
96
+ Object.entries(layer.attributes).map(([key, buffer]) => {
97
+ const divisor = layer.attributeDivisors[key]
98
+ const attrObj = divisor !== undefined ? { buffer, divisor } : { buffer }
99
+ return [shaderName(key), attrObj]
100
+ })
101
+ ),
102
+ a_pickId: isInstanced ? { buffer: regl.buffer(pickIds), divisor: 1 } : regl.buffer(pickIds)
103
+ }
58
104
 
59
105
  const uniforms = {
60
106
  ...u("xDomain"),
61
107
  ...u("yDomain"),
62
108
  ...u("xScaleType"),
63
109
  ...u("yScaleType"),
110
+ u_pickingMode: regl.prop('u_pickingMode'),
111
+ u_pickLayerIndex: regl.prop('u_pickLayerIndex'),
64
112
  ...Object.fromEntries(
65
113
  Object.entries(layer.uniforms).map(([key, value]) => [shaderName(key), value])
66
114
  )
@@ -80,22 +128,11 @@ export class LayerType {
80
128
  const spatialGlsl = buildSpatialGlsl()
81
129
  const colorGlsl = layer.colorAxes.length > 0 ? buildColorGlsl() : ''
82
130
  const filterGlsl = layer.filterAxes.length > 0 ? buildFilterGlsl() : ''
83
- const injectInto = (src, helpers) => {
84
- const injected = helpers.filter(Boolean).join('\n')
85
- if (!injected) return src
86
- const versionRe = /^[ \t]*#version[^\n]*\n?/
87
- const versionMatch = src.match(versionRe)
88
- const version = versionMatch ? versionMatch[0] : ''
89
- const rest = version ? src.slice(version.length) : src
90
- const precisionRe = /^\s*precision\s+\S+\s+\S+\s*;\s*$/mg
91
- const precisions = rest.match(precisionRe) ?? []
92
- const body = rest.replace(precisionRe, '')
93
- return version + precisions.join('\n') + '\n' + injected + '\n' + body
94
- }
131
+ const pickVertDecls = `attribute float a_pickId;\nvarying float v_pickId;`
95
132
 
96
133
  const drawConfig = {
97
- vert: injectInto(this.vert, [spatialGlsl, colorGlsl, filterGlsl]),
98
- frag: injectInto(this.frag, [colorGlsl, filterGlsl]),
134
+ vert: injectPickIdAssignment(injectInto(this.vert, [spatialGlsl, filterGlsl, pickVertDecls])),
135
+ frag: injectInto(this.frag, [buildApplyColorGlsl(), colorGlsl, filterGlsl]),
99
136
  attributes,
100
137
  uniforms,
101
138
  viewport: regl.prop("viewport"),
package/src/Plot.js CHANGED
@@ -90,11 +90,13 @@ export class Plot {
90
90
 
91
91
  const width = this.container.clientWidth
92
92
  const height = this.container.clientHeight
93
+ const plotWidth = width - this.margin.left - this.margin.right
94
+ const plotHeight = height - this.margin.top - this.margin.bottom
93
95
 
94
- // Container is hidden or not yet laid out (e.g. inside display:none tab).
96
+ // Container is hidden, not yet laid out, or too small to fit the margins.
95
97
  // Store config/data and return; ResizeObserver will call forceUpdate() once
96
98
  // the container gets real dimensions.
97
- if (width === 0 || height === 0) {
99
+ if (width === 0 || height === 0 || plotWidth <= 0 || plotHeight <= 0) {
98
100
  return
99
101
  }
100
102
 
@@ -104,8 +106,8 @@ export class Plot {
104
106
 
105
107
  this.width = width
106
108
  this.height = height
107
- this.plotWidth = width - this.margin.left - this.margin.right
108
- this.plotHeight = height - this.margin.top - this.margin.bottom
109
+ this.plotWidth = plotWidth
110
+ this.plotHeight = plotHeight
109
111
 
110
112
  if (this.regl) {
111
113
  this.regl.destroy()
@@ -221,7 +223,10 @@ export class Plot {
221
223
  _setupResizeObserver() {
222
224
  if (typeof ResizeObserver !== 'undefined') {
223
225
  this.resizeObserver = new ResizeObserver(() => {
224
- this.forceUpdate()
226
+ // Defer to next animation frame so the ResizeObserver callback exits
227
+ // before any DOM/layout changes happen, avoiding the "loop completed
228
+ // with undelivered notifications" browser error.
229
+ requestAnimationFrame(() => this.forceUpdate())
225
230
  })
226
231
  this.resizeObserver.observe(this.container)
227
232
  } else {
@@ -355,7 +360,8 @@ export class Plot {
355
360
  }
356
361
 
357
362
  _processLayers(layersConfig, data) {
358
- for (const layerSpec of layersConfig) {
363
+ for (let configLayerIndex = 0; configLayerIndex < layersConfig.length; configLayerIndex++) {
364
+ const layerSpec = layersConfig[configLayerIndex]
359
365
  const entries = Object.entries(layerSpec)
360
366
  if (entries.length !== 1) {
361
367
  throw new Error("Each layer specification must have exactly one layer type key")
@@ -385,6 +391,7 @@ export class Plot {
385
391
 
386
392
  // Create one draw command per GPU config returned by the layer type.
387
393
  for (const layer of layerType.createLayer(parameters, data)) {
394
+ layer.configLayerIndex = configLayerIndex
388
395
  layer.draw = layer.type.createDrawCommand(this.regl, layer, this)
389
396
  this.layers.push(layer)
390
397
  }
@@ -811,7 +818,9 @@ export class Plot {
811
818
  xScaleType: xIsLog ? 1.0 : 0.0,
812
819
  yScaleType: yIsLog ? 1.0 : 0.0,
813
820
  viewport: viewport,
814
- count: layer.vertexCount ?? layer.attributes.x?.length ?? 0
821
+ count: layer.vertexCount ?? layer.attributes.x?.length ?? 0,
822
+ u_pickingMode: 0.0,
823
+ u_pickLayerIndex: 0.0,
815
824
  }
816
825
 
817
826
  if (layer.instanceCount !== null) {
@@ -973,4 +982,92 @@ export class Plot {
973
982
 
974
983
  fullOverlay.call(zoomBehavior)
975
984
  }
985
+
986
+ lookup(x, y) {
987
+ const result = {}
988
+ if (!this.axisRegistry) return result
989
+ const plotX = x - this.margin.left
990
+ const plotY = y - this.margin.top
991
+ for (const axisId of AXES) {
992
+ const scale = this.axisRegistry.getScale(axisId)
993
+ if (!scale) continue
994
+ const qk = this.axisRegistry.axisQuantityKinds[axisId]
995
+ const value = axisId.includes('y') ? scale.invert(plotY) : scale.invert(plotX)
996
+ result[axisId] = value
997
+ if (qk) result[qk] = value
998
+ }
999
+ return result
1000
+ }
1001
+
1002
+ on(eventType, callback) {
1003
+ const handler = (e) => {
1004
+ if (!this.container.contains(e.target)) return
1005
+ const rect = this.container.getBoundingClientRect()
1006
+ const x = e.clientX - rect.left
1007
+ const y = e.clientY - rect.top
1008
+ callback(e, this.lookup(x, y))
1009
+ }
1010
+ window.addEventListener(eventType, handler, { capture: true })
1011
+ return { remove: () => window.removeEventListener(eventType, handler, { capture: true }) }
1012
+ }
1013
+
1014
+ pick(x, y) {
1015
+ if (!this.regl || !this.layers.length) return null
1016
+
1017
+ const fbo = this.regl.framebuffer({
1018
+ width: this.width, height: this.height,
1019
+ colorFormat: 'rgba', colorType: 'uint8', depth: false,
1020
+ })
1021
+
1022
+ const glX = Math.round(x)
1023
+ const glY = this.height - Math.round(y) - 1
1024
+
1025
+ let result = null
1026
+ this.regl({ framebuffer: fbo })(() => {
1027
+ this.regl.clear({ color: [0, 0, 0, 0] })
1028
+ const viewport = {
1029
+ x: this.margin.left, y: this.margin.bottom,
1030
+ width: this.plotWidth, height: this.plotHeight
1031
+ }
1032
+ for (let i = 0; i < this.layers.length; i++) {
1033
+ const layer = this.layers[i]
1034
+ const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
1035
+ const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
1036
+ const props = {
1037
+ xDomain: layer.xAxis ? this.axisRegistry.getScale(layer.xAxis).domain() : [0, 1],
1038
+ yDomain: layer.yAxis ? this.axisRegistry.getScale(layer.yAxis).domain() : [0, 1],
1039
+ xScaleType: xIsLog ? 1.0 : 0.0,
1040
+ yScaleType: yIsLog ? 1.0 : 0.0,
1041
+ viewport,
1042
+ count: layer.vertexCount ?? layer.attributes.x?.length ?? 0,
1043
+ u_pickingMode: 1.0,
1044
+ u_pickLayerIndex: i,
1045
+ }
1046
+ if (layer.instanceCount !== null) props.instances = layer.instanceCount
1047
+ for (const qk of layer.colorAxes) {
1048
+ props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
1049
+ const range = this.colorAxisRegistry.getRange(qk)
1050
+ props[`color_range_${qk}`] = range ?? [0, 1]
1051
+ props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1052
+ }
1053
+ for (const qk of layer.filterAxes) {
1054
+ props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
1055
+ props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1056
+ }
1057
+ layer.draw(props)
1058
+ }
1059
+ const pixels = this.regl.read({ x: glX, y: glY, width: 1, height: 1 })
1060
+ if (pixels[0] === 0) {
1061
+ result = null
1062
+ } else {
1063
+ const layerIndex = pixels[0] - 1
1064
+ const dataIndex = (pixels[1] << 16) | (pixels[2] << 8) | pixels[3]
1065
+ const layer = this.layers[layerIndex]
1066
+ result = { layerIndex, configLayerIndex: layer.configLayerIndex, dataIndex, layer }
1067
+ }
1068
+ })
1069
+
1070
+ fbo.destroy()
1071
+ return result
1072
+ }
976
1073
  }
@@ -3,10 +3,100 @@ import { AXES } from "./AxisRegistry.js"
3
3
  import { registerLayerType } from "./LayerTypeRegistry.js"
4
4
  import { Data } from "./Data.js"
5
5
 
6
- export const scatterLayerType = new LayerType({
7
- name: "scatter",
6
+ const POINTS_VERT = `
7
+ precision mediump float;
8
+ attribute float x;
9
+ attribute float y;
10
+ attribute float color_data;
11
+ uniform vec2 xDomain;
12
+ uniform vec2 yDomain;
13
+ uniform float xScaleType;
14
+ uniform float yScaleType;
15
+ varying float value;
16
+ void main() {
17
+ float nx = normalize_axis(x, xDomain, xScaleType);
18
+ float ny = normalize_axis(y, yDomain, yScaleType);
19
+ gl_Position = vec4(nx*2.0-1.0, ny*2.0-1.0, 0, 1);
20
+ gl_PointSize = 4.0;
21
+ value = color_data;
22
+ }
23
+ `
24
+
25
+ const POINTS_FRAG = `
26
+ precision mediump float;
27
+ uniform int colorscale;
28
+ uniform vec2 color_range;
29
+ uniform float color_scale_type;
30
+ uniform float alphaBlend;
31
+ varying float value;
32
+ void main() {
33
+ gl_FragColor = map_color_s(colorscale, color_range, value, color_scale_type, alphaBlend);
34
+ }
35
+ `
36
+
37
+ // Lines mode uses instanced rendering:
38
+ // - Template: 2 vertices with a_endPoint in {0.0, 1.0} (divisor=0 → interpolates)
39
+ // - Per-segment: a_x0/x1, a_y0/y1, a_v0/v1, a_seg0/seg1 (divisor=1 → constant per instance)
40
+ //
41
+ // Because a_v0 and a_v1 are instanced, they are the same at both template vertices for a given
42
+ // segment, so varyings set from them are constant across the line (no GPU interpolation).
43
+ // Only v_t (from a_endPoint) interpolates, giving the position along the segment.
44
+ //
45
+ // Segment boundary handling: when a_seg0 != a_seg1, collapse both template vertices to
46
+ // (a_x0, a_y0) producing a zero-length degenerate line that the rasterizer discards.
47
+
48
+ const LINES_VERT = `
49
+ precision mediump float;
50
+ attribute float a_endPoint;
51
+ attribute float a_x0, a_y0;
52
+ attribute float a_x1, a_y1;
53
+ attribute float a_v0, a_v1;
54
+ attribute float a_seg0, a_seg1;
55
+ uniform vec2 xDomain;
56
+ uniform vec2 yDomain;
57
+ uniform float xScaleType;
58
+ uniform float yScaleType;
59
+ varying float v_color_start;
60
+ varying float v_color_end;
61
+ varying float v_t;
62
+ void main() {
63
+ float same_seg = abs(a_seg0 - a_seg1) < 0.5 ? 1.0 : 0.0;
64
+ float t = same_seg * a_endPoint;
65
+ float x = mix(a_x0, a_x1, t);
66
+ float y = mix(a_y0, a_y1, t);
67
+ float nx = normalize_axis(x, xDomain, xScaleType);
68
+ float ny = normalize_axis(y, yDomain, yScaleType);
69
+ gl_Position = vec4(nx * 2.0 - 1.0, ny * 2.0 - 1.0, 0, 1);
70
+ v_color_start = a_v0;
71
+ v_color_end = a_v1;
72
+ v_t = a_endPoint;
73
+ }
74
+ `
75
+
76
+ const LINES_FRAG = `
77
+ precision mediump float;
78
+ uniform int colorscale;
79
+ uniform vec2 color_range;
80
+ uniform float color_scale_type;
81
+ uniform float alphaBlend;
82
+ uniform float u_lineColorMode;
83
+ varying float v_color_start;
84
+ varying float v_color_end;
85
+ varying float v_t;
86
+ void main() {
87
+ float value = u_lineColorMode > 0.5
88
+ ? (v_t < 0.5 ? v_color_start : v_color_end)
89
+ : mix(v_color_start, v_color_end, v_t);
90
+ gl_FragColor = map_color_s(colorscale, color_range, value, color_scale_type, alphaBlend);
91
+ }
92
+ `
93
+
94
+ class ScatterLayerType extends LayerType {
95
+ constructor() {
96
+ super({ name: "scatter", vert: POINTS_VERT, frag: POINTS_FRAG })
97
+ }
8
98
 
9
- getAxisConfig: function(parameters, data) {
99
+ _getAxisConfig(parameters, data) {
10
100
  const d = Data.wrap(data)
11
101
  const { xData, yData, vData, xAxis, yAxis } = parameters
12
102
  return {
@@ -16,39 +106,9 @@ export const scatterLayerType = new LayerType({
16
106
  yAxisQuantityKind: d.getQuantityKind(yData) ?? yData,
17
107
  colorAxisQuantityKinds: [d.getQuantityKind(vData) ?? vData],
18
108
  }
19
- },
20
-
21
- vert: `
22
- precision mediump float;
23
- attribute float x;
24
- attribute float y;
25
- attribute float color_data;
26
- uniform vec2 xDomain;
27
- uniform vec2 yDomain;
28
- uniform float xScaleType;
29
- uniform float yScaleType;
30
- varying float value;
31
- void main() {
32
- float nx = normalize_axis(x, xDomain, xScaleType);
33
- float ny = normalize_axis(y, yDomain, yScaleType);
34
- gl_Position = vec4(nx*2.0-1.0, ny*2.0-1.0, 0, 1);
35
- gl_PointSize = 4.0;
36
- value = color_data;
37
- }
38
- `,
39
- frag: `
40
- precision mediump float;
41
- uniform int colorscale;
42
- uniform vec2 color_range;
43
- uniform float color_scale_type;
44
- varying float value;
45
- void main() {
46
- float t = clamp((value - color_range.x) / (color_range.y - color_range.x), 0.0, 1.0);
47
- vec4 color = map_color_s(colorscale, color_range, value, color_scale_type);
48
- gl_FragColor = vec4(color.rgb, t);
49
- }
50
- `,
51
- schema: (data) => {
109
+ }
110
+
111
+ schema(data) {
52
112
  const dataProperties = Data.wrap(data).columns()
53
113
  return {
54
114
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -85,26 +145,57 @@ export const scatterLayerType = new LayerType({
85
145
  type: "boolean",
86
146
  default: false,
87
147
  description: "Map the normalized color value to alpha so low values fade to transparent"
148
+ },
149
+ mode: {
150
+ type: "string",
151
+ enum: ["points", "lines"],
152
+ default: "points",
153
+ description: "Render as individual points or connected lines"
154
+ },
155
+ lineSegmentIdData: {
156
+ type: "string",
157
+ enum: dataProperties,
158
+ description: "Column for segment IDs; only consecutive points sharing the same ID are connected"
159
+ },
160
+ lineColorMode: {
161
+ type: "string",
162
+ enum: ["gradient", "midpoint"],
163
+ default: "gradient",
164
+ description: "Color mode for lines: gradient interpolates vData linearly; midpoint uses each endpoint's color up to the segment center"
165
+ },
166
+ lineWidth: {
167
+ type: "number",
168
+ default: 1.0,
169
+ minimum: 1,
170
+ description: "Line width in pixels (note: browsers may clamp values above 1)"
88
171
  }
89
172
  },
90
173
  required: ["xData", "yData", "vData"]
91
174
  }
92
- },
93
- createLayer: function(parameters, data) {
175
+ }
176
+
177
+ _createLayer(parameters, data) {
94
178
  const d = Data.wrap(data)
95
- const { xData, yData, vData, alphaBlend = false } = parameters
179
+ const {
180
+ xData, yData, vData,
181
+ alphaBlend = false,
182
+ mode = "points",
183
+ lineSegmentIdData,
184
+ lineColorMode = "gradient",
185
+ lineWidth = 1.0,
186
+ } = parameters
96
187
 
97
188
  const xQK = d.getQuantityKind(xData) ?? xData
98
189
  const yQK = d.getQuantityKind(yData) ?? yData
99
190
  const vQK = d.getQuantityKind(vData) ?? vData
100
191
 
101
- const x = d.getData(xData)
102
- const y = d.getData(yData)
103
- const v = d.getData(vData)
192
+ const srcX = d.getData(xData)
193
+ const srcY = d.getData(yData)
194
+ const srcV = d.getData(vData)
104
195
 
105
- if (!x) throw new Error(`Data column '${xData}' not found`)
106
- if (!y) throw new Error(`Data column '${yData}' not found`)
107
- if (!v) throw new Error(`Data column '${vData}' not found`)
196
+ if (!srcX) throw new Error(`Data column '${xData}' not found`)
197
+ if (!srcY) throw new Error(`Data column '${yData}' not found`)
198
+ if (!srcV) throw new Error(`Data column '${vData}' not found`)
108
199
 
109
200
  const domains = {}
110
201
  const xDomain = d.getDomain(xData)
@@ -114,9 +205,59 @@ export const scatterLayerType = new LayerType({
114
205
  if (yDomain) domains[yQK] = yDomain
115
206
  if (vDomain) domains[vQK] = vDomain
116
207
 
208
+ const blendConfig = alphaBlend ? {
209
+ enable: true,
210
+ func: { srcRGB: 'src alpha', dstRGB: 'one minus src alpha', srcAlpha: 0, dstAlpha: 1 },
211
+ } : null
212
+
213
+ if (mode === "lines") {
214
+ const N = srcX.length
215
+ const segIds = lineSegmentIdData ? d.getData(lineSegmentIdData) : null
216
+ // Zero-init array used when no segment IDs: abs(0-0) < 0.5 → always same segment
217
+ const zeroSegs = new Float32Array(N - 1)
218
+ const seg0 = segIds ? segIds.subarray(0, N - 1) : zeroSegs
219
+ const seg1 = segIds ? segIds.subarray(1, N) : zeroSegs
220
+
221
+ return [{
222
+ attributes: {
223
+ a_endPoint: new Float32Array([0.0, 1.0]),
224
+ a_x0: srcX.subarray(0, N - 1),
225
+ a_x1: srcX.subarray(1, N),
226
+ a_y0: srcY.subarray(0, N - 1),
227
+ a_y1: srcY.subarray(1, N),
228
+ a_v0: srcV.subarray(0, N - 1),
229
+ a_v1: srcV.subarray(1, N),
230
+ a_seg0: seg0,
231
+ a_seg1: seg1,
232
+ },
233
+ attributeDivisors: {
234
+ a_x0: 1, a_x1: 1,
235
+ a_y0: 1, a_y1: 1,
236
+ a_v0: 1, a_v1: 1,
237
+ a_seg0: 1, a_seg1: 1,
238
+ },
239
+ uniforms: {
240
+ alphaBlend: alphaBlend ? 1.0 : 0.0,
241
+ u_lineColorMode: lineColorMode === "midpoint" ? 1.0 : 0.0,
242
+ },
243
+ nameMap: {
244
+ [`colorscale_${vQK}`]: 'colorscale',
245
+ [`color_range_${vQK}`]: 'color_range',
246
+ [`color_scale_type_${vQK}`]: 'color_scale_type',
247
+ },
248
+ domains,
249
+ primitive: "lines",
250
+ lineWidth,
251
+ vertexCount: 2,
252
+ instanceCount: N - 1,
253
+ blend: blendConfig,
254
+ }]
255
+ }
256
+
257
+ // Points mode — existing behaviour
117
258
  return [{
118
- attributes: { x, y, [vQK]: v },
119
- uniforms: {},
259
+ attributes: { x: srcX, y: srcY, [vQK]: srcV },
260
+ uniforms: { alphaBlend: alphaBlend ? 1.0 : 0.0 },
120
261
  domains,
121
262
  nameMap: {
122
263
  [vQK]: 'color_data',
@@ -124,11 +265,23 @@ export const scatterLayerType = new LayerType({
124
265
  [`color_range_${vQK}`]: 'color_range',
125
266
  [`color_scale_type_${vQK}`]: 'color_scale_type',
126
267
  },
127
- blend: alphaBlend ? {
128
- enable: true,
129
- func: { srcRGB: 'src alpha', dstRGB: 'one minus src alpha', srcAlpha: 0, dstAlpha: 1 },
130
- } : null,
268
+ blend: blendConfig,
131
269
  }]
132
270
  }
133
- })
271
+
272
+ // Swap vert/frag to the lines variants before letting the parent build the draw command,
273
+ // then restore. JS is single-threaded so the temporary swap is safe.
274
+ createDrawCommand(regl, layer) {
275
+ if (layer.primitive === "lines") {
276
+ this.vert = LINES_VERT
277
+ this.frag = LINES_FRAG
278
+ } else {
279
+ this.vert = POINTS_VERT
280
+ this.frag = POINTS_FRAG
281
+ }
282
+ return super.createDrawCommand(regl, layer)
283
+ }
284
+ }
285
+
286
+ export const scatterLayerType = new ScatterLayerType()
134
287
  registerLayerType("scatter", scatterLayerType)