gladly-plot 0.0.5 → 0.0.6

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 (44) hide show
  1. package/README.md +9 -2
  2. package/package.json +10 -11
  3. package/src/axes/Axis.js +320 -172
  4. package/src/axes/AxisLink.js +6 -2
  5. package/src/axes/AxisRegistry.js +116 -39
  6. package/src/axes/Camera.js +47 -0
  7. package/src/axes/ColorAxisRegistry.js +10 -2
  8. package/src/axes/FilterAxisRegistry.js +1 -1
  9. package/src/axes/TickLabelAtlas.js +99 -0
  10. package/src/axes/ZoomController.js +446 -124
  11. package/src/colorscales/ColorscaleRegistry.js +30 -10
  12. package/src/compute/ComputationRegistry.js +126 -184
  13. package/src/compute/axisFilter.js +21 -9
  14. package/src/compute/conv.js +64 -8
  15. package/src/compute/elementwise.js +72 -0
  16. package/src/compute/fft.js +106 -20
  17. package/src/compute/filter.js +105 -103
  18. package/src/compute/hist.js +247 -142
  19. package/src/compute/kde.js +64 -46
  20. package/src/compute/scatter2dInterpolate.js +277 -0
  21. package/src/compute/util.js +196 -0
  22. package/src/core/ComputePipeline.js +153 -0
  23. package/src/core/GlBase.js +141 -0
  24. package/src/core/Layer.js +22 -8
  25. package/src/core/LayerType.js +251 -92
  26. package/src/core/Plot.js +630 -152
  27. package/src/core/PlotGroup.js +204 -0
  28. package/src/core/ShaderQueue.js +73 -0
  29. package/src/data/ColumnData.js +269 -0
  30. package/src/data/Computation.js +95 -0
  31. package/src/data/Data.js +270 -0
  32. package/src/floats/Float.js +56 -0
  33. package/src/index.js +16 -4
  34. package/src/layers/BarsLayer.js +168 -0
  35. package/src/layers/ColorbarLayer.js +10 -14
  36. package/src/layers/ColorbarLayer2d.js +13 -24
  37. package/src/layers/FilterbarLayer.js +4 -3
  38. package/src/layers/LinesLayer.js +108 -122
  39. package/src/layers/PointsLayer.js +73 -69
  40. package/src/layers/ScatterShared.js +62 -106
  41. package/src/layers/TileLayer.js +20 -16
  42. package/src/math/mat4.js +100 -0
  43. package/src/core/Data.js +0 -67
  44. package/src/layers/HistogramLayer.js +0 -212
@@ -1,31 +1,94 @@
1
1
  import * as d3 from "d3-scale"
2
2
  import { getAxisQuantityKind } from "./AxisQuantityKindRegistry.js"
3
3
 
