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