gladly-plot 0.0.1
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/LICENSE +21 -0
- package/README.md +34 -0
- package/package.json +28 -0
- package/src/Axis.js +48 -0
- package/src/AxisLink.js +31 -0
- package/src/AxisQuantityKindRegistry.js +23 -0
- package/src/AxisRegistry.js +54 -0
- package/src/ColorAxisRegistry.js +49 -0
- package/src/Colorbar.js +64 -0
- package/src/ColorbarLayer.js +74 -0
- package/src/ColorscaleRegistry.js +49 -0
- package/src/FilterAxisRegistry.js +76 -0
- package/src/Filterbar.js +138 -0
- package/src/FilterbarFloat.js +157 -0
- package/src/FilterbarLayer.js +49 -0
- package/src/Float.js +159 -0
- package/src/Layer.js +43 -0
- package/src/LayerType.js +169 -0
- package/src/LayerTypeRegistry.js +19 -0
- package/src/MatplotlibColorscales.js +564 -0
- package/src/Plot.js +976 -0
- package/src/ScatterLayer.js +107 -0
- package/src/index.js +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Egil MΓΆller
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Gladly
|
|
2
|
+
|
|
3
|
+
A lightweight, GPU-accelerated plotting library with a declarative API.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Gladly combines WebGL rendering (via regl) with D3.js for interactive axes and zoom controls. It features a **declarative API** that lets you create high-performance plots with minimal boilerplate.
|
|
8
|
+
|
|
9
|
+
**Key Features:**
|
|
10
|
+
- π GPU-accelerated rendering using WebGL
|
|
11
|
+
- π¨ Zero JavaScript loops over data - all processing in GPU shaders
|
|
12
|
+
- π Declarative plot configuration
|
|
13
|
+
- π― Interactive multi-axis support (up to 4 axes)
|
|
14
|
+
- π Zoom and pan interactions
|
|
15
|
+
- π§© Extensible layer type registry
|
|
16
|
+
- π Unit-aware axis management
|
|
17
|
+
- β‘ ~250 lines of focused source code
|
|
18
|
+
|
|
19
|
+
## Documentation
|
|
20
|
+
|
|
21
|
+
- **[Quick Start](docs/Quickstart.md)** - Installation and minimal working example
|
|
22
|
+
- **[API Documentation](docs/API.md)** - Complete API reference and usage guide
|
|
23
|
+
- **[Architecture Documentation](docs/ARCHITECTURE.md)** - Developer guide and design patterns
|
|
24
|
+
|
|
25
|
+
## Technology Stack
|
|
26
|
+
|
|
27
|
+
- **WebGL Rendering**: regl v2.1.0
|
|
28
|
+
- **Axes & Interaction**: D3.js v7.8.5
|
|
29
|
+
- **Module Format**: ES6 modules
|
|
30
|
+
- **Build Tool**: Parcel v2.9.0
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gladly-plot",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "GPU-powered multi-axis plotting library with regl + d3",
|
|
5
|
+
"type": "module",
|
|
6
|
+
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
"files": ["src"],
|
|
12
|
+
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "parcel serve example/index.html --open",
|
|
15
|
+
"build:example": "parcel build example/index.html --dist-dir dist-example --public-url ./",
|
|
16
|
+
"preview": "npm run build:example && npx serve dist-example"
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"d3": "^7.8.5",
|
|
21
|
+
"regl": "^2.1.0"
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"parcel": "^2.9.0",
|
|
26
|
+
"@json-editor/json-editor": "^2.15.1"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/Axis.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Axis represents a single data axis on a plot. Axis instances are stable across
|
|
3
|
+
* plot.update() calls and can be linked together with linkAxes().
|
|
4
|
+
*
|
|
5
|
+
* Public interface (duck-typing compatible):
|
|
6
|
+
* - axis.quantityKind β string | null
|
|
7
|
+
* - axis.getDomain() β [min, max] | null
|
|
8
|
+
* - axis.setDomain(domain) β update domain, schedule render, notify subscribers
|
|
9
|
+
* - axis.subscribe(callback) β callback([min, max]) called on domain changes
|
|
10
|
+
* - axis.unsubscribe(callback) β remove a previously added callback
|
|
11
|
+
*/
|
|
12
|
+
export class Axis {
|
|
13
|
+
constructor(plot, name) {
|
|
14
|
+
this._plot = plot
|
|
15
|
+
this._name = name
|
|
16
|
+
this._listeners = new Set()
|
|
17
|
+
this._propagating = false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The quantity kind for this axis, or null if the plot hasn't been initialized yet. */
|
|
21
|
+
get quantityKind() { return this._plot.getAxisQuantityKind(this._name) }
|
|
22
|
+
|
|
23
|
+
/** Returns [min, max], or null if the axis has no domain yet. */
|
|
24
|
+
getDomain() { return this._plot.getAxisDomain(this._name) }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sets the axis domain, schedules a render on the owning plot, and notifies all
|
|
28
|
+
* subscribers (e.g. linked axes). A _propagating guard prevents infinite loops
|
|
29
|
+
* when axes are linked bidirectionally.
|
|
30
|
+
*/
|
|
31
|
+
setDomain(domain) {
|
|
32
|
+
if (this._propagating) return
|
|
33
|
+
this._propagating = true
|
|
34
|
+
try {
|
|
35
|
+
this._plot.setAxisDomain(this._name, domain)
|
|
36
|
+
this._plot.scheduleRender()
|
|
37
|
+
for (const cb of this._listeners) cb(domain)
|
|
38
|
+
} finally {
|
|
39
|
+
this._propagating = false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Add a subscriber. callback([min, max]) is called after every setDomain(). */
|
|
44
|
+
subscribe(callback) { this._listeners.add(callback) }
|
|
45
|
+
|
|
46
|
+
/** Remove a previously added subscriber. */
|
|
47
|
+
unsubscribe(callback) { this._listeners.delete(callback) }
|
|
48
|
+
}
|
package/src/AxisLink.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links two Axis (or duck-typed axis) objects bidirectionally.
|
|
3
|
+
*
|
|
4
|
+
* When either axis's domain changes via setDomain(), the other is updated to match.
|
|
5
|
+
* Quantity kinds are validated at link time if both are known.
|
|
6
|
+
*
|
|
7
|
+
* Returns an object with an unlink() method to tear down the link.
|
|
8
|
+
*
|
|
9
|
+
* Any object that implements the Axis interface may be used:
|
|
10
|
+
* { quantityKind, getDomain(), setDomain(domain), subscribe(cb), unsubscribe(cb) }
|
|
11
|
+
*/
|
|
12
|
+
export function linkAxes(axis1, axis2) {
|
|
13
|
+
const qk1 = axis1.quantityKind
|
|
14
|
+
const qk2 = axis2.quantityKind
|
|
15
|
+
if (qk1 && qk2 && qk1 !== qk2) {
|
|
16
|
+
throw new Error(`Cannot link axes with incompatible quantity kinds: ${qk1} vs ${qk2}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const cb1 = (domain) => axis2.setDomain(domain)
|
|
20
|
+
const cb2 = (domain) => axis1.setDomain(domain)
|
|
21
|
+
|
|
22
|
+
axis1.subscribe(cb1)
|
|
23
|
+
axis2.subscribe(cb2)
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
unlink() {
|
|
27
|
+
axis1.unsubscribe(cb1)
|
|
28
|
+
axis2.unsubscribe(cb2)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const registry = new Map()
|
|
2
|
+
|
|
3
|
+
export function registerAxisQuantityKind(name, definition) {
|
|
4
|
+
if (registry.has(name)) {
|
|
5
|
+
// Merge new properties into existing definition
|
|
6
|
+
const existing = registry.get(name)
|
|
7
|
+
registry.set(name, { ...existing, ...definition })
|
|
8
|
+
} else {
|
|
9
|
+
registry.set(name, definition)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getAxisQuantityKind(name) {
|
|
14
|
+
if (!registry.has(name)) {
|
|
15
|
+
// Return a temporary default definition without registering it
|
|
16
|
+
return { label: name, scale: "linear" }
|
|
17
|
+
}
|
|
18
|
+
return registry.get(name)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getRegisteredAxisQuantityKinds() {
|
|
22
|
+
return Array.from(registry.keys())
|
|
23
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as d3 from "d3-scale"
|
|
2
|
+
import { getAxisQuantityKind } from "./AxisQuantityKindRegistry.js"
|
|
3
|
+
|
|
4
|
+
export const AXES = ["xaxis_bottom","xaxis_top","yaxis_left","yaxis_right"]
|
|
5
|
+
|
|
6
|
+
export class AxisRegistry {
|
|
7
|
+
constructor(width, height) {
|
|
8
|
+
this.scales = {}
|
|
9
|
+
this.axisQuantityKinds = {}
|
|
10
|
+
this.width = width
|
|
11
|
+
this.height = height
|
|
12
|
+
AXES.forEach(a => {
|
|
13
|
+
this.scales[a] = null
|
|
14
|
+
this.axisQuantityKinds[a] = null
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
ensureAxis(axisName, axisQuantityKind, scaleOverride) {
|
|
19
|
+
if (!AXES.includes(axisName)) throw `Unknown axis ${axisName}`
|
|
20
|
+
if (this.axisQuantityKinds[axisName] && this.axisQuantityKinds[axisName] !== axisQuantityKind)
|
|
21
|
+
throw `Axis quantity kind mismatch on axis ${axisName}: ${this.axisQuantityKinds[axisName]} vs ${axisQuantityKind}`
|
|
22
|
+
|
|
23
|
+
if (!this.scales[axisName]) {
|
|
24
|
+
const quantityKindDef = getAxisQuantityKind(axisQuantityKind)
|
|
25
|
+
const scaleType = scaleOverride ?? quantityKindDef.scale
|
|
26
|
+
this.scales[axisName] = scaleType === "log"
|
|
27
|
+
? d3.scaleLog().range(axisName.includes("y") ? [this.height,0] : [0,this.width])
|
|
28
|
+
: d3.scaleLinear().range(axisName.includes("y") ? [this.height,0] : [0,this.width])
|
|
29
|
+
this.axisQuantityKinds[axisName] = axisQuantityKind
|
|
30
|
+
}
|
|
31
|
+
return this.scales[axisName]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getScale(axisName) { return this.scales[axisName] }
|
|
35
|
+
|
|
36
|
+
isLogScale(axisName) {
|
|
37
|
+
const scale = this.scales[axisName]
|
|
38
|
+
return !!scale && typeof scale.base === 'function'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setScaleType(axisName, scaleType) {
|
|
42
|
+
const scale = this.scales[axisName]
|
|
43
|
+
if (!scale) return
|
|
44
|
+
const currentIsLog = typeof scale.base === 'function'
|
|
45
|
+
const wantLog = scaleType === "log"
|
|
46
|
+
if (currentIsLog === wantLog) return
|
|
47
|
+
const currentDomain = scale.domain()
|
|
48
|
+
const newScale = wantLog
|
|
49
|
+
? d3.scaleLog().range(axisName.includes("y") ? [this.height, 0] : [0, this.width])
|
|
50
|
+
: d3.scaleLinear().range(axisName.includes("y") ? [this.height, 0] : [0, this.width])
|
|
51
|
+
newScale.domain(currentDomain)
|
|
52
|
+
this.scales[axisName] = newScale
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getAxisQuantityKind } from './AxisQuantityKindRegistry.js'
|
|
2
|
+
import { getColorscaleIndex } from './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 })
|
|
12
|
+
} else if (colorscaleOverride !== null) {
|
|
13
|
+
this._axes.get(quantityKind).colorscaleOverride = colorscaleOverride
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
setRange(quantityKind, min, max) {
|
|
18
|
+
if (!this._axes.has(quantityKind)) {
|
|
19
|
+
throw new Error(`Color axis '${quantityKind}' not found in registry`)
|
|
20
|
+
}
|
|
21
|
+
this._axes.get(quantityKind).range = [min, max]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getRange(quantityKind) {
|
|
25
|
+
return this._axes.get(quantityKind)?.range ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getColorscale(quantityKind) {
|
|
29
|
+
const entry = this._axes.get(quantityKind)
|
|
30
|
+
if (!entry) return null
|
|
31
|
+
if (entry.colorscaleOverride) return entry.colorscaleOverride
|
|
32
|
+
const unitDef = getAxisQuantityKind(quantityKind)
|
|
33
|
+
return unitDef.colorscale ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getColorscaleIndex(quantityKind) {
|
|
37
|
+
const colorscale = this.getColorscale(quantityKind)
|
|
38
|
+
if (colorscale === null) return 0
|
|
39
|
+
return getColorscaleIndex(colorscale)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
hasAxis(quantityKind) {
|
|
43
|
+
return this._axes.has(quantityKind)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getQuantityKinds() {
|
|
47
|
+
return Array.from(this._axes.keys())
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/Colorbar.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Plot } from "./Plot.js"
|
|
2
|
+
import { linkAxes } from "./AxisLink.js"
|
|
3
|
+
import "./ColorbarLayer.js"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MARGINS = {
|
|
6
|
+
horizontal: { top: 5, right: 40, bottom: 45, left: 40 },
|
|
7
|
+
vertical: { top: 40, right: 10, bottom: 40, left: 50 }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class Colorbar extends Plot {
|
|
11
|
+
constructor(container, targetPlot, colorAxisName, { orientation = "horizontal", margin } = {}) {
|
|
12
|
+
super(container, { margin: margin ?? DEFAULT_MARGINS[orientation] })
|
|
13
|
+
|
|
14
|
+
this._targetPlot = targetPlot
|
|
15
|
+
this._colorAxisName = colorAxisName
|
|
16
|
+
this._orientation = orientation
|
|
17
|
+
this._spatialAxis = orientation === "horizontal" ? "xaxis_bottom" : "yaxis_left"
|
|
18
|
+
|
|
19
|
+
this.update({
|
|
20
|
+
data: {},
|
|
21
|
+
config: {
|
|
22
|
+
layers: [{ colorbar: { colorAxis: colorAxisName, orientation } }]
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Link the colorbar's spatial axis to the target's color axis.
|
|
27
|
+
// Zooming the colorbar propagates domain changes to the target's color range.
|
|
28
|
+
this._spatialLink = linkAxes(this.axes[this._spatialAxis], targetPlot.axes[colorAxisName])
|
|
29
|
+
|
|
30
|
+
// Re-render (with sync) whenever the target plot renders.
|
|
31
|
+
this._syncCallback = () => this.render()
|
|
32
|
+
targetPlot._renderCallbacks.add(this._syncCallback)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_getScaleTypeFloat(quantityKind) {
|
|
36
|
+
if (quantityKind === this._colorAxisName && this._targetPlot) {
|
|
37
|
+
return this._targetPlot._getScaleTypeFloat(quantityKind)
|
|
38
|
+
}
|
|
39
|
+
return super._getScaleTypeFloat(quantityKind)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
render() {
|
|
43
|
+
// Always pull the current range, colorscale, and scale type from the target plot so the
|
|
44
|
+
// colorbar stays in sync even after config changes or resizes.
|
|
45
|
+
if (this.colorAxisRegistry && this.axisRegistry && this._targetPlot) {
|
|
46
|
+
const range = this._targetPlot.getAxisDomain(this._colorAxisName)
|
|
47
|
+
if (range) {
|
|
48
|
+
this.setAxisDomain(this._spatialAxis, range)
|
|
49
|
+
this.setAxisDomain(this._colorAxisName, range)
|
|
50
|
+
}
|
|
51
|
+
const colorscale = this._targetPlot.colorAxisRegistry?.getColorscale(this._colorAxisName)
|
|
52
|
+
if (colorscale) this.colorAxisRegistry.ensureColorAxis(this._colorAxisName, colorscale)
|
|
53
|
+
const scaleType = this._targetPlot._getScaleTypeFloat(this._colorAxisName) > 0.5 ? "log" : "linear"
|
|
54
|
+
this.axisRegistry.setScaleType(this._spatialAxis, scaleType)
|
|
55
|
+
}
|
|
56
|
+
super.render()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
destroy() {
|
|
60
|
+
this._spatialLink.unlink()
|
|
61
|
+
this._targetPlot._renderCallbacks.delete(this._syncCallback)
|
|
62
|
+
super.destroy()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { LayerType } from "./LayerType.js"
|
|
2
|
+
import { registerLayerType } from "./LayerTypeRegistry.js"
|
|
3
|
+
|
|
4
|
+
// Four vertices for a triangle-strip quad covering the entire clip space.
|
|
5
|
+
const quadCx = new Float32Array([-1, 1, -1, 1])
|
|
6
|
+
const quadCy = new Float32Array([-1, -1, 1, 1])
|
|
7
|
+
|
|
8
|
+
export const colorbarLayerType = new LayerType({
|
|
9
|
+
name: "colorbar",
|
|
10
|
+
|
|
11
|
+
getAxisConfig: function(parameters) {
|
|
12
|
+
const { colorAxis, orientation = "horizontal" } = parameters
|
|
13
|
+
return {
|
|
14
|
+
xAxis: orientation === "horizontal" ? "xaxis_bottom" : null,
|
|
15
|
+
xAxisQuantityKind: orientation === "horizontal" ? colorAxis : undefined,
|
|
16
|
+
yAxis: orientation === "vertical" ? "yaxis_left" : null,
|
|
17
|
+
yAxisQuantityKind: orientation === "vertical" ? colorAxis : undefined,
|
|
18
|
+
colorAxisQuantityKinds: [colorAxis],
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
vert: `
|
|
23
|
+
precision mediump float;
|
|
24
|
+
attribute float cx;
|
|
25
|
+
attribute float cy;
|
|
26
|
+
uniform int horizontal;
|
|
27
|
+
varying float tval;
|
|
28
|
+
void main() {
|
|
29
|
+
gl_Position = vec4(cx, cy, 0.0, 1.0);
|
|
30
|
+
tval = horizontal == 1 ? (cx + 1.0) / 2.0 : (cy + 1.0) / 2.0;
|
|
31
|
+
}
|
|
32
|
+
`,
|
|
33
|
+
|
|
34
|
+
frag: `
|
|
35
|
+
precision mediump float;
|
|
36
|
+
uniform int colorscale;
|
|
37
|
+
uniform vec2 color_range;
|
|
38
|
+
uniform float color_scale_type;
|
|
39
|
+
varying float tval;
|
|
40
|
+
void main() {
|
|
41
|
+
float r0 = color_scale_type > 0.5 ? log(color_range.x) : color_range.x;
|
|
42
|
+
float r1 = color_scale_type > 0.5 ? log(color_range.y) : color_range.y;
|
|
43
|
+
float v = r0 + tval * (r1 - r0);
|
|
44
|
+
gl_FragColor = map_color(colorscale, vec2(r0, r1), v);
|
|
45
|
+
}
|
|
46
|
+
`,
|
|
47
|
+
|
|
48
|
+
schema: () => ({
|
|
49
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
colorAxis: { type: "string", description: "Quantity kind of the color axis to display" },
|
|
53
|
+
orientation: { type: "string", enum: ["horizontal", "vertical"], default: "horizontal" }
|
|
54
|
+
},
|
|
55
|
+
required: ["colorAxis"]
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
createLayer: function(parameters) {
|
|
59
|
+
const { colorAxis, orientation = "horizontal" } = parameters
|
|
60
|
+
return [{
|
|
61
|
+
attributes: { cx: quadCx, cy: quadCy },
|
|
62
|
+
uniforms: { horizontal: orientation === "horizontal" ? 1 : 0 },
|
|
63
|
+
primitive: "triangle strip",
|
|
64
|
+
vertexCount: 4,
|
|
65
|
+
nameMap: {
|
|
66
|
+
[`colorscale_${colorAxis}`]: 'colorscale',
|
|
67
|
+
[`color_range_${colorAxis}`]: 'color_range',
|
|
68
|
+
[`color_scale_type_${colorAxis}`]: 'color_scale_type',
|
|
69
|
+
},
|
|
70
|
+
}]
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
registerLayerType("colorbar", colorbarLayerType)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const colorscales = new Map()
|
|
2
|
+
|
|
3
|
+
export function registerColorscale(name, glslFn) {
|
|
4
|
+
colorscales.set(name, glslFn)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getRegisteredColorscales() {
|
|
8
|
+
return colorscales
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getColorscaleIndex(name) {
|
|
12
|
+
let idx = 0
|
|
13
|
+
for (const key of colorscales.keys()) {
|
|
14
|
+
if (key === name) return idx
|
|
15
|
+
idx++
|
|
16
|
+
}
|
|
17
|
+
return 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildColorGlsl() {
|
|
21
|
+
if (colorscales.size === 0) return ''
|
|
22
|
+
|
|
23
|
+
const parts = []
|
|
24
|
+
|
|
25
|
+
for (const glslFn of colorscales.values()) {
|
|
26
|
+
parts.push(glslFn)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
parts.push('vec4 map_color(int cs, vec2 range, float value) {')
|
|
30
|
+
parts.push(' float t = clamp((value - range.x) / (range.y - range.x), 0.0, 1.0);')
|
|
31
|
+
|
|
32
|
+
let idx = 0
|
|
33
|
+
for (const name of colorscales.keys()) {
|
|
34
|
+
parts.push(` if (cs == ${idx}) return colorscale_${name}(t);`)
|
|
35
|
+
idx++
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
parts.push(' return vec4(0.5, 0.5, 0.5, 1.0);')
|
|
39
|
+
parts.push('}')
|
|
40
|
+
|
|
41
|
+
parts.push('vec4 map_color_s(int cs, vec2 range, float v, float scaleType) {')
|
|
42
|
+
parts.push(' float vt = scaleType > 0.5 ? log(v) : v;')
|
|
43
|
+
parts.push(' float r0 = scaleType > 0.5 ? log(range.x) : range.x;')
|
|
44
|
+
parts.push(' float r1 = scaleType > 0.5 ? log(range.y) : range.y;')
|
|
45
|
+
parts.push(' return map_color(cs, vec2(r0, r1), vt);')
|
|
46
|
+
parts.push('}')
|
|
47
|
+
|
|
48
|
+
return parts.join('\n')
|
|
49
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export class FilterAxisRegistry {
|
|
2
|
+
constructor() {
|
|
3
|
+
// quantityKind -> { min: number|null, max: number|null, dataExtent: [number,number]|null }
|
|
4
|
+
this._axes = new Map()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
ensureFilterAxis(quantityKind) {
|
|
8
|
+
if (!this._axes.has(quantityKind)) {
|
|
9
|
+
this._axes.set(quantityKind, { min: null, max: null, dataExtent: null })
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
setRange(quantityKind, min, max) {
|
|
14
|
+
if (!this._axes.has(quantityKind)) {
|
|
15
|
+
throw new Error(`Filter axis '${quantityKind}' not found in registry`)
|
|
16
|
+
}
|
|
17
|
+
this._axes.get(quantityKind).min = min
|
|
18
|
+
this._axes.get(quantityKind).max = max
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getRange(quantityKind) {
|
|
22
|
+
const entry = this._axes.get(quantityKind)
|
|
23
|
+
if (!entry) return null
|
|
24
|
+
return { min: entry.min, max: entry.max }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns [min, max, hasMin, hasMax] for use as a vec4 uniform.
|
|
28
|
+
// Open bounds (null) are encoded with the corresponding flag set to 0.0.
|
|
29
|
+
getRangeUniform(quantityKind) {
|
|
30
|
+
const range = this.getRange(quantityKind)
|
|
31
|
+
if (!range) return [0.0, 0.0, 0.0, 0.0]
|
|
32
|
+
return [
|
|
33
|
+
range.min ?? 0.0,
|
|
34
|
+
range.max ?? 0.0,
|
|
35
|
+
range.min !== null ? 1.0 : 0.0,
|
|
36
|
+
range.max !== null ? 1.0 : 0.0
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Store the min/max extent of the actual per-point data (used by Filterbar for display).
|
|
41
|
+
setDataExtent(quantityKind, min, max) {
|
|
42
|
+
if (!this._axes.has(quantityKind)) {
|
|
43
|
+
throw new Error(`Filter axis '${quantityKind}' not found in registry`)
|
|
44
|
+
}
|
|
45
|
+
this._axes.get(quantityKind).dataExtent = [min, max]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Returns [min, max] extent of the raw data, or null if not yet computed.
|
|
49
|
+
getDataExtent(quantityKind) {
|
|
50
|
+
return this._axes.get(quantityKind)?.dataExtent ?? null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
hasAxis(quantityKind) {
|
|
54
|
+
return this._axes.has(quantityKind)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getQuantityKinds() {
|
|
58
|
+
return Array.from(this._axes.keys())
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Injects a GLSL helper used by layer shaders to apply filter axis bounds.
|
|
63
|
+
//
|
|
64
|
+
// filter_in_range(vec4 range, float value):
|
|
65
|
+
// range.xy = [min, max] (only used when the corresponding flag is set)
|
|
66
|
+
// range.zw = [hasMin, hasMax] β 1.0 if bound is active, 0.0 if open
|
|
67
|
+
// Returns false (discard) if value falls outside any active bound.
|
|
68
|
+
export function buildFilterGlsl() {
|
|
69
|
+
return `
|
|
70
|
+
bool filter_in_range(vec4 range, float value) {
|
|
71
|
+
if (range.z > 0.5 && value < range.x) return false;
|
|
72
|
+
if (range.w > 0.5 && value > range.y) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
`
|
|
76
|
+
}
|