4
- export const AXES = ["xaxis_bottom","xaxis_top","yaxis_left","yaxis_right"]
4
+ // Geometry of every spatial axis position in the normalised unit cube [-1, +1]³.
5
+ // dir: which dimension varies along this axis ('x', 'y', or 'z')
6
+ // fixed: the two non-varying coordinates on the unit cube faces
7
+ // outward: model-space unit vector pointing away from the cube face
8
+ // (tick marks and labels are offset in this direction)
9
+ export const AXIS_GEOMETRY = {
10
+ // X-axes: x ∈ [-1,+1] varies; y and z are fixed
11
+ 'xaxis_bottom': { dir: 'x', fixed: { y: -1, z: +1 }, outward: [0, -1, 0] },
12
+ 'xaxis_top': { dir: 'x', fixed: { y: +1, z: +1 }, outward: [0, +1, 0] },
13
+ 'xaxis_bottom_back': { dir: 'x', fixed: { y: -1, z: -1 }, outward: [0, -1, 0] },
14
+ 'xaxis_top_back': { dir: 'x', fixed: { y: +1, z: -1 }, outward: [0, +1, 0] },
15
+ // Y-axes: y ∈ [-1,+1] varies; x and z are fixed
16
+ 'yaxis_left': { dir: 'y', fixed: { x: -1, z: +1 }, outward: [-1, 0, 0] },
17
+ 'yaxis_right': { dir: 'y', fixed: { x: +1, z: +1 }, outward: [+1, 0, 0] },
18
+ 'yaxis_left_back': { dir: 'y', fixed: { x: -1, z: -1 }, outward: [-1, 0, 0] },
19
+ 'yaxis_right_back': { dir: 'y', fixed: { x: +1, z: -1 }, outward: [+1, 0, 0] },
20
+ // Z-axes: z ∈ [-1,+1] varies; x and y are fixed
21
+ 'zaxis_bottom_left': { dir: 'z', fixed: { x: -1, y: -1 }, outward: [0, -1, 0] },
22
+ 'zaxis_bottom_right': { dir: 'z', fixed: { x: +1, y: -1 }, outward: [0, -1, 0] },
23
+ 'zaxis_top_left': { dir: 'z', fixed: { x: -1, y: +1 }, outward: [0, +1, 0] },
24
+ 'zaxis_top_right': { dir: 'z', fixed: { x: +1, y: +1 }, outward: [0, +1, 0] },
25
+ }
26
+
27
+ // All 12 spatial axis names.
28
+ export const AXES = Object.keys(AXIS_GEOMETRY)
29
+
30
+ // The four original 2D axis positions (used by ZoomController for 2D pan/zoom).
31
+ export const AXES_2D = ['xaxis_bottom', 'xaxis_top', 'yaxis_left', 'yaxis_right']
32
+
33
+ // Returns the start and end model-space points [x,y,z] of an axis in the unit cube.
34
+ export function axisEndpoints(axisName) {
35
+ const { dir, fixed } = AXIS_GEOMETRY[axisName]
36
+ const start = [0, 0, 0], end = [0, 0, 0]
37
+ if (dir === 'x') {
38
+ start[0] = -1; end[0] = +1
39
+ start[1] = end[1] = fixed.y
40
+ start[2] = end[2] = fixed.z
41
+ } else if (dir === 'y') {
42
+ start[0] = end[0] = fixed.x
43
+ start[1] = -1; end[1] = +1
44
+ start[2] = end[2] = fixed.z
45
+ } else {
46
+ start[0] = end[0] = fixed.x
47
+ start[1] = end[1] = fixed.y
48
+ start[2] = -1; end[2] = +1
49
+ }
50
+ return { start, end }
51
+ }
52
+
53
+ // Returns the model-space position of a point at normalised position n ∈ [0,1] along an axis.
54
+ export function axisPosAtN(axisName, n) {
55
+ const u = n * 2 - 1 // [0,1] → [-1,+1]
56
+ const { dir, fixed } = AXIS_GEOMETRY[axisName]
57
+ if (dir === 'x') return [u, fixed.y, fixed.z]
58
+ if (dir === 'y') return [fixed.x, u, fixed.z]
59
+ return [fixed.x, fixed.y, u]
60
+ }
5
61
 
