gladly-plot 0.0.4 → 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 (60) hide show
  1. package/README.md +9 -2
  2. package/package.json +10 -11
  3. package/src/axes/Axis.js +401 -0
  4. package/src/{AxisLink.js → axes/AxisLink.js} +6 -2
  5. package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
  6. package/src/axes/AxisRegistry.js +179 -0
  7. package/src/axes/Camera.js +47 -0
  8. package/src/axes/ColorAxisRegistry.js +101 -0
  9. package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
  10. package/src/axes/TickLabelAtlas.js +99 -0
  11. package/src/axes/ZoomController.js +463 -0
  12. package/src/colorscales/BivariateColorscales.js +205 -0
  13. package/src/colorscales/ColorscaleRegistry.js +144 -0
  14. package/src/compute/ComputationRegistry.js +179 -0
  15. package/src/compute/axisFilter.js +59 -0
  16. package/src/compute/conv.js +286 -0
  17. package/src/compute/elementwise.js +72 -0
  18. package/src/compute/fft.js +378 -0
  19. package/src/compute/filter.js +229 -0
  20. package/src/compute/hist.js +285 -0
  21. package/src/compute/kde.js +120 -0
  22. package/src/compute/scatter2dInterpolate.js +277 -0
  23. package/src/compute/util.js +196 -0
  24. package/src/core/ComputePipeline.js +153 -0
  25. package/src/core/GlBase.js +141 -0
  26. package/src/core/Layer.js +59 -0
  27. package/src/core/LayerType.js +433 -0
  28. package/src/core/Plot.js +1213 -0
  29. package/src/core/PlotGroup.js +204 -0
  30. package/src/core/ShaderQueue.js +73 -0
  31. package/src/data/ColumnData.js +269 -0
  32. package/src/data/Computation.js +95 -0
  33. package/src/data/Data.js +270 -0
  34. package/src/{Colorbar.js → floats/Colorbar.js} +19 -5
  35. package/src/floats/Colorbar2d.js +77 -0
  36. package/src/{Filterbar.js → floats/Filterbar.js} +18 -4
  37. package/src/{FilterbarFloat.js → floats/Float.js} +73 -30
  38. package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
  39. package/src/index.js +47 -22
  40. package/src/layers/BarsLayer.js +168 -0
  41. package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +12 -16
  42. package/src/layers/ColorbarLayer2d.js +86 -0
  43. package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +6 -5
  44. package/src/layers/LinesLayer.js +185 -0
  45. package/src/layers/PointsLayer.js +118 -0
  46. package/src/layers/ScatterShared.js +98 -0
  47. package/src/{TileLayer.js → layers/TileLayer.js} +24 -20
  48. package/src/math/mat4.js +100 -0
  49. package/src/Axis.js +0 -48
  50. package/src/AxisRegistry.js +0 -54
  51. package/src/ColorAxisRegistry.js +0 -49
  52. package/src/ColorscaleRegistry.js +0 -52
  53. package/src/Data.js +0 -67
  54. package/src/Float.js +0 -159
  55. package/src/Layer.js +0 -44
  56. package/src/LayerType.js +0 -209
  57. package/src/Plot.js +0 -1073
  58. package/src/ScatterLayer.js +0 -287
  59. /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
  60. /package/src/{LayerTypeRegistry.js → core/LayerTypeRegistry.js} +0 -0
package/README.md CHANGED
@@ -19,11 +19,18 @@ Gladly combines WebGL rendering (via regl) with D3.js for interactive axes and z
19
19
  - 🌈 Axis to coloring or filtering linking
20
20
  - 🌎 Basemap layer with XYZ,WMS and WMTS support and CRS reprojection
21
21
 
