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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { getAxisQuantityKind, getScaleTypeFloat } from './AxisQuantityKindRegistry.js'
|
|
2
|
+
import { getColorscaleIndex } from '../colorscales/ColorscaleRegistry.js'
|
|
3
|
+
|
|
4
|
+
export class ColorAxisRegistry {
|
|
5
|
+
constructor() {
|
|
6
|
+
this._axes = new Map()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
ensureColorAxis(quantityKind, colorscaleOverride = null) {
|
|
10
|
+
if (!this._axes.has(quantityKind)) {
|
|
11
|
+
this._axes.set(quantityKind, { colorscaleOverride, range: null, alphaBlend: 0.0 })
|
|
12
|
+
} else if (colorscaleOverride !== null) {
|
|
13
|
+
this._axes.get(quantityKind).colorscaleOverride = colorscaleOverride
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getAlphaBlend(quantityKind) {
|
|
18
|
+
return this._axes.get(quantityKind)?.alphaBlend ?? 0.0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
setRange(quantityKind, min, max) {
|
|
22
|
+
if (!this._axes.has(quantityKind)) {
|
|
23
|
+
throw new Error(`Color axis '${quantityKind}' not found in registry`)
|
|
24
|
+
}
|
|
25
|
+
this._axes.get(quantityKind).range = [min, max]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getRange(quantityKind) {
|
|
29
|
+
return this._axes.get(quantityKind)?.range ?? null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getColorscale(quantityKind) {
|
|
33
|
+
const entry = this._axes.get(quantityKind)
|
|
34
|
+
if (!entry) return null
|
|
35
|
+
if (entry.colorscaleOverride) return entry.colorscaleOverride
|
|
36
|
+
const unitDef = getAxisQuantityKind(quantityKind)
|
|
37
|
+
return unitDef.colorscale ?? null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getColorscaleIndex(quantityKind) {
|
|
41
|
+
const colorscale = this.getColorscale(quantityKind)
|
|
42
|
+
if (colorscale === null) return 0
|
|
43
|
+
return getColorscaleIndex(colorscale)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
hasAxis(quantityKind) {
|
|
47
|
+
return this._axes.has(quantityKind)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getQuantityKinds() {
|
|
51
|
+
return Array.from(this._axes.keys())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
applyAutoDomainsFromLayers(layers, axesOverrides) {
|
|
55
|
+
for (const quantityKind of this.getQuantityKinds()) {
|
|
56
|
+
const override = axesOverrides[quantityKind]
|
|
57
|
+
if (override?.colorscale && override?.colorscale != "none")
|
|
58
|
+
this.ensureColorAxis(quantityKind, override.colorscale)
|
|
59
|
+
if (override?.alpha_blend !== undefined)
|
|
60
|
+
this._axes.get(quantityKind).alphaBlend = override.alpha_blend
|
|
61
|
+
|
|
62
|
+
let min = Infinity, max = -Infinity
|
|
63
|
+
|
|
64
|
+
for (const layer of layers) {
|
|
65
|
+
for (const qk of Object.values(layer.colorAxes)) {
|
|
66
|
+
if (qk !== quantityKind) continue
|
|
67
|
+
if (layer.domains[qk] !== undefined) {
|
|
68
|
+
const [dMin, dMax] = layer.domains[qk]
|
|
69
|
+
if (dMin < min) min = dMin
|
|
70
|
+
if (dMax > max) max = dMax
|
|
71
|
+
} else {
|
|
72
|
+
const data = layer.attributes[qk]
|
|
73
|
+
if (!data) continue
|
|
74
|
+
for (let i = 0; i < data.length; i++) {
|
|
75
|
+
if (data[i] < min) min = data[i]
|
|
76
|
+
if (data[i] > max) max = data[i]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (min !== Infinity) {
|
|
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)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const quantityKind of this.getQuantityKinds()) {
|
|
90
|
+
if (getScaleTypeFloat(quantityKind, axesOverrides) <= 0.5) continue
|
|
91
|
+
const range = this.getRange(quantityKind)
|
|
92
|
+
if (!range) continue
|
|
93
|
+
if (range[0] <= 0 || range[1] <= 0) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Color axis '${quantityKind}' uses log scale but has non-positive range [${range[0]}, ${range[1]}]. ` +
|
|
96
|
+
`All data values and min/max must be > 0 for log scale.`
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getScaleTypeFloat } from './AxisQuantityKindRegistry.js'
|
|
2
|
+
|
|
1
3
|
export class FilterAxisRegistry {
|
|
2
4
|
constructor() {
|
|
3
5
|
// quantityKind -> { min: number|null, max: number|null, dataExtent: [number,number]|null }
|
|
@@ -57,6 +59,67 @@ export class FilterAxisRegistry {
|
|
|
57
59
|
getQuantityKinds() {
|
|
58
60
|
return Array.from(this._axes.keys())
|
|
59
61
|
}
|
|
62
|
+
|
|
63
|
+
applyAutoDomainsFromLayers(layers, axesOverrides) {
|
|
64
|
+
for (const quantityKind of this.getQuantityKinds()) {
|
|
65
|
+
let extMin = Infinity, extMax = -Infinity
|
|
66
|
+
|
|
67
|
+
for (const layer of layers) {
|
|
68
|
+
for (const qk of Object.values(layer.filterAxes)) {
|
|
69
|
+
if (qk !== quantityKind) continue
|
|
70
|
+
if (layer.domains[qk] !== undefined) {
|
|
71
|
+
const [dMin, dMax] = layer.domains[qk]
|
|
72
|
+
if (dMin < extMin) extMin = dMin
|
|
73
|
+
if (dMax > extMax) extMax = dMax
|
|
74
|
+
} else {
|
|
75
|
+
const data = layer.attributes[qk]
|
|
76
|
+
if (!data) continue
|
|
77
|
+
for (let i = 0; i < data.length; i++) {
|
|
78
|
+
if (data[i] < extMin) extMin = data[i]
|
|
79
|
+
if (data[i] > extMax) extMax = data[i]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (extMin !== Infinity) this.setDataExtent(quantityKind, extMin, extMax)
|
|
86
|
+
|
|
87
|
+
if (axesOverrides[quantityKind]) {
|
|
88
|
+
const override = axesOverrides[quantityKind]
|
|
89
|
+
this.setRange(
|
|
90
|
+
quantityKind,
|
|
91
|
+
override.min !== undefined ? override.min : null,
|
|
92
|
+
override.max !== undefined ? override.max : null
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const quantityKind of this.getQuantityKinds()) {
|
|
98
|
+
if (getScaleTypeFloat(quantityKind, axesOverrides) <= 0.5) continue
|
|
99
|
+
const extent = this.getDataExtent(quantityKind)
|
|
100
|
+
if (extent && extent[0] <= 0) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Filter axis '${quantityKind}' uses log scale but data minimum is ${extent[0]}. ` +
|
|
103
|
+
`All data values must be > 0 for log scale.`
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
const filterRange = this.getRange(quantityKind)
|
|
107
|
+
if (filterRange) {
|
|
108
|
+
if (filterRange.min !== null && filterRange.min <= 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Filter axis '${quantityKind}' uses log scale but min is ${filterRange.min}. ` +
|
|
111
|
+
`min must be > 0 for log scale.`
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
if (filterRange.max !== null && filterRange.max <= 0) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Filter axis '${quantityKind}' uses log scale but max is ${filterRange.max}. ` +
|
|
117
|
+
`max must be > 0 for log scale.`
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
60
123
|
}
|
|
61
124
|
|
|
62
125
|
// Injects a GLSL helper used by layer shaders to apply filter axis bounds.
|
|
@@ -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
|
+
}
|