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.
Files changed (43) hide show
  1. package/package.json +2 -2
  2. package/src/axes/Axis.js +253 -0
  3. package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
  4. package/src/{AxisRegistry.js → axes/AxisRegistry.js} +48 -0
  5. package/src/axes/ColorAxisRegistry.js +93 -0
  6. package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
  7. package/src/axes/ZoomController.js +141 -0
  8. package/src/colorscales/BivariateColorscales.js +205 -0
  9. package/src/colorscales/ColorscaleRegistry.js +124 -0
  10. package/src/compute/ComputationRegistry.js +237 -0
  11. package/src/compute/axisFilter.js +47 -0
  12. package/src/compute/conv.js +230 -0
  13. package/src/compute/fft.js +292 -0
  14. package/src/compute/filter.js +227 -0
  15. package/src/compute/hist.js +180 -0
  16. package/src/compute/kde.js +102 -0
  17. package/src/{Layer.js → core/Layer.js} +4 -3
  18. package/src/{LayerType.js → core/LayerType.js} +72 -7
  19. package/src/core/Plot.js +735 -0
  20. package/src/{Colorbar.js → floats/Colorbar.js} +19 -5
  21. package/src/floats/Colorbar2d.js +77 -0
  22. package/src/{Filterbar.js → floats/Filterbar.js} +18 -4
  23. package/src/{FilterbarFloat.js → floats/Float.js} +17 -30
  24. package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
  25. package/src/index.js +35 -22
  26. package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +2 -2
  27. package/src/layers/ColorbarLayer2d.js +97 -0
  28. package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +2 -2
  29. package/src/layers/HistogramLayer.js +212 -0
  30. package/src/layers/LinesLayer.js +199 -0
  31. package/src/layers/PointsLayer.js +114 -0
  32. package/src/layers/ScatterShared.js +142 -0
  33. package/src/{TileLayer.js → layers/TileLayer.js} +4 -4
  34. package/src/Axis.js +0 -48
  35. package/src/ColorAxisRegistry.js +0 -49
  36. package/src/ColorscaleRegistry.js +0 -52
  37. package/src/Float.js +0 -159
  38. package/src/Plot.js +0 -1073
  39. package/src/ScatterLayer.js +0 -287
  40. /package/src/{AxisLink.js → axes/AxisLink.js} +0 -0
  41. /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
  42. /package/src/{Data.js → core/Data.js} +0 -0
  43. /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.4",
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 --open",
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
  },
@@ -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
+ }