22
+ ## Extensions
23
+
24
+ - **[gladly-jupyter](https://redhog.github.io/gladly-jupyter/)** - Jupyter notebook widget extension for Gladly
25
+
22
26
  ## Documentation
23
27
 
24
28
  - **[Quick Start](docs/Quickstart.md)** - Installation and minimal working example
25
- - **[API Documentation](docs/API.md)** - Complete API reference and usage guide
26
- - **[Architecture Documentation](docs/ARCHITECTURE.md)** - Developer guide and design patterns
29
+ - **[Concepts](docs/concepts/Overview.md)** - Data model overview: axes, quantity kinds, colorscales, layer types, data format
30
+ - **[Configuration](docs/configuration/overview.md)** - Plot configuration, layers, axes, colorscales, built-in computations
31
+ - **[User API](docs/user-api/overview.md)** - Programmatic APIs for plots, axes, data, and computations
32
+ - **[Extension API](docs/extension-api/overview.md)** - Writing custom layer types and computations
33
+ - **[Architecture](docs/architecture/overview.md)** - Design patterns and module responsibilities
27
34
 
28
35
  ## Technology Stack
29
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gladly-plot",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "GPU-powered multi-axis plotting library with regl + d3",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,15 +10,14 @@
10
10
  "src"
11
11
  ],
12
12
  "scripts": {
13
- "prepare": "node scripts/fix-numjs-wasm.js",
14
- "dev": "parcel serve example/index.html --open",
15
- "build:example": "parcel build example/index.html --dist-dir dist-example --public-url ./",
16
- "preview": "npm run build:example && npx serve dist-example"
13
+ "dev": "npx serve . -l 1234",
14
+ "build:example": "bash scripts/build-example.sh",
15
+ "build:lib": "rollup -c"
17
16
  },
18
17
  "dependencies": {
19
18
  "d3": "^7.8.5",
20
19
  "proj4": "^2.15.0",
21
- "projnames": "^0.0.2",
20
+ "projnames": "^0.0.4",
22
21
  "regl": "^2.1.0"
23
22
  },
24
23
  "browserslist": [
@@ -27,12 +26,12 @@
27
26
  "last 2 Firefox versions",
28
27
  "last 2 Safari versions"
29
28
  ],
30
-
31
29
  "devDependencies": {
32
- "@jayce789/numjs": "^2.2.6",
33
30
  "@json-editor/json-editor": "^2.15.1",
34
- "parcel": "^2.9.0",
35
- "process": "^0.11.10",
36
- "url": "^0.11.4"
31
+ "@rollup/plugin-commonjs": "^29.0.2",
32
+ "@rollup/plugin-json": "^6.1.0",
33
+ "@rollup/plugin-node-resolve": "^15.3.1",
34
+ "@rollup/plugin-terser": "^1.0.0",
35
+ "rollup": "^4.59.1"
37
36
  }
38
37
  }
