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.
- package/README.md +9 -2
- package/package.json +10 -11
- package/src/axes/Axis.js +320 -172
- package/src/axes/AxisLink.js +6 -2
- package/src/axes/AxisRegistry.js +116 -39
- package/src/axes/Camera.js +47 -0
- package/src/axes/ColorAxisRegistry.js +10 -2
- package/src/axes/FilterAxisRegistry.js +1 -1
- package/src/axes/TickLabelAtlas.js +99 -0
- package/src/axes/ZoomController.js +446 -124
- package/src/colorscales/ColorscaleRegistry.js +30 -10
- package/src/compute/ComputationRegistry.js +126 -184
- package/src/compute/axisFilter.js +21 -9
- package/src/compute/conv.js +64 -8
- package/src/compute/elementwise.js +72 -0
- package/src/compute/fft.js +106 -20
- package/src/compute/filter.js +105 -103
- package/src/compute/hist.js +247 -142
- package/src/compute/kde.js +64 -46
- 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 +22 -8
- package/src/core/LayerType.js +251 -92
- package/src/core/Plot.js +630 -152
- 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/floats/Float.js +56 -0
- package/src/index.js +16 -4
- package/src/layers/BarsLayer.js +168 -0
- package/src/layers/ColorbarLayer.js +10 -14
- package/src/layers/ColorbarLayer2d.js +13 -24
- package/src/layers/FilterbarLayer.js +4 -3
- package/src/layers/LinesLayer.js +108 -122
- package/src/layers/PointsLayer.js +73 -69
- package/src/layers/ScatterShared.js +62 -106
- package/src/layers/TileLayer.js +20 -16
- package/src/math/mat4.js +100 -0
- package/src/core/Data.js +0 -67
- package/src/layers/HistogramLayer.js +0 -212
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
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { AXES } from "./AxisRegistry.js"
|
|
1
|
+
import { AXIS_GEOMETRY, axisEndpoints, axisPosAtN } from "./AxisRegistry.js"
|
|
3
2
|
import { getAxisQuantityKind } from "./AxisQuantityKindRegistry.js"
|
|
3
|
+
import { projectToScreen } from "../math/mat4.js"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
xaxis_bottom: axisBottom,
|
|
7
|
-
xaxis_top: axisTop,
|
|
8
|
-
yaxis_left: axisLeft,
|
|
9
|
-
yaxis_right: axisRight,
|
|
10
|
-
}
|
|
5
|
+
// ─── Tick formatting (same logic as before) ───────────────────────────────────
|
|
11
6
|
|
|
12
7
|
function formatTick(v) {
|
|
13
8
|
if (v === 0) return "0"
|
|
@@ -16,26 +11,15 @@ function formatTick(v) {
|
|
|
16
11
|
return v.toExponential(2).replace(/\.?0+(e)/, '$1')
|
|
17
12
|
}
|
|
18
13
|
const s = v.toPrecision(4)
|
|
19
|
-
if (s.includes('.') && !s.includes('e'))
|
|
20
|
-
return s.replace(/\.?0+$/, '')
|
|
21
|
-
}
|
|
14
|
+
if (s.includes('.') && !s.includes('e')) return s.replace(/\.?0+$/, '')
|
|
22
15
|
return s
|
|
23
16
|
}
|
|
24
17
|
|
|
25
|
-
|
|
26
|
-
// which gives evenly-spaced marks in log space across any domain size.
|
|
27
|
-
// Falls back to powers-of-10 (subsampled if needed) when the domain is too wide for
|
|
28
|
-
// 1-2-5 ticks to fit within pixelCount. Returns null for very narrow domains where
|
|
29
|
-
// no "nice" values land inside, so the caller can fall back to D3's default logic.
|
|
30
|
-
function logTickValues(scale, pixelCount) {
|
|
18
|
+
function logTickValues(scale, count) {
|
|
31
19
|
const [dMin, dMax] = scale.domain()
|
|
32
20
|
if (dMin <= 0 || dMax <= 0) return null
|
|
33
|
-
|
|
34
|
-
const logMin = Math.
|
|
35
|
-
const logMax = Math.log10(dMax)
|
|
36
|
-
const startExp = Math.floor(logMin)
|
|
37
|
-
const endExp = Math.ceil(logMax)
|
|
38
|
-
|
|
21
|
+
const logMin = Math.log10(dMin), logMax = Math.log10(dMax)
|
|
22
|
+
const startExp = Math.floor(logMin), endExp = Math.ceil(logMax)
|
|
39
23
|
const candidate = []
|
|
40
24
|
for (let e = startExp; e < endExp; e++) {
|
|
41
25
|
const base = Math.pow(10, e)
|
|
@@ -46,208 +30,372 @@ function logTickValues(scale, pixelCount) {
|
|
|
46
30
|
}
|
|
47
31
|
const upperPow = Math.pow(10, endExp)
|
|
48
32
|
if (upperPow >= dMin * (1 - 1e-10) && upperPow <= dMax * (1 + 1e-10)) candidate.push(upperPow)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const firstExp = Math.ceil(logMin)
|
|
55
|
-
const lastExp = Math.floor(logMax)
|
|
56
|
-
if (firstExp > lastExp) {
|
|
57
|
-
return candidate.length >= 2 ? candidate : null
|
|
58
|
-
}
|
|
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
|
|
59
36
|
const numPowers = lastExp - firstExp + 1
|
|
60
|
-
const step = numPowers >
|
|
37
|
+
const step = numPowers > count ? Math.ceil(numPowers / count) : 1
|
|
61
38
|
const powers = []
|
|
62
|
-
for (let e = firstExp; e <= lastExp; e += step)
|
|
63
|
-
powers.push(Math.pow(10, e))
|
|
64
|
-
}
|
|
39
|
+
for (let e = firstExp; e <= lastExp; e += step) powers.push(Math.pow(10, e))
|
|
65
40
|
return powers.length >= 2 ? powers : null
|
|
66
41
|
}
|
|
67
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
|
+
|
|
68
64
|
/**
|
|
69
|
-
* An Axis represents a single data axis on a plot.
|
|
70
|
-
*
|
|
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()
|
|
71
70
|
*
|
|
72
|
-
*
|
|
73
|
-
* - axis.quantityKind — string | null
|
|
74
|
-
* - axis.isSpatial — boolean; true for xaxis_bottom/xaxis_top/yaxis_left/yaxis_right
|
|
75
|
-
* - axis.getDomain() — [min, max] | null
|
|
76
|
-
* - axis.setDomain(domain) — update domain, schedule render, notify subscribers
|
|
77
|
-
* - axis.subscribe(callback) — callback([min, max]) called on domain changes
|
|
78
|
-
* - axis.unsubscribe(callback) — remove a previously added callback
|
|
71
|
+
* Rendering is now entirely WebGL-based. Call axis.render() from Plot.render().
|
|
79
72
|
*/
|
|
80
73
|
export class Axis {
|
|
81
74
|
constructor(plot, name) {
|
|
82
|
-
this._plot
|
|
83
|
-
this._name
|
|
84
|
-
this._listeners
|
|
75
|
+
this._plot = plot
|
|
76
|
+
this._name = name
|
|
77
|
+
this._listeners = new Set()
|
|
78
|
+
this._linkedAxes = new Set()
|
|
85
79
|
this._propagating = false
|
|
86
80
|
}
|
|
87
81
|
|
|
88
|
-
/** The quantity kind for this axis, or null if the plot hasn't been initialized yet. */
|
|
89
82
|
get quantityKind() { return this._plot.getAxisQuantityKind(this._name) }
|
|
90
83
|
|
|
91
|
-
|
|
92
|
-
get isSpatial() { return
|
|
84
|
+
// True for all 12 spatial axes; false for colour/filter axes.
|
|
85
|
+
get isSpatial() { return Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, this._name) }
|
|
93
86
|
|
|
94
|
-
/** Returns [min, max], or null if the axis has no domain yet. */
|
|
95
87
|
getDomain() { return this._plot.getAxisDomain(this._name) }
|
|
96
88
|
|
|
97
|
-
|
|
98
|
-
* Sets the axis domain, schedules a render on the owning plot, and notifies all
|
|
99
|
-
* subscribers (e.g. linked axes). A _propagating guard prevents infinite loops
|
|
100
|
-
* when axes are linked bidirectionally.
|
|
101
|
-
*/
|
|
102
|
-
setDomain(domain) {
|
|
89
|
+
setDomain(domain, { sourcePlot = null } = {}) {
|
|
103
90
|
if (this._propagating) return
|
|
104
91
|
this._propagating = true
|
|
105
92
|
try {
|
|
106
93
|
this._plot.setAxisDomain(this._name, domain)
|
|
107
|
-
this._plot.scheduleRender()
|
|
94
|
+
this._plot.scheduleRender(sourcePlot)
|
|
108
95
|
for (const cb of this._listeners) cb(domain)
|
|
109
96
|
} finally {
|
|
110
97
|
this._propagating = false
|
|
111
98
|
}
|
|
112
99
|
}
|
|
113
100
|
|
|
114
|
-
|
|
115
|
-
subscribe(callback) { this._listeners.add(callback) }
|
|
116
|
-
|
|
117
|
-
/** Remove a previously added subscriber. */
|
|
101
|
+
subscribe(callback) { this._listeners.add(callback) }
|
|
118
102
|
unsubscribe(callback) { this._listeners.delete(callback) }
|
|
119
103
|
|
|
120
|
-
// ───
|
|
104
|
+
// ─── WebGL rendering ───────────────────────────────────────────────────────
|
|
121
105
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
126
|
}
|
|
127
|
-
const pixelsPerTick = rotate ? 28 : 40
|
|
128
|
-
return Math.max(2, Math.floor(plotWidth / pixelsPerTick))
|
|
129
127
|
}
|
|
130
128
|
|
|
131
|
-
|
|
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) {
|
|
132
141
|
const isLog = typeof scale.base === 'function'
|
|
133
|
-
const count = this._tickCount(rotate)
|
|
134
|
-
const gen = AXIS_CONSTRUCTORS[this._name](scale).tickFormat(formatTick)
|
|
135
142
|
if (isLog) {
|
|
136
143
|
const tv = logTickValues(scale, count)
|
|
137
|
-
if (tv !== null)
|
|
138
|
-
gen.tickValues(tv)
|
|
139
|
-
} else {
|
|
140
|
-
gen.ticks(count)
|
|
141
|
-
}
|
|
142
|
-
} else if (count <= 2) {
|
|
143
|
-
gen.tickValues(scale.domain())
|
|
144
|
-
} else {
|
|
145
|
-
gen.ticks(count)
|
|
144
|
+
if (tv !== null) return tv
|
|
146
145
|
}
|
|
147
|
-
return
|
|
146
|
+
if (count <= 2) return scale.domain()
|
|
147
|
+
return scale.ticks(count)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
.attr("class", "axis-label")
|
|
164
|
-
.attr("fill", "#000")
|
|
165
|
-
.style("text-anchor", "middle")
|
|
166
|
-
.style("font-size", "14px")
|
|
167
|
-
.style("font-weight", "bold")
|
|
168
|
-
|
|
169
|
-
const lines = unitLabel.split('\n')
|
|
170
|
-
if (lines.length > 1) {
|
|
171
|
-
lines.forEach((line, i) => {
|
|
172
|
-
text.append("tspan")
|
|
173
|
-
.attr("x", 0)
|
|
174
|
-
.attr("dy", i === 0 ? "0em" : "1.2em")
|
|
175
|
-
.text(line)
|
|
176
|
-
})
|
|
177
|
-
} else {
|
|
178
|
-
text.text(unitLabel)
|
|
179
|
-
}
|
|
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
|
+
}
|
|
180
163
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
yOffset = (tickSpace + (availableMargin - tickSpace) / 2) - (bbox.y + bbox.height / 2)
|
|
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)
|
|
196
178
|
}
|
|
197
|
-
|
|
198
|
-
text.attr("y", yOffset)
|
|
179
|
+
return accepted
|
|
199
180
|
}
|
|
200
181
|
|
|
201
182
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
|
204
193
|
*/
|
|
205
|
-
render() {
|
|
194
|
+
render(regl, axisMvp, cw, ch, is3D, atlas, lineCmd, billboardCmd) {
|
|
206
195
|
if (!this.isSpatial) return
|
|
207
|
-
const {
|
|
196
|
+
const { axisRegistry, currentConfig } = this._plot
|
|
197
|
+
if (currentConfig?.axes?.[this._name]?.visible === false) return
|
|
208
198
|
const scale = axisRegistry.getScale(this._name)
|
|
209
199
|
if (!scale) return
|
|
210
200
|
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}
|
|
227
316
|
}
|
|
228
317
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
.attr("dy", "0.15em")
|
|
242
|
-
.attr("transform", "rotate(-45)")
|
|
243
|
-
} else if (rotate && this._name === "xaxis_top") {
|
|
244
|
-
g.selectAll(".tick text")
|
|
245
|
-
.style("text-anchor", "start")
|
|
246
|
-
.attr("dx", "0.8em")
|
|
247
|
-
.attr("dy", "-0.35em")
|
|
248
|
-
.attr("transform", "rotate(45)")
|
|
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
|
+
})
|
|
249
330
|
}
|
|
250
331
|
|
|
251
|
-
|
|
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
|
+
}
|
|
252
400
|
}
|
|
253
401
|
}
|
package/src/axes/AxisLink.js
CHANGED
|
@@ -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
|
}
|