gladly-plot 0.0.1 → 0.0.3
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 +4 -2
- package/package.json +3 -1
- package/src/AxisRegistry.js +2 -2
- package/src/ColorbarLayer.js +1 -1
- package/src/ColorscaleRegistry.js +5 -2
- package/src/Data.js +67 -0
- package/src/EpsgUtils.js +123 -0
- package/src/FilterbarLayer.js +1 -1
- package/src/Layer.js +2 -1
- package/src/LayerType.js +63 -23
- package/src/Plot.js +95 -3
- package/src/ScatterLayer.js +45 -19
- package/src/TileLayer.js +708 -0
- package/src/index.js +3 -0
package/README.md
CHANGED
|
@@ -13,8 +13,10 @@ Gladly combines WebGL rendering (via regl) with D3.js for interactive axes and z
|
|
|
13
13
|
- 🎯 Interactive multi-axis support (up to 4 axes)
|
|
14
14
|
- 🔍 Zoom and pan interactions
|
|
15
15
|
- 🧩 Extensible layer type registry
|
|
16
|
-
- 📏
|
|
17
|
-
-
|
|
16
|
+
- 📏 Quantity and unit-aware axis management
|
|
17
|
+
- 🎨 Supports all standard colorscales
|
|
18
|
+
- 🔗 Subplot axis linking
|
|
19
|
+
- 🌈 Axis to coloring or filtering linking
|
|
18
20
|
|
|
19
21
|
## Documentation
|
|
20
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gladly-plot",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "GPU-powered multi-axis plotting library with regl + d3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"d3": "^7.8.5",
|
|
21
|
+
"proj4": "^2.15.0",
|
|
22
|
+
"projnames": "^0.0.2",
|
|
21
23
|
"regl": "^2.1.0"
|
|
22
24
|
},
|
|
23
25
|
|
package/src/AxisRegistry.js
CHANGED
|
@@ -16,9 +16,9 @@ export class AxisRegistry {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
ensureAxis(axisName, axisQuantityKind, scaleOverride) {
|
|
19
|
-
if (!AXES.includes(axisName)) throw `Unknown axis ${axisName}`
|
|
19
|
+
if (!AXES.includes(axisName)) throw new Error(`Unknown axis ${axisName}`)
|
|
20
20
|
if (this.axisQuantityKinds[axisName] && this.axisQuantityKinds[axisName] !== axisQuantityKind)
|
|
21
|
-
throw `Axis quantity kind mismatch on axis ${axisName}: ${this.axisQuantityKinds[axisName]} vs ${axisQuantityKind}`
|
|
21
|
+
throw new Error(`Axis quantity kind mismatch on axis ${axisName}: ${this.axisQuantityKinds[axisName]} vs ${axisQuantityKind}`)
|
|
22
22
|
|
|
23
23
|
if (!this.scales[axisName]) {
|
|
24
24
|
const quantityKindDef = getAxisQuantityKind(axisQuantityKind)
|
package/src/ColorbarLayer.js
CHANGED
|
@@ -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('
|
|
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')
|
package/src/Data.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export class Data {
|
|
2
|
+
constructor(raw) {
|
|
3
|
+
raw = raw ?? {}
|
|
4
|
+
// Columnar format: { data: {col: Float32Array}, quantity_kinds?: {...}, domains?: {...} }
|
|
5
|
+
// Detected by the presence of a top-level `data` property that is a plain object.
|
|
6
|
+
if (raw.data != null && typeof raw.data === 'object' && !(raw.data instanceof Float32Array)) {
|
|
7
|
+
this._columnar = true
|
|
8
|
+
this._data = raw.data
|
|
9
|
+
this._quantityKinds = raw.quantity_kinds ?? {}
|
|
10
|
+
this._rawDomains = raw.domains ?? {}
|
|
11
|
+
} else {
|
|
12
|
+
this._columnar = false
|
|
13
|
+
this._raw = raw
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static wrap(data) {
|
|
18
|
+
if (data != null && typeof data.columns === 'function' && typeof data.getData === 'function') {
|
|
19
|
+
return data
|
|
20
|
+
}
|
|
21
|
+
return new Data(data)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_entry(col) {
|
|
25
|
+
if (this._columnar) {
|
|
26
|
+
const rawDomain = this._rawDomains[col]
|
|
27
|
+
let domain
|
|
28
|
+
if (Array.isArray(rawDomain)) {
|
|
29
|
+
domain = [rawDomain[0], rawDomain[1]]
|
|
30
|
+
} else if (rawDomain && typeof rawDomain === 'object') {
|
|
31
|
+
domain = [rawDomain.min, rawDomain.max]
|
|
32
|
+
}
|
|
33
|
+
return { data: this._data[col], quantityKind: this._quantityKinds[col], domain }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const v = this._raw[col]
|
|
37
|
+
if (v instanceof Float32Array) {
|
|
38
|
+
return { data: v, quantityKind: undefined, domain: undefined }
|
|
39
|
+
}
|
|
40
|
+
if (v && typeof v === 'object') {
|
|
41
|
+
let domain
|
|
42
|
+
if (Array.isArray(v.domain)) {
|
|
43
|
+
domain = [v.domain[0], v.domain[1]]
|
|
44
|
+
} else if (v.domain && typeof v.domain === 'object') {
|
|
45
|
+
domain = [v.domain.min, v.domain.max]
|
|
46
|
+
}
|
|
47
|
+
return { data: v.data, quantityKind: v.quantity_kind, domain }
|
|
48
|
+
}
|
|
49
|
+
return { data: undefined, quantityKind: undefined, domain: undefined }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
columns() {
|
|
53
|
+
return this._columnar ? Object.keys(this._data) : Object.keys(this._raw)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getData(col) {
|
|
57
|
+
return this._entry(col).data
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getQuantityKind(col) {
|
|
61
|
+
return this._entry(col).quantityKind
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getDomain(col) {
|
|
65
|
+
return this._entry(col).domain
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/EpsgUtils.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import proj4 from 'proj4'
|
|
2
|
+
import { byEpsg } from 'projnames'
|
|
3
|
+
import { registerAxisQuantityKind } from './AxisQuantityKindRegistry.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse an EPSG CRS string or number to a plain integer code.
|
|
7
|
+
* Accepts: 26911, "26911", "EPSG:26911", "epsg:26911"
|
|
8
|
+
*/
|
|
9
|
+
export function parseCrsCode(crs) {
|
|
10
|
+
if (typeof crs === 'number') return crs
|
|
11
|
+
const m = String(crs).match(/(\d+)$/)
|
|
12
|
+
return m ? parseInt(m[1]) : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** "EPSG:26911" → "epsg_26911_x" */
|
|
16
|
+
export function crsToQkX(crs) {
|
|
17
|
+
return `epsg_${parseCrsCode(crs)}_x`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** "EPSG:26911" → "epsg_26911_y" */
|
|
21
|
+
export function crsToQkY(crs) {
|
|
22
|
+
return `epsg_${parseCrsCode(crs)}_y`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** "epsg_26911_x" or "epsg_26911_y" → 26911, or null if not matching */
|
|
26
|
+
export function qkToEpsgCode(qk) {
|
|
27
|
+
const m = String(qk).match(/^epsg_(\d+)_[xy]$/)
|
|
28
|
+
return m ? parseInt(m[1]) : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register a proj4 CRS definition and auto-register the matching
|
|
33
|
+
* epsg_CODE_x / epsg_CODE_y quantity kinds with labels from projnames.
|
|
34
|
+
* Useful for offline/air-gapped environments where network access is unavailable.
|
|
35
|
+
*
|
|
36
|
+
* @param {number} epsgCode - e.g. 26911
|
|
37
|
+
* @param {string} proj4string - e.g. "+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs"
|
|
38
|
+
*/
|
|
39
|
+
export function registerEpsgDef(epsgCode, proj4string) {
|
|
40
|
+
proj4.defs(`EPSG:${epsgCode}`, proj4string)
|
|
41
|
+
_registerQuantityKinds(epsgCode)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Internal auto-fetch machinery ───────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
// Labels for EPSG codes absent from (or wrong in) the projnames package.
|
|
47
|
+
const EPSG_LABEL_OVERRIDES = {
|
|
48
|
+
4326: 'WGS 84',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Tracks codes whose quantity kinds have been registered (avoids duplicate work)
|
|
52
|
+
const _registeredQkCodes = new Set()
|
|
53
|
+
|
|
54
|
+
// In-flight fetch promises keyed by code (deduplicates concurrent requests)
|
|
55
|
+
const _pendingFetches = new Map()
|
|
56
|
+
|
|
57
|
+
function _registerQuantityKinds(code) {
|
|
58
|
+
if (_registeredQkCodes.has(code)) return
|
|
59
|
+
_registeredQkCodes.add(code)
|
|
60
|
+
const name = EPSG_LABEL_OVERRIDES[code] ?? byEpsg[code] ?? `EPSG:${code}`
|
|
61
|
+
registerAxisQuantityKind(`epsg_${code}_x`, { label: `${name} X`, scale: 'linear' })
|
|
62
|
+
registerAxisQuantityKind(`epsg_${code}_y`, { label: `${name} Y`, scale: 'linear' })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ensure a CRS is defined in proj4 and has quantity kinds registered.
|
|
67
|
+
* - If already known to proj4, only registers quantity kinds (synchronous work).
|
|
68
|
+
* - Otherwise fetches the proj4 string from epsg.io (async).
|
|
69
|
+
* - Concurrent calls for the same code share a single in-flight request.
|
|
70
|
+
*
|
|
71
|
+
* @param {string|number} crs - e.g. "EPSG:26911", 26911
|
|
72
|
+
* @returns {Promise<void>}
|
|
73
|
+
*/
|
|
74
|
+
export async function ensureCrsDefined(crs) {
|
|
75
|
+
const code = parseCrsCode(crs)
|
|
76
|
+
if (!code) throw new Error(`Cannot parse CRS code from: ${crs}`)
|
|
77
|
+
|
|
78
|
+
const key = `EPSG:${code}`
|
|
79
|
+
|
|
80
|
+
// Register quantity kinds first — this is always synchronous (projnames lookup)
|
|
81
|
+
_registerQuantityKinds(code)
|
|
82
|
+
|
|
83
|
+
// If proj4 already has this definition (including built-ins 4326 and 3857), we're done
|
|
84
|
+
if (proj4.defs(key)) return
|
|
85
|
+
|
|
86
|
+
// Deduplicate concurrent fetches for the same code
|
|
87
|
+
if (!_pendingFetches.has(code)) {
|
|
88
|
+
const p = fetch(`https://epsg.io/${code}.proj4`)
|
|
89
|
+
.then(res => {
|
|
90
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
91
|
+
return res.text()
|
|
92
|
+
})
|
|
93
|
+
.then(def => {
|
|
94
|
+
proj4.defs(key, def.trim())
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
_pendingFetches.delete(code)
|
|
98
|
+
})
|
|
99
|
+
_pendingFetches.set(code, p)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await _pendingFetches.get(code)
|
|
104
|
+
} catch (err) {
|
|
105
|
+
throw new Error(`Failed to fetch proj4 definition for ${key}: ${err.message}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reproject a [x, y] point from one CRS to another.
|
|
111
|
+
* Both CRS strings are parsed via parseCrsCode (accepts "EPSG:N", "N", N).
|
|
112
|
+
* The CRS must already be registered (via registerEpsgDef or ensureCrsDefined).
|
|
113
|
+
*
|
|
114
|
+
* @param {string|number} fromCrs
|
|
115
|
+
* @param {string|number} toCrs
|
|
116
|
+
* @param {[number, number]} point
|
|
117
|
+
* @returns {[number, number]}
|
|
118
|
+
*/
|
|
119
|
+
export function reproject(fromCrs, toCrs, point) {
|
|
120
|
+
const from = `EPSG:${parseCrsCode(fromCrs)}`
|
|
121
|
+
const to = `EPSG:${parseCrsCode(toCrs)}`
|
|
122
|
+
return proj4(from, to).forward(point)
|
|
123
|
+
}
|
package/src/FilterbarLayer.js
CHANGED
|
@@ -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/Layer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export class Layer {
|
|
2
|
-
constructor({ type, attributes, uniforms, nameMap = {}, domains = {}, lineWidth = 1, primitive = "points", xAxis = "xaxis_bottom", yAxis = "yaxis_left", xAxisQuantityKind, yAxisQuantityKind, colorAxes = [], filterAxes = [], vertexCount = null, instanceCount = null, attributeDivisors = {} }) {
|
|
2
|
+
constructor({ type, attributes, uniforms, nameMap = {}, domains = {}, lineWidth = 1, primitive = "points", xAxis = "xaxis_bottom", yAxis = "yaxis_left", xAxisQuantityKind, yAxisQuantityKind, colorAxes = [], filterAxes = [], vertexCount = null, instanceCount = null, attributeDivisors = {}, blend = null }) {
|
|
3
3
|
// Validate that all attributes are typed arrays
|
|
4
4
|
for (const [key, value] of Object.entries(attributes)) {
|
|
5
5
|
if (!(value instanceof Float32Array)) {
|
|
@@ -39,5 +39,6 @@ export class Layer {
|
|
|
39
39
|
this.vertexCount = vertexCount
|
|
40
40
|
this.instanceCount = instanceCount
|
|
41
41
|
this.attributeDivisors = attributeDivisors
|
|
42
|
+
this.blend = blend
|
|
42
43
|
}
|
|
43
44
|
}
|
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,
|
|
@@ -22,7 +60,7 @@ export class LayerType {
|
|
|
22
60
|
// Optional dynamic resolver — overrides statics wherever it returns a non-undefined value
|
|
23
61
|
getAxisConfig,
|
|
24
62
|
// GPU rendering
|
|
25
|
-
vert, frag, schema, createLayer
|
|
63
|
+
vert, frag, schema, createLayer, createDrawCommand
|
|
26
64
|
}) {
|
|
27
65
|
this.name = name
|
|
28
66
|
// Static declarations stored as-is (undefined = not declared)
|
|
@@ -38,6 +76,7 @@ export class LayerType {
|
|
|
38
76
|
if (schema) this._schema = schema
|
|
39
77
|
if (createLayer) this._createLayer = createLayer
|
|
40
78
|
if (getAxisConfig) this._getAxisConfig = getAxisConfig
|
|
79
|
+
if (createDrawCommand) this.createDrawCommand = createDrawCommand
|
|
41
80
|
}
|
|
42
81
|
|
|
43
82
|
createDrawCommand(regl, layer) {
|
|
@@ -47,19 +86,29 @@ export class LayerType {
|
|
|
47
86
|
// Build a single-entry uniform object with renamed key reading from the internal prop name.
|
|
48
87
|
const u = (internalName) => ({ [shaderName(internalName)]: regl.prop(internalName) })
|
|
49
88
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
104
|
|
|
58
105
|
const uniforms = {
|
|
59
106
|
...u("xDomain"),
|
|
60
107
|
...u("yDomain"),
|
|
61
108
|
...u("xScaleType"),
|
|
62
109
|
...u("yScaleType"),
|
|
110
|
+
u_pickingMode: regl.prop('u_pickingMode'),
|
|
111
|
+
u_pickLayerIndex: regl.prop('u_pickLayerIndex'),
|
|
63
112
|
...Object.fromEntries(
|
|
64
113
|
Object.entries(layer.uniforms).map(([key, value]) => [shaderName(key), value])
|
|
65
114
|
)
|
|
@@ -79,28 +128,18 @@ export class LayerType {
|
|
|
79
128
|
const spatialGlsl = buildSpatialGlsl()
|
|
80
129
|
const colorGlsl = layer.colorAxes.length > 0 ? buildColorGlsl() : ''
|
|
81
130
|
const filterGlsl = layer.filterAxes.length > 0 ? buildFilterGlsl() : ''
|
|
82
|
-
const
|
|
83
|
-
const injected = helpers.filter(Boolean).join('\n')
|
|
84
|
-
if (!injected) return src
|
|
85
|
-
const versionRe = /^[ \t]*#version[^\n]*\n?/
|
|
86
|
-
const versionMatch = src.match(versionRe)
|
|
87
|
-
const version = versionMatch ? versionMatch[0] : ''
|
|
88
|
-
const rest = version ? src.slice(version.length) : src
|
|
89
|
-
const precisionRe = /^\s*precision\s+\S+\s+\S+\s*;\s*$/mg
|
|
90
|
-
const precisions = rest.match(precisionRe) ?? []
|
|
91
|
-
const body = rest.replace(precisionRe, '')
|
|
92
|
-
return version + precisions.join('\n') + '\n' + injected + '\n' + body
|
|
93
|
-
}
|
|
131
|
+
const pickVertDecls = `attribute float a_pickId;\nvarying float v_pickId;`
|
|
94
132
|
|
|
95
133
|
const drawConfig = {
|
|
96
|
-
vert: injectInto(this.vert, [spatialGlsl,
|
|
97
|
-
frag: injectInto(this.frag, [colorGlsl, filterGlsl]),
|
|
134
|
+
vert: injectPickIdAssignment(injectInto(this.vert, [spatialGlsl, filterGlsl, pickVertDecls])),
|
|
135
|
+
frag: injectInto(this.frag, [buildApplyColorGlsl(), colorGlsl, filterGlsl]),
|
|
98
136
|
attributes,
|
|
99
137
|
uniforms,
|
|
100
138
|
viewport: regl.prop("viewport"),
|
|
101
139
|
primitive: layer.primitive,
|
|
102
140
|
lineWidth: layer.lineWidth,
|
|
103
|
-
count: regl.prop("count")
|
|
141
|
+
count: regl.prop("count"),
|
|
142
|
+
...(layer.blend ? { blend: layer.blend } : {})
|
|
104
143
|
}
|
|
105
144
|
|
|
106
145
|
if (layer.instanceCount !== null) {
|
|
@@ -158,6 +197,7 @@ export class LayerType {
|
|
|
158
197
|
vertexCount: gpuConfig.vertexCount ?? null,
|
|
159
198
|
instanceCount: gpuConfig.instanceCount ?? null,
|
|
160
199
|
attributeDivisors: gpuConfig.attributeDivisors ?? {},
|
|
200
|
+
blend: gpuConfig.blend ?? null,
|
|
161
201
|
xAxis: axisConfig.xAxis,
|
|
162
202
|
yAxis: axisConfig.yAxis,
|
|
163
203
|
xAxisQuantityKind: axisConfig.xAxisQuantityKind,
|
package/src/Plot.js
CHANGED
|
@@ -355,7 +355,8 @@ export class Plot {
|
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
_processLayers(layersConfig, data) {
|
|
358
|
-
for (
|
|
358
|
+
for (let configLayerIndex = 0; configLayerIndex < layersConfig.length; configLayerIndex++) {
|
|
359
|
+
const layerSpec = layersConfig[configLayerIndex]
|
|
359
360
|
const entries = Object.entries(layerSpec)
|
|
360
361
|
if (entries.length !== 1) {
|
|
361
362
|
throw new Error("Each layer specification must have exactly one layer type key")
|
|
@@ -385,7 +386,8 @@ export class Plot {
|
|
|
385
386
|
|
|
386
387
|
// Create one draw command per GPU config returned by the layer type.
|
|
387
388
|
for (const layer of layerType.createLayer(parameters, data)) {
|
|
388
|
-
layer.
|
|
389
|
+
layer.configLayerIndex = configLayerIndex
|
|
390
|
+
layer.draw = layer.type.createDrawCommand(this.regl, layer, this)
|
|
389
391
|
this.layers.push(layer)
|
|
390
392
|
}
|
|
391
393
|
}
|
|
@@ -811,7 +813,9 @@ export class Plot {
|
|
|
811
813
|
xScaleType: xIsLog ? 1.0 : 0.0,
|
|
812
814
|
yScaleType: yIsLog ? 1.0 : 0.0,
|
|
813
815
|
viewport: viewport,
|
|
814
|
-
count: layer.vertexCount ?? layer.attributes.x?.length ?? 0
|
|
816
|
+
count: layer.vertexCount ?? layer.attributes.x?.length ?? 0,
|
|
817
|
+
u_pickingMode: 0.0,
|
|
818
|
+
u_pickLayerIndex: 0.0,
|
|
815
819
|
}
|
|
816
820
|
|
|
817
821
|
if (layer.instanceCount !== null) {
|
|
@@ -973,4 +977,92 @@ export class Plot {
|
|
|
973
977
|
|
|
974
978
|
fullOverlay.call(zoomBehavior)
|
|
975
979
|
}
|
|
980
|
+
|
|
981
|
+
lookup(x, y) {
|
|
982
|
+
const result = {}
|
|
983
|
+
if (!this.axisRegistry) return result
|
|
984
|
+
const plotX = x - this.margin.left
|
|
985
|
+
const plotY = y - this.margin.top
|
|
986
|
+
for (const axisId of AXES) {
|
|
987
|
+
const scale = this.axisRegistry.getScale(axisId)
|
|
988
|
+
if (!scale) continue
|
|
989
|
+
const qk = this.axisRegistry.axisQuantityKinds[axisId]
|
|
990
|
+
const value = axisId.includes('y') ? scale.invert(plotY) : scale.invert(plotX)
|
|
991
|
+
result[axisId] = value
|
|
992
|
+
if (qk) result[qk] = value
|
|
993
|
+
}
|
|
994
|
+
return result
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
on(eventType, callback) {
|
|
998
|
+
const handler = (e) => {
|
|
999
|
+
if (!this.container.contains(e.target)) return
|
|
1000
|
+
const rect = this.container.getBoundingClientRect()
|
|
1001
|
+
const x = e.clientX - rect.left
|
|
1002
|
+
const y = e.clientY - rect.top
|
|
1003
|
+
callback(e, this.lookup(x, y))
|
|
1004
|
+
}
|
|
1005
|
+
window.addEventListener(eventType, handler, { capture: true })
|
|
1006
|
+
return { remove: () => window.removeEventListener(eventType, handler, { capture: true }) }
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
pick(x, y) {
|
|
1010
|
+
if (!this.regl || !this.layers.length) return null
|
|
1011
|
+
|
|
1012
|
+
const fbo = this.regl.framebuffer({
|
|
1013
|
+
width: this.width, height: this.height,
|
|
1014
|
+
colorFormat: 'rgba', colorType: 'uint8', depth: false,
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
const glX = Math.round(x)
|
|
1018
|
+
const glY = this.height - Math.round(y) - 1
|
|
1019
|
+
|
|
1020
|
+
let result = null
|
|
1021
|
+
this.regl({ framebuffer: fbo })(() => {
|
|
1022
|
+
this.regl.clear({ color: [0, 0, 0, 0] })
|
|
1023
|
+
const viewport = {
|
|
1024
|
+
x: this.margin.left, y: this.margin.bottom,
|
|
1025
|
+
width: this.plotWidth, height: this.plotHeight
|
|
1026
|
+
}
|
|
1027
|
+
for (let i = 0; i < this.layers.length; i++) {
|
|
1028
|
+
const layer = this.layers[i]
|
|
1029
|
+
const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
|
|
1030
|
+
const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
|
|
1031
|
+
const props = {
|
|
1032
|
+
xDomain: layer.xAxis ? this.axisRegistry.getScale(layer.xAxis).domain() : [0, 1],
|
|
1033
|
+
yDomain: layer.yAxis ? this.axisRegistry.getScale(layer.yAxis).domain() : [0, 1],
|
|
1034
|
+
xScaleType: xIsLog ? 1.0 : 0.0,
|
|
1035
|
+
yScaleType: yIsLog ? 1.0 : 0.0,
|
|
1036
|
+
viewport,
|
|
1037
|
+
count: layer.vertexCount ?? layer.attributes.x?.length ?? 0,
|
|
1038
|
+
u_pickingMode: 1.0,
|
|
1039
|
+
u_pickLayerIndex: i,
|
|
1040
|
+
}
|
|
1041
|
+
if (layer.instanceCount !== null) props.instances = layer.instanceCount
|
|
1042
|
+
for (const qk of layer.colorAxes) {
|
|
1043
|
+
props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
|
|
1044
|
+
const range = this.colorAxisRegistry.getRange(qk)
|
|
1045
|
+
props[`color_range_${qk}`] = range ?? [0, 1]
|
|
1046
|
+
props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
1047
|
+
}
|
|
1048
|
+
for (const qk of layer.filterAxes) {
|
|
1049
|
+
props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
|
|
1050
|
+
props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
1051
|
+
}
|
|
1052
|
+
layer.draw(props)
|
|
1053
|
+
}
|
|
1054
|
+
const pixels = this.regl.read({ x: glX, y: glY, width: 1, height: 1 })
|
|
1055
|
+
if (pixels[0] === 0) {
|
|
1056
|
+
result = null
|
|
1057
|
+
} else {
|
|
1058
|
+
const layerIndex = pixels[0] - 1
|
|
1059
|
+
const dataIndex = (pixels[1] << 16) | (pixels[2] << 8) | pixels[3]
|
|
1060
|
+
const layer = this.layers[layerIndex]
|
|
1061
|
+
result = { layerIndex, configLayerIndex: layer.configLayerIndex, dataIndex, layer }
|
|
1062
|
+
}
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
fbo.destroy()
|
|
1066
|
+
return result
|
|
1067
|
+
}
|
|
976
1068
|
}
|
package/src/ScatterLayer.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { LayerType } from "./LayerType.js"
|
|
2
2
|
import { AXES } from "./AxisRegistry.js"
|
|
3
3
|
import { registerLayerType } from "./LayerTypeRegistry.js"
|
|
4
|
+
import { Data } from "./Data.js"
|
|
4
5
|
|
|
5
6
|
export const scatterLayerType = new LayerType({
|
|
6
7
|
name: "scatter",
|
|
7
8
|
|
|
8
|
-
getAxisConfig: function(parameters) {
|
|
9
|
+
getAxisConfig: function(parameters, data) {
|
|
10
|
+
const d = Data.wrap(data)
|
|
9
11
|
const { xData, yData, vData, xAxis, yAxis } = parameters
|
|
10
12
|
return {
|
|
11
13
|
xAxis,
|
|
12
|
-
xAxisQuantityKind: xData,
|
|
14
|
+
xAxisQuantityKind: d.getQuantityKind(xData) ?? xData,
|
|
13
15
|
yAxis,
|
|
14
|
-
yAxisQuantityKind: yData,
|
|
15
|
-
colorAxisQuantityKinds: [vData],
|
|
16
|
+
yAxisQuantityKind: d.getQuantityKind(yData) ?? yData,
|
|
17
|
+
colorAxisQuantityKinds: [d.getQuantityKind(vData) ?? vData],
|
|
16
18
|
}
|
|
17
19
|
},
|
|
18
20
|
|
|
@@ -39,13 +41,14 @@ export const scatterLayerType = new LayerType({
|
|
|
39
41
|
uniform int colorscale;
|
|
40
42
|
uniform vec2 color_range;
|
|
41
43
|
uniform float color_scale_type;
|
|
44
|
+
uniform float alphaBlend;
|
|
42
45
|
varying float value;
|
|
43
46
|
void main() {
|
|
44
|
-
gl_FragColor = map_color_s(colorscale, color_range, value, color_scale_type);
|
|
47
|
+
gl_FragColor = map_color_s(colorscale, color_range, value, color_scale_type, alphaBlend);
|
|
45
48
|
}
|
|
46
49
|
`,
|
|
47
50
|
schema: (data) => {
|
|
48
|
-
const dataProperties =
|
|
51
|
+
const dataProperties = Data.wrap(data).columns()
|
|
49
52
|
return {
|
|
50
53
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
51
54
|
type: "object",
|
|
@@ -76,31 +79,54 @@ export const scatterLayerType = new LayerType({
|
|
|
76
79
|
enum: AXES.filter(a => a.includes("y")),
|
|
77
80
|
default: "yaxis_left",
|
|
78
81
|
description: "Which y-axis to use for this layer"
|
|
82
|
+
},
|
|
83
|
+
alphaBlend: {
|
|
84
|
+
type: "boolean",
|
|
85
|
+
default: false,
|
|
86
|
+
description: "Map the normalized color value to alpha so low values fade to transparent"
|
|
79
87
|
}
|
|
80
88
|
},
|
|
81
89
|
required: ["xData", "yData", "vData"]
|
|
82
90
|
}
|
|
83
91
|
},
|
|
84
92
|
createLayer: function(parameters, data) {
|
|
85
|
-
const
|
|
93
|
+
const d = Data.wrap(data)
|
|
94
|
+
const { xData, yData, vData, alphaBlend = false } = parameters
|
|
95
|
+
|
|
96
|
+
const xQK = d.getQuantityKind(xData) ?? xData
|
|
97
|
+
const yQK = d.getQuantityKind(yData) ?? yData
|
|
98
|
+
const vQK = d.getQuantityKind(vData) ?? vData
|
|
99
|
+
|
|
100
|
+
const x = d.getData(xData)
|
|
101
|
+
const y = d.getData(yData)
|
|
102
|
+
const v = d.getData(vData)
|
|
86
103
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
if (!x) throw new Error(`Data column '${xData}' not found`)
|
|
105
|
+
if (!y) throw new Error(`Data column '${yData}' not found`)
|
|
106
|
+
if (!v) throw new Error(`Data column '${vData}' not found`)
|
|
90
107
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
108
|
+
const domains = {}
|
|
109
|
+
const xDomain = d.getDomain(xData)
|
|
110
|
+
const yDomain = d.getDomain(yData)
|
|
111
|
+
const vDomain = d.getDomain(vData)
|
|
112
|
+
if (xDomain) domains[xQK] = xDomain
|
|
113
|
+
if (yDomain) domains[yQK] = yDomain
|
|
114
|
+
if (vDomain) domains[vQK] = vDomain
|
|
94
115
|
|
|
95
116
|
return [{
|
|
96
|
-
attributes: { x, y, [
|
|
97
|
-
uniforms: {},
|
|
117
|
+
attributes: { x, y, [vQK]: v },
|
|
118
|
+
uniforms: { alphaBlend: alphaBlend ? 1.0 : 0.0 },
|
|
119
|
+
domains,
|
|
98
120
|
nameMap: {
|
|
99
|
-
[
|
|
100
|
-
[`colorscale_${
|
|
101
|
-
[`color_range_${
|
|
102
|
-
[`color_scale_type_${
|
|
121
|
+
[vQK]: 'color_data',
|
|
122
|
+
[`colorscale_${vQK}`]: 'colorscale',
|
|
123
|
+
[`color_range_${vQK}`]: 'color_range',
|
|
124
|
+
[`color_scale_type_${vQK}`]: 'color_scale_type',
|
|
103
125
|
},
|
|
126
|
+
blend: alphaBlend ? {
|
|
127
|
+
enable: true,
|
|
128
|
+
func: { srcRGB: 'src alpha', dstRGB: 'one minus src alpha', srcAlpha: 0, dstAlpha: 1 },
|
|
129
|
+
} : null,
|
|
104
130
|
}]
|
|
105
131
|
}
|
|
106
132
|
})
|