@@ -0,0 +1,401 @@
1
+ import { AXIS_GEOMETRY, axisEndpoints, axisPosAtN } from "./AxisRegistry.js"
2
+ import { getAxisQuantityKind } from "./AxisQuantityKindRegistry.js"
3
+ import { projectToScreen } from "../math/mat4.js"
4
+
5
+ // ─── Tick formatting (same logic as before) ───────────────────────────────────
6
+
7
+ function formatTick(v) {
8
+ if (v === 0) return "0"
9
+ const abs = Math.abs(v)
10
+ if (abs >= 10000 || abs < 0.01) {
11
+ return v.toExponential(2).replace(/\.?0+(e)/, '$1')
12
+ }
13
+ const s = v.toPrecision(4)
14
+ if (s.includes('.') && !s.includes('e')) return s.replace(/\.?0+$/, '')
15
+ return s
16
+ }
17
+
18
+ function logTickValues(scale, count) {
19
+ const [dMin, dMax] = scale.domain()
20
+ if (dMin <= 0 || dMax <= 0) return null
21
+ const logMin = Math.log10(dMin), logMax = Math.log10(dMax)
22
+ const startExp = Math.floor(logMin), endExp = Math.ceil(logMax)
23
+ const candidate = []
24
+ for (let e = startExp; e < endExp; e++) {
25
+ const base = Math.pow(10, e)
26
+ for (const mult of [1, 2, 5]) {
27
+ const v = base * mult
28
+ if (v >= dMin * (1 - 1e-10) && v <= dMax * (1 + 1e-10)) candidate.push(v)
29
+ }
30
+ }
31
+ const upperPow = Math.pow(10, endExp)
32
+ if (upperPow >= dMin * (1 - 1e-10) && upperPow <= dMax * (1 + 1e-10)) candidate.push(upperPow)
33
+ if (candidate.length >= 2 && candidate.length <= count) return candidate
34
+ const firstExp = Math.ceil(logMin), lastExp = Math.floor(logMax)
35
+ if (firstExp > lastExp) return candidate.length >= 2 ? candidate : null
36
+ const numPowers = lastExp - firstExp + 1
37
+ const step = numPowers > count ? Math.ceil(numPowers / count) : 1
38
+ const powers = []
39
+ for (let e = firstExp; e <= lastExp; e += step) powers.push(Math.pow(10, e))
40
+ return powers.length >= 2 ? powers : null
41
+ }
42
+
43
+ // Normalise a data value to [0,1] within its domain (matches GLSL normalize_axis).
44
+ function normaliseValue(v, domain, isLog) {
45
+ const vt = isLog ? Math.log(v) : v
46
+ const d0 = isLog ? Math.log(domain[0]) : domain[0]
47
+ const d1 = isLog ? Math.log(domain[1]) : domain[1]
48
+ return (vt - d0) / (d1 - d0)
49
+ }
50
+
51
+ // ─── Tick mark / label geometry constants ─────────────────────────────────────
52
+ // 3D: fixed model-space offsets (legacy behaviour)
53
+ const TICK_LEN_3D = 0.05
54
+ const LABEL_DIST_3D = 0.12
55
+ const TITLE_EXTRA_3D = 0.14 // extra outward offset for axis title beyond LABEL_DIST_3D
56
+ const TITLE_LINE_STEP_3D = 0.05 // multi-line title step in model space
57
+
58
+ // 2D: fractions of the pixel distance from the axis to the nearest canvas edge
59
+ const TICK_LEN_FRAC = 0.12
60
+ const TICK_LABEL_FRAC = 0.40
61
+ const AXIS_TITLE_FRAC = 0.75
62
+ const TITLE_LINE_SPACING_FRAC = 0.12
63
+
64
+ /**
65
+ * An Axis represents a single data axis on a plot.
66
+ *
67
+ * Public interface (unchanged from before):
68
+ * axis.quantityKind, axis.isSpatial, axis.getDomain(), axis.setDomain(),
69
+ * axis.subscribe(), axis.unsubscribe()
70
+ *
71
+ * Rendering is now entirely WebGL-based. Call axis.render() from Plot.render().
72
+ */
73
+ export class Axis {
74
+ constructor(plot, name) {
75
+ this._plot = plot
76
+ this._name = name
77
+ this._listeners = new Set()
78
+ this._linkedAxes = new Set()
79
+ this._propagating = false
80
+ }
81
+
82
+ get quantityKind() { return this._plot.getAxisQuantityKind(this._name) }
83
+
84
+ // True for all 12 spatial axes; false for colour/filter axes.
85
+ get isSpatial() { return Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, this._name) }
86
+
87
+ getDomain() { return this._plot.getAxisDomain(this._name) }
88
+
89
+ setDomain(domain, { sourcePlot = null } = {}) {
90
+ if (this._propagating) return
91
+ this._propagating = true
92
+ try {
93
+ this._plot.setAxisDomain(this._name, domain)
94
+ this._plot.scheduleRender(sourcePlot)
95
+ for (const cb of this._listeners) cb(domain)
96
+ } finally {
97
+ this._propagating = false
98
+ }
99
+ }
100
+
101
+ subscribe(callback) { this._listeners.add(callback) }
102
+ unsubscribe(callback) { this._listeners.delete(callback) }
103
+
104
+ // ─── WebGL rendering ───────────────────────────────────────────────────────
105
+
106
+ // Pre-pass: mark all labels this axis needs into the atlas (no flush).
107
+ // Called by Plot.render() for all axes before the single atlas.flush().
108
+ prepareAtlas(atlas, axisMvp, cw, ch) {
109
+ if (!this.isSpatial) return
110
+ const { axisRegistry, currentConfig } = this._plot
111
+ if (currentConfig?.axes?.[this._name]?.visible === false) return
112
+ const scale = axisRegistry.getScale(this._name)
113
+ if (!scale) return
114
+
115
+ const screenLen = this._projectedLength(axisMvp, cw, ch)
116
+ const pxPerTick = AXIS_GEOMETRY[this._name].dir === 'y' ? 27 : 40
117
+ const tickCount = Math.max(2, Math.floor(screenLen / pxPerTick))
118
+ const ticks = this._computeTicks(scale, tickCount)
119
+ atlas.markLabels(ticks.map(t => formatTick(t)))
120
+
121
+ const qk = axisRegistry.axisQuantityKinds[this._name]
122
+ if (qk) {
123
+ const axisConfig = currentConfig?.axes?.[this._name] ?? {}
124
+ const unitLabel = axisConfig.label ?? getAxisQuantityKind(qk).label
125
+ if (unitLabel) atlas.markLabels(String(unitLabel).split('\n'))
126
+ }
127
+ }
128
+
129
+ // Compute the approximate projected screen length of this axis (for tick density).
130
+ _projectedLength(axisMvp, cw, ch) {
131
+ const { start, end } = axisEndpoints(this._name)
132
+ const s = projectToScreen(start, axisMvp, cw, ch)
133
+ const e = projectToScreen(end, axisMvp, cw, ch)
134
+ if (!s || !e) return 0
135
+ const dx = e[0] - s[0], dy = e[1] - s[1]
136
+ return Math.sqrt(dx * dx + dy * dy)
137
+ }
138
+
139
+ // Returns tick values as an array of numbers.
140
+ _computeTicks(scale, count) {
141
+ const isLog = typeof scale.base === 'function'
142
+ if (isLog) {
143
+ const tv = logTickValues(scale, count)
144
+ if (tv !== null) return tv
145
+ }
146
+ if (count <= 2) return scale.domain()
147
+ return scale.ticks(count)
148
+ }
149
+
150
+ // Returns the outward screen-space unit direction [dx, dy] (HTML coords, y down).
151
+ _outwardScreenDir(axisMvp, cw, ch) {
152
+ const { start, end } = axisEndpoints(this._name)
153
+ const mid3D = [(start[0]+end[0])/2, (start[1]+end[1])/2, (start[2]+end[2])/2]
154
+ const ow = AXIS_GEOMETRY[this._name].outward
155
+ const tip3D = [mid3D[0] + ow[0]*0.2, mid3D[1] + ow[1]*0.2, mid3D[2] + ow[2]*0.2]
156
+ const midS = projectToScreen(mid3D, axisMvp, cw, ch)
157
+ const tipS = projectToScreen(tip3D, axisMvp, cw, ch)
158
+ if (!midS || !tipS) return [0, 1]
159
+ const dx = tipS[0] - midS[0], dy = tipS[1] - midS[1]
160
+ const len = Math.sqrt(dx*dx + dy*dy)
161
+ return len > 0.5 ? [dx/len, dy/len] : [0, 1]
162
+ }
163
+
164
+ // Greedy screen-space overlap rejection. Returns an array of indices into `ticks`
165
+ // that can be rendered without their label boxes overlapping.
166
+ _visibleTickIndices(ticks, screenPositions, tickLabelAtlas) {
167
+ const accepted = [], boxes = []
168
+ for (let i = 0; i < ticks.length; i++) {
169
+ const sp = screenPositions[i]
170
+ if (!sp) continue
171
+ const label = formatTick(ticks[i])
172
+ const entry = tickLabelAtlas.getEntry(label)
173
+ const pw = entry ? entry.pw : 48, ph = entry ? entry.ph : 16
174
+ const box = [sp[0] - pw/2, sp[1] - ph/2, sp[0] + pw/2, sp[1] + ph/2]
175
+ if (boxes.some(b => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1])) continue
176
+ accepted.push(i)
177
+ boxes.push(box)
178
+ }
179
+ return accepted
180
+ }
181
+
182
+ /**
183
+ * Render this axis using the shared WebGL draw commands supplied by Plot.
184
+ *
185
+ * @param {object} regl - regl instance
186
+ * @param {Float32Array} axisMvp - MVP that maps model space to full-canvas NDC
187
+ * @param {number} cw - canvas width in pixels
188
+ * @param {number} ch - canvas height in pixels
189
+ * @param {boolean} is3D - enables depth testing (3D) vs always-on-top (2D)
190
+ * @param {TickLabelAtlas} atlas - shared label atlas
191
+ * @param {Function} lineCmd - compiled regl command for axis/tick lines
192
+ * @param {Function} billboardCmd- compiled regl command for label billboards
193
+ */
194
+ render(regl, axisMvp, cw, ch, is3D, atlas, lineCmd, billboardCmd) {
195
+ if (!this.isSpatial) return
196
+ const { axisRegistry, currentConfig } = this._plot
197
+ if (currentConfig?.axes?.[this._name]?.visible === false) return
198
+ const scale = axisRegistry.getScale(this._name)
199
+ if (!scale) return
200
+
201
+ const geom = AXIS_GEOMETRY[this._name]
202
+ const isLog = typeof scale.base === 'function'
203
+ const domain = scale.domain()
204
+ const { start, end } = axisEndpoints(this._name)
205
+ const ow = geom.outward // [ox, oy, oz]
206
+
207
+ // ── 0. Outward screen direction + model-space offset resolution ──────────
208
+ const mid3D = [(start[0]+end[0])/2, (start[1]+end[1])/2, (start[2]+end[2])/2]
209
+ const tip3D = [mid3D[0]+ow[0]*0.2, mid3D[1]+ow[1]*0.2, mid3D[2]+ow[2]*0.2]
210
+ const midS = projectToScreen(mid3D, axisMvp, cw, ch)
211
+ const tipS = projectToScreen(tip3D, axisMvp, cw, ch)
212
+ const outDirVec = (midS && tipS) ? [tipS[0]-midS[0], tipS[1]-midS[1]] : [0, 1]
213
+ const outDirLen = Math.sqrt(outDirVec[0]**2 + outDirVec[1]**2)
214
+ const outDir = outDirLen > 0.5 ? [outDirVec[0]/outDirLen, outDirVec[1]/outDirLen] : [0, 1]
215
+ const pxPerModelUnit = outDirLen / 0.2
216
+
217
+ let tickModelLen, labelModelDist, titleModelDist, titleLineStep
218
+ if (!is3D && midS && pxPerModelUnit > 0) {
219
+ // In 2D: make all offsets proportional to the pixel distance from the axis
220
+ // to the nearest canvas edge (i.e. the margin width in pixels).
221
+ const tx = outDir[0] > 1e-6 ? (cw - midS[0]) / outDir[0]
222
+ : outDir[0] < -1e-6 ? -midS[0] / outDir[0]
223
+ : Infinity
224
+ const ty = outDir[1] > 1e-6 ? (ch - midS[1]) / outDir[1]
225
+ : outDir[1] < -1e-6 ? -midS[1] / outDir[1]
226
+ : Infinity
227
+ const distToEdge = Math.max(4, Math.min(tx, ty))
228
+ const scale2d = distToEdge / pxPerModelUnit
229
+ tickModelLen = TICK_LEN_FRAC * scale2d
230
+ labelModelDist = TICK_LABEL_FRAC * scale2d
231
+ titleModelDist = AXIS_TITLE_FRAC * scale2d
232
+ titleLineStep = TITLE_LINE_SPACING_FRAC * scale2d
233
+ } else {
234
+ // In 3D: fixed model-space offsets (original behaviour).
235
+ tickModelLen = TICK_LEN_3D
236
+ labelModelDist = LABEL_DIST_3D
237
+ titleModelDist = LABEL_DIST_3D + TITLE_EXTRA_3D
238
+ titleLineStep = TITLE_LINE_STEP_3D
239
+ }
240
+
241
+ // ── 1. Tick count based on projected screen length ──────────────────────
242
+ const screenLen = this._projectedLength(axisMvp, cw, ch)
243
+ const pxPerTick = geom.dir === 'y' ? 27 : 40
244
+ const tickCount = Math.max(2, Math.floor(screenLen / pxPerTick))
245
+ const ticks = this._computeTicks(scale, tickCount)
246
+
247
+ // ── 2. Build axis line + tick-mark geometry ─────────────────────────────
248
+ // Each pair of consecutive floats = one endpoint of a line segment (primitive: 'lines').
249
+ const lineVerts = []
250
+
251
+ // Main axis line
252
+ lineVerts.push(...start, ...end)
253
+
254
+ // Tick marks
255
+ for (const t of ticks) {
256
+ const n = normaliseValue(t, domain, isLog)
257
+ if (!isFinite(n)) continue
258
+ const pos = axisPosAtN(this._name, n)
259
+ lineVerts.push(
260
+ pos[0], pos[1], pos[2],
261
+ pos[0] + ow[0]*tickModelLen, pos[1] + ow[1]*tickModelLen, pos[2] + ow[2]*tickModelLen,
262
+ )
263
+ }
264
+
265
+ const fullViewport = { x: 0, y: 0, width: cw, height: ch }
266
+
267
+ lineCmd({
268
+ positions: new Float32Array(lineVerts),
269
+ mvp: axisMvp,
270
+ color: [0, 0, 0, 1],
271
+ viewport: fullViewport,
272
+ count: lineVerts.length / 3,
273
+ depthEnable: is3D,
274
+ })
275
+
276
+ // ── 3. Tick labels ──────────────────────────────────────────────────────
277
+ const labels = ticks.map(t => formatTick(t))
278
+
279
+ if (!atlas.texture) return
280
+
281
+ // Compute label anchor positions in model space and project to screen.
282
+ const anchors3D = ticks.map((t) => {
283
+ const n = normaliseValue(t, domain, isLog)
284
+ if (!isFinite(n)) return null
285
+ const pos = axisPosAtN(this._name, n)
286
+ return [pos[0] + ow[0]*labelModelDist, pos[1] + ow[1]*labelModelDist, pos[2] + ow[2]*labelModelDist]
287
+ })
288
+ const screenPositions = anchors3D.map(a => a ? projectToScreen(a, axisMvp, cw, ch) : null)
289
+
290
+ const visIdx = this._visibleTickIndices(ticks, screenPositions, atlas)
291
+
292
+ // Build billboard vertex arrays.
293
+ const aAnchor = [], aOffsetPx = [], aUV = []
294
+
295
+ for (const i of visIdx) {
296
+ const anchor3D = anchors3D[i]
297
+ if (!anchor3D) continue
298
+ const entry = atlas.getEntry(labels[i])
299
+ if (!entry) continue
300
+ const { pw, ph, u, v, uw, vh } = entry
301
+ const hw = pw / 2, hh = ph / 2
302
+ // 6 vertices (2 triangles): TL TR BL TR BR BL
303
+ const corners = [
304
+ [-hw, -hh, u, v ],
305
+ [+hw, -hh, u+uw, v ],
306
+ [-hw, +hh, u, v+vh],
307
+ [+hw, -hh, u+uw, v ],
308
+ [+hw, +hh, u+uw, v+vh],
309
+ [-hw, +hh, u, v+vh],
310
+ ]
311
+ for (const [ox, oy, tu, tv] of corners) {
312
+ aAnchor.push(...anchor3D)
313
+ aOffsetPx.push(ox, oy)
314
+ aUV.push(tu, tv)
315
+ }
316
+ }
317
+
318
+ if (aAnchor.length > 0) {
319
+ billboardCmd({
320
+ anchors: new Float32Array(aAnchor),
321
+ offsetsPx: new Float32Array(aOffsetPx),
322
+ uvs: new Float32Array(aUV),
323
+ mvp: axisMvp,
324
+ canvasSize: [cw, ch],
325
+ atlas: atlas.texture,
326
+ viewport: fullViewport,
327
+ count: aAnchor.length / 3,
328
+ depthEnable: is3D,
329
+ })
330
+ }
331
+
332
+ // ── 4. Axis title ───────────────────────────────────────────────────────
333
+ const qk = axisRegistry.axisQuantityKinds[this._name]
334
+ if (!qk) return
335
+ const axisConfig = currentConfig?.axes?.[this._name] ?? {}
336
+ const unitLabel = axisConfig.label ?? getAxisQuantityKind(qk).label
337
+ if (!unitLabel) return
338
+
339
+ const titleLines = String(unitLabel).split('\n')
340
+
341
+ // Place title at axis midpoint + larger outward offset
342
+ const titleAnchorBase = [
343
+ mid3D[0] + ow[0] * titleModelDist,
344
+ mid3D[1] + ow[1] * titleModelDist,
345
+ mid3D[2] + ow[2] * titleModelDist,
346
+ ]
347
+
348
+ const titleAnchor = [], titleOffsets = [], titleUVs = []
349
+ let lineOffset = 0
350
+ for (const line of titleLines) {
351
+ const entry = atlas.getEntry(line)
352
+ if (!entry) continue
353
+ const { pw, ph, u, v, uw, vh } = entry
354
+ const hw = pw / 2, hh = ph / 2
355
+ // Shift successive lines along the outward screen direction
356
+ const anchor3D = [
357
+ titleAnchorBase[0] + ow[0]*lineOffset,
358
+ titleAnchorBase[1] + ow[1]*lineOffset,
359
+ titleAnchorBase[2] + ow[2]*lineOffset,
360
+ ]
361
+ const rawCorners = [
362
+ [-hw, -hh, u, v ],
363
+ [+hw, -hh, u+uw, v ],
364
+ [-hw, +hh, u, v+vh],
365
+ [+hw, -hh, u+uw, v ],
366
+ [+hw, +hh, u+uw, v+vh],
367
+ [-hw, +hh, u, v+vh],
368
+ ]
369
+ // Rotate the title 90° CW when the axis is more vertical than horizontal
370
+ // in screen space (works for 2D and 3D). Rotation: (ox, oy) → (oy, -ox)
371
+ const sStart = projectToScreen(start, axisMvp, cw, ch)
372
+ const sEnd = projectToScreen(end, axisMvp, cw, ch)
373
+ const axisDx = sEnd ? sEnd[0] - sStart[0] : 0
374
+ const axisDy = sEnd ? sEnd[1] - sStart[1] : 0
375
+ const isVertical = Math.abs(axisDy) > Math.abs(axisDx)
376
+ const corners = isVertical
377
+ ? rawCorners.map(([ox, oy, tu, tv]) => [oy, -ox, tu, tv])
378
+ : rawCorners
379
+ for (const [ox, oy, tu, tv] of corners) {
380
+ titleAnchor.push(...anchor3D)
381
+ titleOffsets.push(ox, oy)
382
+ titleUVs.push(tu, tv)
383
+ }
384
+ lineOffset += titleLineStep // shift next line further out in model space
385
+ }
386
+
387
+ if (titleAnchor.length > 0) {
388
+ billboardCmd({
389
+ anchors: new Float32Array(titleAnchor),
390
+ offsetsPx: new Float32Array(titleOffsets),
391
+ uvs: new Float32Array(titleUVs),
392
+ mvp: axisMvp,
393
+ canvasSize: [cw, ch],
394
+ atlas: atlas.texture,
395
+ viewport: fullViewport,
396
+ count: titleAnchor.length / 3,
397
+ depthEnable: is3D,
398
+ })
399
+ }
400
+ }
401
+ }
@@ -16,16 +16,20 @@ export function linkAxes(axis1, axis2) {
16
16
  throw new Error(`Cannot link axes with incompatible quantity kinds: ${qk1} vs ${qk2}`)
17
17
  }
