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.
- package/README.md +9 -2
- package/package.json +10 -11
- package/src/axes/Axis.js +401 -0
- package/src/{AxisLink.js → axes/AxisLink.js} +6 -2
- package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
- package/src/axes/AxisRegistry.js +179 -0
- package/src/axes/Camera.js +47 -0
- package/src/axes/ColorAxisRegistry.js +101 -0
- package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
- package/src/axes/TickLabelAtlas.js +99 -0
- package/src/axes/ZoomController.js +463 -0
- package/src/colorscales/BivariateColorscales.js +205 -0
- package/src/colorscales/ColorscaleRegistry.js +144 -0
- package/src/compute/ComputationRegistry.js +179 -0
- package/src/compute/axisFilter.js +59 -0
- package/src/compute/conv.js +286 -0
- package/src/compute/elementwise.js +72 -0
- package/src/compute/fft.js +378 -0
- package/src/compute/filter.js +229 -0
- package/src/compute/hist.js +285 -0
- package/src/compute/kde.js +120 -0
- package/src/compute/scatter2dInterpolate.js +277 -0
- package/src/compute/util.js +196 -0
- package/src/core/ComputePipeline.js +153 -0
- package/src/core/GlBase.js +141 -0
- package/src/core/Layer.js +59 -0
- package/src/core/LayerType.js +433 -0
- package/src/core/Plot.js +1213 -0
- package/src/core/PlotGroup.js +204 -0
- package/src/core/ShaderQueue.js +73 -0
- package/src/data/ColumnData.js +269 -0
- package/src/data/Computation.js +95 -0
- package/src/data/Data.js +270 -0
- package/src/{Colorbar.js → floats/Colorbar.js} +19 -5
- package/src/floats/Colorbar2d.js +77 -0
- package/src/{Filterbar.js → floats/Filterbar.js} +18 -4
- package/src/{FilterbarFloat.js → floats/Float.js} +73 -30
- package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
- package/src/index.js +47 -22
- package/src/layers/BarsLayer.js +168 -0
- package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +12 -16
- package/src/layers/ColorbarLayer2d.js +86 -0
- package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +6 -5
- package/src/layers/LinesLayer.js +185 -0
- package/src/layers/PointsLayer.js +118 -0
- package/src/layers/ScatterShared.js +98 -0
- package/src/{TileLayer.js → layers/TileLayer.js} +24 -20
- package/src/math/mat4.js +100 -0
- package/src/Axis.js +0 -48
- package/src/AxisRegistry.js +0 -54
- package/src/ColorAxisRegistry.js +0 -49
- package/src/ColorscaleRegistry.js +0 -52
- package/src/Data.js +0 -67
- package/src/Float.js +0 -159
- package/src/Layer.js +0 -44
- package/src/LayerType.js +0 -209
- package/src/Plot.js +0 -1073
- package/src/ScatterLayer.js +0 -287
- /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
- /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
|
-
- **[
|
|
26
|
-
- **[
|
|
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.
|
|
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
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"build:
|
|
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.
|
|
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
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
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
|
}
|
package/src/axes/Axis.js
ADDED
|
@@ -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
|
+
}
|