gladly-plot 0.0.4 → 0.0.5
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/package.json +2 -2
- package/src/axes/Axis.js +253 -0
- package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
- package/src/{AxisRegistry.js → axes/AxisRegistry.js} +48 -0
- package/src/axes/ColorAxisRegistry.js +93 -0
- package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
- package/src/axes/ZoomController.js +141 -0
- package/src/colorscales/BivariateColorscales.js +205 -0
- package/src/colorscales/ColorscaleRegistry.js +124 -0
- package/src/compute/ComputationRegistry.js +237 -0
- package/src/compute/axisFilter.js +47 -0
- package/src/compute/conv.js +230 -0
- package/src/compute/fft.js +292 -0
- package/src/compute/filter.js +227 -0
- package/src/compute/hist.js +180 -0
- package/src/compute/kde.js +102 -0
- package/src/{Layer.js → core/Layer.js} +4 -3
- package/src/{LayerType.js → core/LayerType.js} +72 -7
- package/src/core/Plot.js +735 -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} +17 -30
- package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
- package/src/index.js +35 -22
- package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +2 -2
- package/src/layers/ColorbarLayer2d.js +97 -0
- package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +2 -2
- package/src/layers/HistogramLayer.js +212 -0
- package/src/layers/LinesLayer.js +199 -0
- package/src/layers/PointsLayer.js +114 -0
- package/src/layers/ScatterShared.js +142 -0
- package/src/{TileLayer.js → layers/TileLayer.js} +4 -4
- package/src/Axis.js +0 -48
- package/src/ColorAxisRegistry.js +0 -49
- package/src/ColorscaleRegistry.js +0 -52
- package/src/Float.js +0 -159
- package/src/Plot.js +0 -1073
- package/src/ScatterLayer.js +0 -287
- /package/src/{AxisLink.js → axes/AxisLink.js} +0 -0
- /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
- /package/src/{Data.js → core/Data.js} +0 -0
- /package/src/{LayerTypeRegistry.js → core/LayerTypeRegistry.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gladly-plot",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "GPU-powered multi-axis plotting library with regl + d3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"prepare": "node scripts/fix-numjs-wasm.js",
|
|
14
|
-
"dev": "parcel serve example/index.html --
|
|
14
|
+
"dev": "parcel serve example/index.html --no-cache",
|
|
15
15
|
"build:example": "parcel build example/index.html --dist-dir dist-example --public-url ./",
|
|
16
16
|
"preview": "npm run build:example && npx serve dist-example"
|
|
17
17
|
},
|
package/src/axes/Axis.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { axisBottom, axisTop, axisLeft, axisRight } from "d3-axis"
|
|
2
|
+
import { AXES } from "./AxisRegistry.js"
|
|
3
|
+
import { getAxisQuantityKind } from "./AxisQuantityKindRegistry.js"
|
|
4
|
+
|
|
5
|
+
const AXIS_CONSTRUCTORS = {
|
|
6
|
+
xaxis_bottom: axisBottom,
|
|
7
|
+
xaxis_top: axisTop,
|
|
8
|
+
yaxis_left: axisLeft,
|
|
9
|
+
yaxis_right: axisRight,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatTick(v) {
|
|
13
|
+
if (v === 0) return "0"
|
|
14
|
+
const abs = Math.abs(v)
|
|
15
|
+
if (abs >= 10000 || abs < 0.01) {
|
|
16
|
+
return v.toExponential(2).replace(/\.?0+(e)/, '$1')
|
|
17
|
+
}
|
|
18
|
+
const s = v.toPrecision(4)
|
|
19
|
+
if (s.includes('.') && !s.includes('e')) {
|
|
20
|
+
return s.replace(/\.?0+$/, '')
|
|
21
|
+
}
|
|
22
|
+
return s
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Returns tick values for a log scale using the 1-2-5 sequence (1×, 2×, 5× per decade),
|
|
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) {
|
|
31
|
+
const [dMin, dMax] = scale.domain()
|
|
32
|
+
if (dMin <= 0 || dMax <= 0) return null
|
|
33
|
+
|
|
34
|
+
const logMin = Math.log10(dMin)
|
|
35
|
+
const logMax = Math.log10(dMax)
|
|
36
|
+
const startExp = Math.floor(logMin)
|
|
37
|
+
const endExp = Math.ceil(logMax)
|
|
38
|
+
|
|
39
|
+
const candidate = []
|
|
40
|
+
for (let e = startExp; e < endExp; e++) {
|
|
41
|
+
const base = Math.pow(10, e)
|
|
42
|
+
for (const mult of [1, 2, 5]) {
|
|
43
|
+
const v = base * mult
|
|
44
|
+
if (v >= dMin * (1 - 1e-10) && v <= dMax * (1 + 1e-10)) candidate.push(v)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const upperPow = Math.pow(10, endExp)
|
|
48
|
+
if (upperPow >= dMin * (1 - 1e-10) && upperPow <= dMax * (1 + 1e-10)) candidate.push(upperPow)
|
|
49
|
+
|
|
50
|
+
if (candidate.length >= 2 && candidate.length <= pixelCount) {
|
|
51
|
+
return candidate
|
|
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
|
+
}
|
|
59
|
+
const numPowers = lastExp - firstExp + 1
|
|
60
|
+
const step = numPowers > pixelCount ? Math.ceil(numPowers / pixelCount) : 1
|
|
61
|
+
const powers = []
|
|
62
|
+
for (let e = firstExp; e <= lastExp; e += step) {
|
|
63
|
+
powers.push(Math.pow(10, e))
|
|
64
|
+
}
|
|
65
|
+
return powers.length >= 2 ? powers : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An Axis represents a single data axis on a plot. Axis instances are stable across
|
|
70
|
+
* plot.update() calls and can be linked together with linkAxes().
|
|
71
|
+
*
|
|
72
|
+
* Public interface (duck-typing compatible):
|
|
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
|
|
79
|
+
*/
|
|
80
|
+
export class Axis {
|
|
81
|
+
constructor(plot, name) {
|
|
82
|
+
this._plot = plot
|
|
83
|
+
this._name = name
|
|
84
|
+
this._listeners = new Set()
|
|
85
|
+
this._propagating = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The quantity kind for this axis, or null if the plot hasn't been initialized yet. */
|
|
89
|
+
get quantityKind() { return this._plot.getAxisQuantityKind(this._name) }
|
|
90
|
+
|
|
91
|
+
/** True if this is a spatial (D3-rendered) axis; false for color/filter axes. */
|
|
92
|
+
get isSpatial() { return AXES.includes(this._name) }
|
|
93
|
+
|
|
94
|
+
/** Returns [min, max], or null if the axis has no domain yet. */
|
|
95
|
+
getDomain() { return this._plot.getAxisDomain(this._name) }
|
|
96
|
+
|
|
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) {
|
|
103
|
+
if (this._propagating) return
|
|
104
|
+
this._propagating = true
|
|
105
|
+
try {
|
|
106
|
+
this._plot.setAxisDomain(this._name, domain)
|
|
107
|
+
this._plot.scheduleRender()
|
|
108
|
+
for (const cb of this._listeners) cb(domain)
|
|
109
|
+
} finally {
|
|
110
|
+
this._propagating = false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Add a subscriber. callback([min, max]) is called after every setDomain(). */
|
|
115
|
+
subscribe(callback) { this._listeners.add(callback) }
|
|
116
|
+
|
|
117
|
+
/** Remove a previously added subscriber. */
|
|
118
|
+
unsubscribe(callback) { this._listeners.delete(callback) }
|
|
119
|
+
|
|
120
|
+
// ─── Spatial axis rendering ───────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
_tickCount(rotate = false) {
|
|
123
|
+
const { plotWidth, plotHeight } = this._plot
|
|
124
|
+
if (this._name.includes("y")) {
|
|
125
|
+
return Math.max(2, Math.floor(plotHeight / 27))
|
|
126
|
+
}
|
|
127
|
+
const pixelsPerTick = rotate ? 28 : 40
|
|
128
|
+
return Math.max(2, Math.floor(plotWidth / pixelsPerTick))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_makeD3Axis(scale, { rotate = false } = {}) {
|
|
132
|
+
const isLog = typeof scale.base === 'function'
|
|
133
|
+
const count = this._tickCount(rotate)
|
|
134
|
+
const gen = AXIS_CONSTRUCTORS[this._name](scale).tickFormat(formatTick)
|
|
135
|
+
if (isLog) {
|
|
136
|
+
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)
|
|
146
|
+
}
|
|
147
|
+
return gen
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_renderLabel(axisGroup, availableMargin) {
|
|
151
|
+
const { axisRegistry, currentConfig, plotWidth, plotHeight } = this._plot
|
|
152
|
+
const axisQuantityKind = axisRegistry.axisQuantityKinds[this._name]
|
|
153
|
+
if (!axisQuantityKind) return
|
|
154
|
+
|
|
155
|
+
const unitLabel = currentConfig?.axes?.[axisQuantityKind]?.label
|
|
156
|
+
?? getAxisQuantityKind(axisQuantityKind).label
|
|
157
|
+
const isVertical = this._name.includes("y")
|
|
158
|
+
const centerPos = isVertical ? -plotHeight / 2 : plotWidth / 2
|
|
159
|
+
|
|
160
|
+
axisGroup.select(".axis-label").remove()
|
|
161
|
+
|
|
162
|
+
const text = axisGroup.append("text")
|
|
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
|
+
}
|
|
180
|
+
|
|
181
|
+
if (isVertical) text.attr("transform", "rotate(-90)")
|
|
182
|
+
text.attr("x", centerPos).attr("y", 0)
|
|
183
|
+
|
|
184
|
+
const bbox = text.node().getBBox()
|
|
185
|
+
const tickSpace = 25
|
|
186
|
+
let yOffset
|
|
187
|
+
|
|
188
|
+
if (this._name === "xaxis_bottom") {
|
|
189
|
+
yOffset = (tickSpace + (availableMargin - tickSpace) / 2) - (bbox.y + bbox.height / 2)
|
|
190
|
+
} else if (this._name === "xaxis_top") {
|
|
191
|
+
yOffset = -(tickSpace + (availableMargin - tickSpace) / 2) - (bbox.y + bbox.height / 2)
|
|
192
|
+
} else if (this._name === "yaxis_left") {
|
|
193
|
+
yOffset = -(tickSpace + (availableMargin - tickSpace) / 2) - (bbox.y + bbox.height / 2)
|
|
194
|
+
} else if (this._name === "yaxis_right") {
|
|
195
|
+
yOffset = (tickSpace + (availableMargin - tickSpace) / 2) - (bbox.y + bbox.height / 2)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
text.attr("y", yOffset)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Renders this axis into the plot's SVG. No-op for non-spatial axes (color/filter).
|
|
203
|
+
* Called by Plot.render() after each WebGL frame.
|
|
204
|
+
*/
|
|
205
|
+
render() {
|
|
206
|
+
if (!this.isSpatial) return
|
|
207
|
+
const { svg, margin, plotWidth, plotHeight, axisRegistry, currentConfig } = this._plot
|
|
208
|
+
const scale = axisRegistry.getScale(this._name)
|
|
209
|
+
if (!scale) return
|
|
210
|
+
|
|
211
|
+
const axisConfig = currentConfig?.axes?.[this._name] ?? {}
|
|
212
|
+
const rotate = axisConfig.rotate ?? false
|
|
213
|
+
|
|
214
|
+
let transform, availableMargin
|
|
215
|
+
if (this._name === "xaxis_bottom") {
|
|
216
|
+
transform = `translate(${margin.left},${margin.top + plotHeight})`
|
|
217
|
+
availableMargin = margin.bottom
|
|
218
|
+
} else if (this._name === "xaxis_top") {
|
|
219
|
+
transform = `translate(${margin.left},${margin.top})`
|
|
220
|
+
availableMargin = margin.top
|
|
221
|
+
} else if (this._name === "yaxis_left") {
|
|
222
|
+
transform = `translate(${margin.left},${margin.top})`
|
|
223
|
+
availableMargin = margin.left
|
|
224
|
+
} else if (this._name === "yaxis_right") {
|
|
225
|
+
transform = `translate(${margin.left + plotWidth},${margin.top})`
|
|
226
|
+
availableMargin = margin.right
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const g = svg.select(`.${this._name}`)
|
|
230
|
+
.attr("transform", transform)
|
|
231
|
+
.call(this._makeD3Axis(scale, { rotate }))
|
|
232
|
+
|
|
233
|
+
g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
|
|
234
|
+
g.selectAll(".tick line").attr("stroke", "#000")
|
|
235
|
+
g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
|
|
236
|
+
|
|
237
|
+
if (rotate && this._name === "xaxis_bottom") {
|
|
238
|
+
g.selectAll(".tick text")
|
|
239
|
+
.style("text-anchor", "end")
|
|
240
|
+
.attr("dx", "-0.8em")
|
|
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)")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this._renderLabel(g, availableMargin)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -38,6 +38,54 @@ export class AxisRegistry {
|
|
|
38
38
|
return !!scale && typeof scale.base === 'function'
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
applyAutoDomainsFromLayers(layers, axesOverrides) {
|
|
42
|
+
const autoDomains = {}
|
|
43
|
+
|
|
44
|
+
for (const axis of AXES) {
|
|
45
|
+
const layersUsingAxis = layers.filter(l => l.xAxis === axis || l.yAxis === axis)
|
|
46
|
+
if (layersUsingAxis.length === 0) continue
|
|
47
|
+
|
|
48
|
+
let min = Infinity, max = -Infinity
|
|
49
|
+
for (const layer of layersUsingAxis) {
|
|
50
|
+
const isXAxis = layer.xAxis === axis
|
|
51
|
+
const qk = isXAxis ? layer.xAxisQuantityKind : layer.yAxisQuantityKind
|
|
52
|
+
if (layer.domains[qk] !== undefined) {
|
|
53
|
+
const [dMin, dMax] = layer.domains[qk]
|
|
54
|
+
if (dMin < min) min = dMin
|
|
55
|
+
if (dMax > max) max = dMax
|
|
56
|
+
} else {
|
|
57
|
+
const dataArray = isXAxis ? layer.attributes.x : layer.attributes.y
|
|
58
|
+
if (!dataArray) continue
|
|
59
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
60
|
+
const val = dataArray[i]
|
|
61
|
+
if (val < min) min = val
|
|
62
|
+
if (val > max) max = val
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (min !== Infinity) autoDomains[axis] = [min, max]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const axis of AXES) {
|
|
70
|
+
const scale = this.getScale(axis)
|
|
71
|
+
if (!scale) continue
|
|
72
|
+
const override = axesOverrides[axis]
|
|
73
|
+
const domain = override ? [override.min, override.max] : autoDomains[axis]
|
|
74
|
+
if (domain) scale.domain(domain)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const axis of AXES) {
|
|
78
|
+
if (!this.isLogScale(axis)) continue
|
|
79
|
+
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
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
41
89
|
setScaleType(axisName, scaleType) {
|
|
42
90
|
const scale = this.scales[axisName]
|
|
43
91
|
if (!scale) return
|
|
@@ -0,0 +1,93 @@
|
|
|
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 })
|
|
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
|
+
|
|
50
|
+
applyAutoDomainsFromLayers(layers, axesOverrides) {
|
|
51
|
+
for (const quantityKind of this.getQuantityKinds()) {
|
|
52
|
+
const override = axesOverrides[quantityKind]
|
|
53
|
+
if (override?.colorscale && override?.colorscale != "none")
|
|
54
|
+
this.ensureColorAxis(quantityKind, override.colorscale)
|
|
55
|
+
|
|
56
|
+
let min = Infinity, max = -Infinity
|
|
57
|
+
|
|
58
|
+
for (const layer of layers) {
|
|
59
|
+
for (const qk of layer.colorAxes) {
|
|
60
|
+
if (qk !== quantityKind) continue
|
|
61
|
+
if (layer.domains[qk] !== undefined) {
|
|
62
|
+
const [dMin, dMax] = layer.domains[qk]
|
|
63
|
+
if (dMin < min) min = dMin
|
|
64
|
+
if (dMax > max) max = dMax
|
|
65
|
+
} else {
|
|
66
|
+
const data = layer.attributes[qk]
|
|
67
|
+
if (!data) continue
|
|
68
|
+
for (let i = 0; i < data.length; i++) {
|
|
69
|
+
if (data[i] < min) min = data[i]
|
|
70
|
+
if (data[i] > max) max = data[i]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (min !== Infinity) {
|
|
77
|
+
this.setRange(quantityKind, override?.min ?? min, override?.max ?? max)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const quantityKind of this.getQuantityKinds()) {
|
|
82
|
+
if (getScaleTypeFloat(quantityKind, axesOverrides) <= 0.5) continue
|
|
83
|
+
const range = this.getRange(quantityKind)
|
|
84
|
+
if (!range) continue
|
|
85
|
+
if (range[0] <= 0 || range[1] <= 0) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Color axis '${quantityKind}' uses log scale but has non-positive range [${range[0]}, ${range[1]}]. ` +
|
|
88
|
+
`All data values and min/max must be > 0 for log scale.`
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -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 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,141 @@
|
|
|
1
|
+
import * as d3 from "d3-selection"
|
|
2
|
+
import { zoom } from "d3-zoom"
|
|
3
|
+
import { AXES } from "./AxisRegistry.js"
|
|
4
|
+
|
|
5
|
+
export class ZoomController {
|
|
6
|
+
constructor(plot) {
|
|
7
|
+
this._plot = plot
|
|
8
|
+
this._init()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
_init() {
|
|
12
|
+
const plot = this._plot
|
|
13
|
+
|
|
14
|
+
const fullOverlay = plot.svg.append("rect")
|
|
15
|
+
.attr("class", "zoom-overlay")
|
|
16
|
+
.attr("x", 0)
|
|
17
|
+
.attr("y", 0)
|
|
18
|
+
.attr("width", plot.width)
|
|
19
|
+
.attr("height", plot.height)
|
|
20
|
+
.attr("fill", "none")
|
|
21
|
+
.attr("pointer-events", "all")
|
|
22
|
+
.style("cursor", "move")
|
|
23
|
+
|
|
24
|
+
let currentRegion = null
|
|
25
|
+
let gestureStartDomains = {}
|
|
26
|
+
let gestureStartMousePos = {}
|
|
27
|
+
let gestureStartDataPos = {}
|
|
28
|
+
let gestureStartTransform = null
|
|
29
|
+
|
|
30
|
+
const zoomBehavior = zoom()
|
|
31
|
+
.on("start", (event) => {
|
|
32
|
+
if (!event.sourceEvent) return
|
|
33
|
+
|
|
34
|
+
gestureStartTransform = { k: event.transform.k, x: event.transform.x, y: event.transform.y }
|
|
35
|
+
const [mouseX, mouseY] = d3.pointer(event.sourceEvent, plot.svg.node())
|
|
36
|
+
const { margin, plotWidth, plotHeight } = plot
|
|
37
|
+
|
|
38
|
+
const inPlotX = mouseX >= margin.left && mouseX < margin.left + plotWidth
|
|
39
|
+
const inPlotY = mouseY >= margin.top && mouseY < margin.top + plotHeight
|
|
40
|
+
|
|
41
|
+
if (inPlotX && mouseY < margin.top) {
|
|
42
|
+
currentRegion = "xaxis_top"
|
|
43
|
+
} else if (inPlotX && mouseY >= margin.top + plotHeight) {
|
|
44
|
+
currentRegion = "xaxis_bottom"
|
|
45
|
+
} else if (inPlotY && mouseX < margin.left) {
|
|
46
|
+
currentRegion = "yaxis_left"
|
|
47
|
+
} else if (inPlotY && mouseX >= margin.left + plotWidth) {
|
|
48
|
+
currentRegion = "yaxis_right"
|
|
49
|
+
} else if (inPlotX && inPlotY) {
|
|
50
|
+
currentRegion = "plot_area"
|
|
51
|
+
} else {
|
|
52
|
+
currentRegion = null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
gestureStartDomains = {}
|
|
56
|
+
gestureStartMousePos = {}
|
|
57
|
+
gestureStartDataPos = {}
|
|
58
|
+
|
|
59
|
+
if (currentRegion && plot.axisRegistry) {
|
|
60
|
+
const axesToZoom = currentRegion === "plot_area" ? AXES : [currentRegion]
|
|
61
|
+
axesToZoom.forEach(axis => {
|
|
62
|
+
const scale = plot.axisRegistry.getScale(axis)
|
|
63
|
+
if (!scale) return
|
|
64
|
+
|
|
65
|
+
const { margin, plotWidth, plotHeight } = plot
|
|
66
|
+
const isY = axis.includes("y")
|
|
67
|
+
const mousePixel = isY ? (mouseY - margin.top) : (mouseX - margin.left)
|
|
68
|
+
const pixelSize = isY ? plotHeight : plotWidth
|
|
69
|
+
const currentDomain = scale.domain()
|
|
70
|
+
gestureStartDomains[axis] = currentDomain.slice()
|
|
71
|
+
gestureStartMousePos[axis] = mousePixel
|
|
72
|
+
|
|
73
|
+
const isLog = plot.axisRegistry.isLogScale(axis)
|
|
74
|
+
const [d0, d1] = currentDomain
|
|
75
|
+
const t0 = isLog ? Math.log(d0) : d0
|
|
76
|
+
const t1 = isLog ? Math.log(d1) : d1
|
|
77
|
+
const fraction = mousePixel / pixelSize
|
|
78
|
+
gestureStartDataPos[axis] = isY
|
|
79
|
+
? t1 - fraction * (t1 - t0)
|
|
80
|
+
: t0 + fraction * (t1 - t0)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
.on("zoom", (event) => {
|
|
85
|
+
if (!plot.axisRegistry || !currentRegion || !gestureStartTransform) return
|
|
86
|
+
|
|
87
|
+
const deltaK = event.transform.k / gestureStartTransform.k
|
|
88
|
+
const deltaX = event.transform.x - gestureStartTransform.x
|
|
89
|
+
const deltaY = event.transform.y - gestureStartTransform.y
|
|
90
|
+
const isWheel = event.sourceEvent && event.sourceEvent.type === 'wheel'
|
|
91
|
+
const axesToZoom = currentRegion === "plot_area" ? AXES : [currentRegion]
|
|
92
|
+
|
|
93
|
+
axesToZoom.forEach(axis => {
|
|
94
|
+
const scale = plot.axisRegistry.getScale(axis)
|
|
95
|
+
if (!scale || !gestureStartDomains[axis] || gestureStartDataPos[axis] === undefined) return
|
|
96
|
+
|
|
97
|
+
const { plotWidth, plotHeight } = plot
|
|
98
|
+
const isY = axis.includes("y")
|
|
99
|
+
const [d0, d1] = gestureStartDomains[axis]
|
|
100
|
+
const isLog = plot.axisRegistry.isLogScale(axis)
|
|
101
|
+
const t0 = isLog ? Math.log(d0) : d0
|
|
102
|
+
const t1 = isLog ? Math.log(d1) : d1
|
|
103
|
+
const tDomainWidth = t1 - t0
|
|
104
|
+
|
|
105
|
+
const pixelSize = isY ? plotHeight : plotWidth
|
|
106
|
+
const pixelDelta = isY ? deltaY : deltaX
|
|
107
|
+
const newTDomainWidth = tDomainWidth / deltaK
|
|
108
|
+
const targetDataPos = gestureStartDataPos[axis]
|
|
109
|
+
const mousePixelPos = gestureStartMousePos[axis]
|
|
110
|
+
const fraction = mousePixelPos / pixelSize
|
|
111
|
+
|
|
112
|
+
const panTDomainDelta = isWheel ? 0 : (isY
|
|
113
|
+
? pixelDelta * tDomainWidth / pixelSize / deltaK
|
|
114
|
+
: -pixelDelta * tDomainWidth / pixelSize / deltaK)
|
|
115
|
+
|
|
116
|
+
const tCenter = isY
|
|
117
|
+
? (targetDataPos + panTDomainDelta) + (fraction - 0.5) * newTDomainWidth
|
|
118
|
+
: (targetDataPos + panTDomainDelta) + (0.5 - fraction) * newTDomainWidth
|
|
119
|
+
|
|
120
|
+
const newTDomain = [tCenter - newTDomainWidth / 2, tCenter + newTDomainWidth / 2]
|
|
121
|
+
const newDomain = isLog
|
|
122
|
+
? [Math.exp(newTDomain[0]), Math.exp(newTDomain[1])]
|
|
123
|
+
: newTDomain
|
|
124
|
+
|
|
125
|
+
plot._getAxis(axis).setDomain(newDomain)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
plot.scheduleRender()
|
|
129
|
+
})
|
|
130
|
+
.on("end", () => {
|
|
131
|
+
currentRegion = null
|
|
132
|
+
gestureStartDomains = {}
|
|
133
|
+
gestureStartMousePos = {}
|
|
134
|
+
gestureStartDataPos = {}
|
|
135
|
+
gestureStartTransform = null
|
|
136
|
+
plot._zoomEndCallbacks.forEach(cb => cb())
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
fullOverlay.call(zoomBehavior)
|
|
140
|
+
}
|
|
141
|
+
}
|