6
62
  export class AxisRegistry {
7
63
  constructor(width, height) {
8
64
  this.scales = {}
9
65
  this.axisQuantityKinds = {}
10
- this.width = width
66
+ this.width = width
11
67
  this.height = height
12
- AXES.forEach(a => {
13
- this.scales[a] = null
68
+ for (const a of AXES) {
69
+ this.scales[a] = null
14
70
  this.axisQuantityKinds[a] = null
15
- })
71
+ }
16
72
  }
17
73
 
18
74
  ensureAxis(axisName, axisQuantityKind, scaleOverride) {
19
- if (!AXES.includes(axisName)) throw new Error(`Unknown axis ${axisName}`)
75
+ if (!AXES.includes(axisName))
76
+ throw new Error(`Unknown axis '${axisName}'`)
20
77
  if (this.axisQuantityKinds[axisName] && this.axisQuantityKinds[axisName] !== axisQuantityKind)
21
- throw new Error(`Axis quantity kind mismatch on axis ${axisName}: ${this.axisQuantityKinds[axisName]} vs ${axisQuantityKind}`)
78
+ throw new Error(`Axis quantity kind mismatch on '${axisName}': ${this.axisQuantityKinds[axisName]} vs ${axisQuantityKind}`)
22
79
 
23
80
  if (!this.scales[axisName]) {
24
- const quantityKindDef = getAxisQuantityKind(axisQuantityKind)
25
- const scaleType = scaleOverride ?? quantityKindDef.scale
26
- this.scales[axisName] = scaleType === "log"
27
- ? d3.scaleLog().range(axisName.includes("y") ? [this.height,0] : [0,this.width])
28
- : d3.scaleLinear().range(axisName.includes("y") ? [this.height,0] : [0,this.width])
81
+ const qkDef = getAxisQuantityKind(axisQuantityKind)
82
+ const scaleType = scaleOverride ?? qkDef.scale
83
+ const dir = AXIS_GEOMETRY[axisName].dir
84
+ // D3 scale range: pixel length for x/y axes (used for tick-density hints in 2D).
85
+ // Z-axes use [0, 1] (no direct pixel mapping; tick density computed from projected length).
86
+ const range = dir === 'z' ? [0, 1]
87
+ : dir === 'y' ? [this.height, 0] // inverted so y=0 → top
88
+ : [0, this.width]
89
+ this.scales[axisName] = scaleType === 'log'
90
+ ? d3.scaleLog().range(range)
91
+ : d3.scaleLinear().range(range)
29
92
  this.axisQuantityKinds[axisName] = axisQuantityKind
30
93
  }
31
94
  return this.scales[axisName]
@@ -34,55 +97,65 @@ export class AxisRegistry {
34
97
  getScale(axisName) { return this.scales[axisName] }
35
98
 
36
99
  isLogScale(axisName) {
37
- const scale = this.scales[axisName]
38
- return !!scale && typeof scale.base === 'function'
100
+ const s = this.scales[axisName]
101
+ return !!s && typeof s.base === 'function'
39
102
  }
40
103
 
41
104
  applyAutoDomainsFromLayers(layers, axesOverrides) {
42
105
  const autoDomains = {}
43
106
 
44
107
  for (const axis of AXES) {
45
- const layersUsingAxis = layers.filter(l => l.xAxis === axis || l.yAxis === axis)
46
- if (layersUsingAxis.length === 0) continue
108
+ const used = layers.filter(l => l.xAxis === axis || l.yAxis === axis || l.zAxis === axis)
109
+ if (used.length === 0) continue
47
110
 
48
111
  let min = Infinity, max = -Infinity
49
- for (const layer of layersUsingAxis) {
50
- const isXAxis = layer.xAxis === axis
51
- const qk = isXAxis ? layer.xAxisQuantityKind : layer.yAxisQuantityKind
112
+ for (const layer of used) {
113
+ const qk = layer.xAxis === axis ? layer.xAxisQuantityKind
114
+ : layer.yAxis === axis ? layer.yAxisQuantityKind
115
+ : layer.zAxisQuantityKind
52
116
  if (layer.domains[qk] !== undefined) {
53
117
  const [dMin, dMax] = layer.domains[qk]
54
118
  if (dMin < min) min = dMin
55
119
  if (dMax > max) max = dMax
56
- } else {
57
- const dataArray = isXAxis ? layer.attributes.x : layer.attributes.y
58
- if (!dataArray) continue
59
- for (let i = 0; i < dataArray.length; i++) {
60
- const val = dataArray[i]
61
- if (val < min) min = val
62
- if (val > max) max = val
63
- }
120
+ } else if (qk && !layer.type?.suppressWarnings) {
121
+ console.warn(
122
+ `[gladly] Layer type '${layer.type?.name ?? 'unknown'}' has no domain for ` +
123
+ `quantity kind '${qk}' on axis '${axis}'. ` +
124
+ `Auto-domain for this axis cannot be computed from this layer.`
125
+ )
64
126
  }
65
127
  }
66
- if (min !== Infinity) autoDomains[axis] = [min, max]
128
+ if (min !== Infinity) {
129
+ if (!isFinite(min) || !isFinite(max))
130
+ throw new Error(`[gladly] Axis '${axis}': auto-computed domain [${min}, ${max}] is non-finite.`)
131
+ if (min === max)
132
+ console.warn(`[gladly] Axis '${axis}': auto-computed domain is degenerate (all data at ${min}).`)
133
+ autoDomains[axis] = [min, max]
134
+ }
67
135
  }
68
136
 
69
137
  for (const axis of AXES) {
70
138
  const scale = this.getScale(axis)
71
139
  if (!scale) continue
72
140
  const override = axesOverrides[axis]
73
- const domain = override ? [override.min, override.max] : autoDomains[axis]
74
- if (domain) scale.domain(domain)
141
+ const domain = (override?.min != null && override?.max != null)
142
+ ? [override.min, override.max]
143
+ : autoDomains[axis]
144
+ if (domain) {
145
+ const [lo, hi] = domain
146
+ if (lo == null || hi == null || !isFinite(lo) || !isFinite(hi))
147
+ throw new Error(`[gladly] Axis '${axis}': domain [${lo}, ${hi}] contains null or non-finite values.`)
148
+ if (lo === hi)
149
+ console.warn(`[gladly] Axis '${axis}': domain [${lo}] is degenerate (min equals max).`)
150
+ scale.domain(domain)
151
+ }
75
152
  }
76
153
 
77
154
  for (const axis of AXES) {
78
155
  if (!this.isLogScale(axis)) continue
79
156
  const [dMin, dMax] = this.getScale(axis).domain()
80
- if ((isFinite(dMin) && dMin <= 0) || (isFinite(dMax) && dMax <= 0)) {
81
- throw new Error(
82
- `Axis '${axis}' uses log scale but has non-positive domain [${dMin}, ${dMax}]. ` +
83
- `All data values and min/max must be > 0 for log scale.`
84
- )
85
- }
157
+ if ((isFinite(dMin) && dMin <= 0) || (isFinite(dMax) && dMax <= 0))
158
+ throw new Error(`Axis '${axis}' uses log scale but has non-positive domain [${dMin}, ${dMax}].`)
86
159
  }
87
160
  }
88
161
 
@@ -90,12 +163,16 @@ export class AxisRegistry {
90
163
  const scale = this.scales[axisName]
91
164
  if (!scale) return
92
165
  const currentIsLog = typeof scale.base === 'function'
93
- const wantLog = scaleType === "log"
166
+ const wantLog = scaleType === 'log'
94
167
  if (currentIsLog === wantLog) return
95
168
  const currentDomain = scale.domain()
169
+ const dir = AXIS_GEOMETRY[axisName].dir
170
+ const range = dir === 'z' ? [0, 1]
171
+ : dir === 'y' ? [this.height, 0]
172
+ : [0, this.width]
96
173
  const newScale = wantLog
97
- ? d3.scaleLog().range(axisName.includes("y") ? [this.height, 0] : [0, this.width])
98
- : d3.scaleLinear().range(axisName.includes("y") ? [this.height, 0] : [0, this.width])
174
+ ? d3.scaleLog().range(range)
175
+ : d3.scaleLinear().range(range)
99
176
  newScale.domain(currentDomain)
100
177
  this.scales[axisName] = newScale
101
178
  }
@@ -0,0 +1,47 @@
1
+ import {
2
+ mat4Identity, mat4Multiply, mat4Perspective, mat4LookAt,
3
+ sphericalToCartesian, vec3Normalize, vec3Cross,
4
+ } from '../math/mat4.js'
5
+
6
+ /**
7
+ * Camera manages the MVP matrix for the plot.
8
+ *
9
+ * In 2D mode (is3D=false): getMVP() returns the identity matrix.
10
+ * In 3D mode (is3D=true): orbit camera (y-up) with perspective projection.
11
+ * Mouse interaction is handled by ZoomController.
12
+ */
13
+ export class Camera {
14
+ constructor(is3D) {
15
+ this._is3D = is3D
16
+ this._theta = Math.PI / 4 // azimuth (rotation around y-axis)
17
+ this._phi = Math.PI / 6 // elevation (clamped away from poles)
18
+ this._radius = 3.0
19
+ this._fov = Math.PI / 4 // vertical field-of-view (45°)
20
+ this._aspect = 1.0 // width / height, updated by resize()
21
+ }
22
+
23
+ resize(width, height) {
24
+ this._aspect = width / height
25
+ }
26
+
27
+ // Returns the camera MVP matrix as a column-major Float32Array[16].
28
+ // In 2D mode this is the identity (data coordinates already map to NDC via domain uniforms).
29
+ getMVP() {
30
+ if (!this._is3D) return mat4Identity()
31
+ const eye = sphericalToCartesian(this._theta, this._phi, this._radius)
32
+ const view = mat4LookAt(eye, [0, 0, 0], [0, 1, 0])
33
+ const proj = mat4Perspective(this._fov, this._aspect, 0.1, 100)
34
+ return mat4Multiply(proj, view)
35
+ }
36
+
37
+ // Returns the camera right and up unit vectors in world space.
38
+ // Used to orient billboard quads so they always face the camera.
39
+ getCameraVectors() {
40
+ if (!this._is3D) return { right: [1, 0, 0], up: [0, 1, 0] }
41
+ const eye = sphericalToCartesian(this._theta, this._phi, this._radius)
42
+ const fwd = vec3Normalize([-eye[0], -eye[1], -eye[2]])
43
+ const right = vec3Normalize(vec3Cross(fwd, [0, 1, 0]))
44
+ const up = vec3Normalize(vec3Cross(right, fwd))
45
+ return { right, up }
46
+ }
47
+ }
@@ -8,12 +8,16 @@ export class ColorAxisRegistry {
8
8
 
9
9
  ensureColorAxis(quantityKind, colorscaleOverride = null) {
10
10
  if (!this._axes.has(quantityKind)) {
11
- this._axes.set(quantityKind, { colorscaleOverride, range: null })
11
+ this._axes.set(quantityKind, { colorscaleOverride, range: null, alphaBlend: 0.0 })
12
12
  } else if (colorscaleOverride !== null) {
13
13
  this._axes.get(quantityKind).colorscaleOverride = colorscaleOverride
14
14
  }
15
15
  }
16
16
 
17
+ getAlphaBlend(quantityKind) {
18
+ return this._axes.get(quantityKind)?.alphaBlend ?? 0.0
19
+ }
20
+
17
21
  setRange(quantityKind, min, max) {
18
22
  if (!this._axes.has(quantityKind)) {
19
23
  throw new Error(`Color axis '${quantityKind}' not found in registry`)
@@ -52,11 +56,13 @@ export class ColorAxisRegistry {
52
56
  const override = axesOverrides[quantityKind]
53
57
  if (override?.colorscale && override?.colorscale != "none")
54
58
  this.ensureColorAxis(quantityKind, override.colorscale)
59
+ if (override?.alpha_blend !== undefined)
60
+ this._axes.get(quantityKind).alphaBlend = override.alpha_blend
55
61
 
56
62
  let min = Infinity, max = -Infinity
57
63
 
58
64
  for (const layer of layers) {
59
- for (const qk of layer.colorAxes) {
65
+ for (const qk of Object.values(layer.colorAxes)) {
60
66
  if (qk !== quantityKind) continue
61
67
  if (layer.domains[qk] !== undefined) {
62
68
  const [dMin, dMax] = layer.domains[qk]
@@ -75,6 +81,8 @@ export class ColorAxisRegistry {
75
81
 
76
82
  if (min !== Infinity) {
77
83
  this.setRange(quantityKind, override?.min ?? min, override?.max ?? max)
84
+ } else if (override?.min !== undefined && override?.max !== undefined) {
85
+ this.setRange(quantityKind, override.min, override.max)
78
86
  }
79
87
  }
80
88
 
@@ -65,7 +65,7 @@ export class FilterAxisRegistry {
65
65
  let extMin = Infinity, extMax = -Infinity
66
66
 
67
67
  for (const layer of layers) {
68
- for (const qk of layer.filterAxes) {
68
+ for (const qk of Object.values(layer.filterAxes)) {
69
69
  if (qk !== quantityKind) continue
70
70
  if (layer.domains[qk] !== undefined) {
71
71
  const [dMin, dMax] = layer.domains[qk]
@@ -0,0 +1,99 @@
1
+ // Canvas-backed texture atlas for tick and axis title labels.
2
+ // Labels are rendered with Canvas 2D and packed into a single GPU texture (shelf packing).
3
+ // UV coordinates use the convention: v = canvas_y / ATLAS_SIZE (v=0 = canvas top).
4
+ // The atlas texture is uploaded with flipY: false, so texture(sampler, (u,v)) samples
5
+ // canvas pixel (u*W, v*H). Billboard quads use matching UV assignments.
6
+
7
+ const ATLAS_SIZE = 1024
8
+ const FONT = '12px sans-serif'
9
+ const PADDING = 2
10
+ const ROW_HEIGHT = 16 // fixed text-line height in pixels
11
+
12
+ export class TickLabelAtlas {
13
+ constructor(regl) {
14
+ this._regl = regl
15
+ this._canvas = null
16
+ this._ctx = null
17
+ this._texture = null
18
+ this._entries = new Map() // text → { u, v, uw, vh, pw, ph } | null (pending)
19
+ this._needsRebuild = false
20
+ }
21
+
22
+ // Mark a set of label strings as needed. Call before flush().
23
+ markLabels(labels) {
24
+ for (const l of labels) {
25
+ if (!this._entries.has(l)) {
26
+ this._entries.set(l, null)
27
+ this._needsRebuild = true
28
+ }
29
+ }
30
+ }
31
+
32
+ // Re-render the atlas canvas and re-upload the GPU texture if anything changed.
33
+ flush() {
34
+ if (!this._needsRebuild) return
35
+ this._needsRebuild = false
36
+
37
+ if (!this._canvas) {
38
+ this._canvas = document.createElement('canvas')
39
+ this._canvas.width = ATLAS_SIZE
40
+ this._canvas.height = ATLAS_SIZE
41
+ this._ctx = this._canvas.getContext('2d')
42
+ }
43
+
44
+ const ctx = this._ctx
45
+ ctx.clearRect(0, 0, ATLAS_SIZE, ATLAS_SIZE)
46
+ ctx.font = FONT
47
+ ctx.fillStyle = '#000'
48
+ ctx.textBaseline = 'top'
49
+
50
+ const rowH = ROW_HEIGHT + PADDING * 2
51
+ let x = 0, y = 0
52
+
53
+ for (const label of this._entries.keys()) {
54
+ const metrics = ctx.measureText(label)
55
+ const pw = Math.ceil(metrics.width) + PADDING * 2
56
+ const ph = rowH
57
+
58
+ if (x + pw > ATLAS_SIZE) { x = 0; y += rowH }
59
+ if (y + ph > ATLAS_SIZE) {
60
+ console.warn('[gladly] TickLabelAtlas: atlas is full; some labels may be missing')
61
+ break
62
+ }
63
+
64
+ ctx.fillText(label, x + PADDING, y + PADDING)
65
+
66
+ this._entries.set(label, {
67
+ u: x / ATLAS_SIZE,
68
+ v: y / ATLAS_SIZE,
69
+ uw: pw / ATLAS_SIZE,
70
+ vh: ph / ATLAS_SIZE,
71
+ pw,
72
+ ph,
73
+ })
74
+ x += pw
75
+ }
76
+
77
+ // flipY: false — v = canvas_y / ATLAS_SIZE; v=0 samples canvas top.
78
+ if (!this._texture) {
79
+ this._texture = this._regl.texture({
80
+ width: ATLAS_SIZE,
81
+ height: ATLAS_SIZE,
82
+ format: 'rgba',
83
+ mag: 'nearest',
84
+ min: 'nearest',
85
+ flipY: false,
86
+ })
87
+ }
88
+ this._texture.subimage({ data: this._canvas, x: 0, y: 0, flipY: false })
89
+ }
90
+
91
+ // Returns the atlas entry for a label, or null if it hasn't been built yet.
92
+ getEntry(label) { return this._entries.get(label) ?? null }
93
+
94
+ get texture() { return this._texture }
95
+
96
+ destroy() {
97
+ if (this._texture) { this._texture.destroy(); this._texture = null }
98
+ }
99
+ }