18
18
 
19
- const cb1 = (domain) => axis2.setDomain(domain)
20
- const cb2 = (domain) => axis1.setDomain(domain)
19
+ const cb1 = (domain) => axis2.setDomain(domain, { sourcePlot: axis1._plot })
20
+ const cb2 = (domain) => axis1.setDomain(domain, { sourcePlot: axis2._plot })
21
21
 
22
22
  axis1.subscribe(cb1)
23
23
  axis2.subscribe(cb2)
24
+ axis1._linkedAxes.add(axis2)
25
+ axis2._linkedAxes.add(axis1)
24
26
 
25
27
  return {
26
28
  unlink() {
27
29
  axis1.unsubscribe(cb1)
28
30
  axis2.unsubscribe(cb2)
31
+ axis1._linkedAxes.delete(axis2)
32
+ axis2._linkedAxes.delete(axis1)
29
33
  }
30
34
  }
31
35
  }
@@ -21,3 +21,10 @@ export function getAxisQuantityKind(name) {
21
21
  export function getRegisteredAxisQuantityKinds() {
22
22
  return Array.from(registry.keys())
23
23
  }
24
+
25
+ // Returns 1.0 for log scale, 0.0 for linear. axesConfig is the `axes` sub-object of plot config.
26
+ export function getScaleTypeFloat(quantityKind, axesConfig) {
27
+ const configScale = axesConfig?.[quantityKind]?.scale
28
+ const defScale = getAxisQuantityKind(quantityKind).scale
29
+ return (configScale ?? defScale) === "log" ? 1.0 : 0.0
30
+ }
@@ -0,0 +1,179 @@
1
+ import * as d3 from "d3-scale"
2
+ import { getAxisQuantityKind } from "./AxisQuantityKindRegistry.js"
3
+
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
+ }
61
+
62
+ export class AxisRegistry {
63
+ constructor(width, height) {
64
+ this.scales = {}
65
+ this.axisQuantityKinds = {}
66
+ this.width = width
67
+ this.height = height
68
+ for (const a of AXES) {
69
+ this.scales[a] = null
70
+ this.axisQuantityKinds[a] = null
71
+ }
72
+ }
73
+
74
+ ensureAxis(axisName, axisQuantityKind, scaleOverride) {
75
+ if (!AXES.includes(axisName))
76
+ throw new Error(`Unknown axis '${axisName}'`)
77
+ if (this.axisQuantityKinds[axisName] && this.axisQuantityKinds[axisName] !== axisQuantityKind)
78
+ throw new Error(`Axis quantity kind mismatch on '${axisName}': ${this.axisQuantityKinds[axisName]} vs ${axisQuantityKind}`)
79
+
80
+ if (!this.scales[axisName]) {
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)
92
+ this.axisQuantityKinds[axisName] = axisQuantityKind
93
+ }
94
+ return this.scales[axisName]
95
+ }
96
+
97
+ getScale(axisName) { return this.scales[axisName] }
98
+
99
+ isLogScale(axisName) {
100
+ const s = this.scales[axisName]
101
+ return !!s && typeof s.base === 'function'
102
+ }
103
+
104
+ applyAutoDomainsFromLayers(layers, axesOverrides) {
105
+ const autoDomains = {}
106
+
107
+ for (const axis of AXES) {
108
+ const used = layers.filter(l => l.xAxis === axis || l.yAxis === axis || l.zAxis === axis)
109
+ if (used.length === 0) continue
110
+
111
+ let min = Infinity, max = -Infinity
112
+ for (const layer of used) {
113
+ const qk = layer.xAxis === axis ? layer.xAxisQuantityKind
114
+ : layer.yAxis === axis ? layer.yAxisQuantityKind
115
+ : layer.zAxisQuantityKind
116
+ if (layer.domains[qk] !== undefined) {
117
+ const [dMin, dMax] = layer.domains[qk]
118
+ if (dMin < min) min = dMin
119
+ if (dMax > max) max = dMax
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
+ )
126
+ }
127
+ }
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
+ }
135
+ }
136
+
137
+ for (const axis of AXES) {
138
+ const scale = this.getScale(axis)
139
+ if (!scale) continue
140
+ const override = axesOverrides[axis]
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
+ }
152
+ }
153
+
154
+ for (const axis of AXES) {
155
+ if (!this.isLogScale(axis)) continue
156
+ const [dMin, dMax] = this.getScale(axis).domain()
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}].`)
159
+ }
160
+ }
161
+
162
+ setScaleType(axisName, scaleType) {
163
+ const scale = this.scales[axisName]
164
+ if (!scale) return
165
+ const currentIsLog = typeof scale.base === 'function'
166
+ const wantLog = scaleType === 'log'
167
+ if (currentIsLog === wantLog) return
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]
173
+ const newScale = wantLog
174
+ ? d3.scaleLog().range(range)
175
+ : d3.scaleLinear().range(range)
176
+ newScale.domain(currentDomain)
177
+ this.scales[axisName] = newScale
178
+ }
179
+ }