gladly-plot 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +9 -2
  2. package/package.json +10 -11
  3. package/src/axes/Axis.js +320 -172
  4. package/src/axes/AxisLink.js +6 -2
  5. package/src/axes/AxisRegistry.js +116 -39
  6. package/src/axes/Camera.js +47 -0
  7. package/src/axes/ColorAxisRegistry.js +10 -2
  8. package/src/axes/FilterAxisRegistry.js +1 -1
  9. package/src/axes/TickLabelAtlas.js +99 -0
  10. package/src/axes/ZoomController.js +446 -124
  11. package/src/colorscales/ColorscaleRegistry.js +30 -10
  12. package/src/compute/ComputationRegistry.js +126 -184
  13. package/src/compute/axisFilter.js +21 -9
  14. package/src/compute/conv.js +64 -8
  15. package/src/compute/elementwise.js +72 -0
  16. package/src/compute/fft.js +106 -20
  17. package/src/compute/filter.js +105 -103
  18. package/src/compute/hist.js +247 -142
  19. package/src/compute/kde.js +64 -46
  20. package/src/compute/scatter2dInterpolate.js +277 -0
  21. package/src/compute/util.js +196 -0
  22. package/src/core/ComputePipeline.js +153 -0
  23. package/src/core/GlBase.js +141 -0
  24. package/src/core/Layer.js +22 -8
  25. package/src/core/LayerType.js +251 -92
  26. package/src/core/Plot.js +630 -152
  27. package/src/core/PlotGroup.js +204 -0
  28. package/src/core/ShaderQueue.js +73 -0
  29. package/src/data/ColumnData.js +269 -0
  30. package/src/data/Computation.js +95 -0
  31. package/src/data/Data.js +270 -0
  32. package/src/floats/Float.js +56 -0
  33. package/src/index.js +16 -4
  34. package/src/layers/BarsLayer.js +168 -0
  35. package/src/layers/ColorbarLayer.js +10 -14
  36. package/src/layers/ColorbarLayer2d.js +13 -24
  37. package/src/layers/FilterbarLayer.js +4 -3
  38. package/src/layers/LinesLayer.js +108 -122
  39. package/src/layers/PointsLayer.js +73 -69
  40. package/src/layers/ScatterShared.js +62 -106
  41. package/src/layers/TileLayer.js +20 -16
  42. package/src/math/mat4.js +100 -0
  43. package/src/core/Data.js +0 -67
  44. package/src/layers/HistogramLayer.js +0 -212
package/README.md CHANGED
@@ -19,11 +19,18 @@ Gladly combines WebGL rendering (via regl) with D3.js for interactive axes and z
19
19
  - 🌈 Axis to coloring or filtering linking
20
20
  - 🌎 Basemap layer with XYZ,WMS and WMTS support and CRS reprojection
21
21
 
22
+ ## Extensions
23
+
24
+ - **[gladly-jupyter](https://redhog.github.io/gladly-jupyter/)** - Jupyter notebook widget extension for Gladly
25
+
22
26
  ## Documentation
23
27
 
24
28
  - **[Quick Start](docs/Quickstart.md)** - Installation and minimal working example
25
- - **[API Documentation](docs/API.md)** - Complete API reference and usage guide
26
- - **[Architecture Documentation](docs/ARCHITECTURE.md)** - Developer guide and design patterns
29
+ - **[Concepts](docs/concepts/Overview.md)** - Data model overview: axes, quantity kinds, colorscales, layer types, data format
30
+ - **[Configuration](docs/configuration/overview.md)** - Plot configuration, layers, axes, colorscales, built-in computations
31
+ - **[User API](docs/user-api/overview.md)** - Programmatic APIs for plots, axes, data, and computations
32
+ - **[Extension API](docs/extension-api/overview.md)** - Writing custom layer types and computations
33
+ - **[Architecture](docs/architecture/overview.md)** - Design patterns and module responsibilities
27
34
 
28
35
  ## Technology Stack
29
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gladly-plot",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "GPU-powered multi-axis plotting library with regl + d3",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,15 +10,14 @@
10
10
  "src"
11
11
  ],
12
12
  "scripts": {
13
- "prepare": "node scripts/fix-numjs-wasm.js",
14
- "dev": "parcel serve example/index.html --no-cache",
15
- "build:example": "parcel build example/index.html --dist-dir dist-example --public-url ./",
16
- "preview": "npm run build:example && npx serve dist-example"
13
+ "dev": "npx serve . -l 1234",
14
+ "build:example": "bash scripts/build-example.sh",
15
+ "build:lib": "rollup -c"
17
16
  },
18
17
  "dependencies": {
19
18
  "d3": "^7.8.5",
20
19
  "proj4": "^2.15.0",
21
- "projnames": "^0.0.2",
20
+ "projnames": "^0.0.4",
22
21
  "regl": "^2.1.0"
23
22
  },
24
23
  "browserslist": [
@@ -27,12 +26,12 @@
27
26
  "last 2 Firefox versions",
28
27
  "last 2 Safari versions"
29
28
  ],
30
-
31
29
  "devDependencies": {
32
- "@jayce789/numjs": "^2.2.6",
33
30
  "@json-editor/json-editor": "^2.15.1",
34
- "parcel": "^2.9.0",
35
- "process": "^0.11.10",
36
- "url": "^0.11.4"
31
+ "@rollup/plugin-commonjs": "^29.0.2",
32
+ "@rollup/plugin-json": "^6.1.0",
33
+ "@rollup/plugin-node-resolve": "^15.3.1",
34
+ "@rollup/plugin-terser": "^1.0.0",
35
+ "rollup": "^4.59.1"
37
36
  }
38
37
  }
package/src/axes/Axis.js CHANGED
@@ -1,13 +1,8 @@
1
- import { axisBottom, axisTop, axisLeft, axisRight } from "d3-axis"
2
- import { AXES } from "./AxisRegistry.js"
1
+ import { AXIS_GEOMETRY, axisEndpoints, axisPosAtN } from "./AxisRegistry.js"
3
2
  import { getAxisQuantityKind } from "./AxisQuantityKindRegistry.js"
3
+ import { projectToScreen } from "../math/mat4.js"
4
4
 
5
- const AXIS_CONSTRUCTORS = {
6
- xaxis_bottom: axisBottom,
7
- xaxis_top: axisTop,
8
- yaxis_left: axisLeft,
9
- yaxis_right: axisRight,
10
- }
5
+ // ─── Tick formatting (same logic as before) ───────────────────────────────────
11
6
 
12
7
  function formatTick(v) {
13
8
  if (v === 0) return "0"
@@ -16,26 +11,15 @@ function formatTick(v) {
16
11
  return v.toExponential(2).replace(/\.?0+(e)/, '$1')
17
12
  }
18
13
  const s = v.toPrecision(4)
19
- if (s.includes('.') && !s.includes('e')) {
20
- return s.replace(/\.?0+$/, '')
21
- }
14
+ if (s.includes('.') && !s.includes('e')) return s.replace(/\.?0+$/, '')
22
15
  return s
23
16
  }
24
17
 
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) {
18
+ function logTickValues(scale, count) {
31
19
  const [dMin, dMax] = scale.domain()
32
20
  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
-
21
+ const logMin = Math.log10(dMin), logMax = Math.log10(dMax)
22
+ const startExp = Math.floor(logMin), endExp = Math.ceil(logMax)
39
23
  const candidate = []
40
24
  for (let e = startExp; e < endExp; e++) {
41
25
  const base = Math.pow(10, e)
@@ -46,208 +30,372 @@ function logTickValues(scale, pixelCount) {
46
30
  }
47
31
  const upperPow = Math.pow(10, endExp)
48
32
  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
- }
33
+ if (candidate.length >= 2 && candidate.length <= count) return candidate
34
+ const firstExp = Math.ceil(logMin), lastExp = Math.floor(logMax)
35
+ if (firstExp > lastExp) return candidate.length >= 2 ? candidate : null
59
36
  const numPowers = lastExp - firstExp + 1
60
- const step = numPowers > pixelCount ? Math.ceil(numPowers / pixelCount) : 1
37
+ const step = numPowers > count ? Math.ceil(numPowers / count) : 1
61
38
  const powers = []
62
- for (let e = firstExp; e <= lastExp; e += step) {
63
- powers.push(Math.pow(10, e))
64
- }
39
+ for (let e = firstExp; e <= lastExp; e += step) powers.push(Math.pow(10, e))
65
40
  return powers.length >= 2 ? powers : null
66
41
  }
67
42
 
43
+ // Normalise a data value to [0,1] within its domain (matches GLSL normalize_axis).
44
+ function normaliseValue(v, domain, isLog) {
45
+ const vt = isLog ? Math.log(v) : v
46
+ const d0 = isLog ? Math.log(domain[0]) : domain[0]
47
+ const d1 = isLog ? Math.log(domain[1]) : domain[1]
48
+ return (vt - d0) / (d1 - d0)
49
+ }
50
+
51
+ // ─── Tick mark / label geometry constants ─────────────────────────────────────
52
+ // 3D: fixed model-space offsets (legacy behaviour)
53
+ const TICK_LEN_3D = 0.05
54
+ const LABEL_DIST_3D = 0.12
55
+ const TITLE_EXTRA_3D = 0.14 // extra outward offset for axis title beyond LABEL_DIST_3D
56
+ const TITLE_LINE_STEP_3D = 0.05 // multi-line title step in model space
57
+
58
+ // 2D: fractions of the pixel distance from the axis to the nearest canvas edge
59
+ const TICK_LEN_FRAC = 0.12
60
+ const TICK_LABEL_FRAC = 0.40
61
+ const AXIS_TITLE_FRAC = 0.75
62
+ const TITLE_LINE_SPACING_FRAC = 0.12
63
+
68
64
  /**
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().
65
+ * An Axis represents a single data axis on a plot.
66
+ *
67
+ * Public interface (unchanged from before):
68
+ * axis.quantityKind, axis.isSpatial, axis.getDomain(), axis.setDomain(),
69
+ * axis.subscribe(), axis.unsubscribe()
71
70
  *
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
71
+ * Rendering is now entirely WebGL-based. Call axis.render() from Plot.render().
79
72
  */
80
73
  export class Axis {
81
74
  constructor(plot, name) {
82
- this._plot = plot
83
- this._name = name
84
- this._listeners = new Set()
75
+ this._plot = plot
76
+ this._name = name
77
+ this._listeners = new Set()
78
+ this._linkedAxes = new Set()
85
79
  this._propagating = false
86
80
  }
87
81
 
88
- /** The quantity kind for this axis, or null if the plot hasn't been initialized yet. */
89
82
  get quantityKind() { return this._plot.getAxisQuantityKind(this._name) }
90
83
 
91
- /** True if this is a spatial (D3-rendered) axis; false for color/filter axes. */
92
- get isSpatial() { return AXES.includes(this._name) }
84
+ // True for all 12 spatial axes; false for colour/filter axes.
85
+ get isSpatial() { return Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, this._name) }
93
86
 
94
- /** Returns [min, max], or null if the axis has no domain yet. */
95
87
  getDomain() { return this._plot.getAxisDomain(this._name) }
96
88
 
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) {
89
+ setDomain(domain, { sourcePlot = null } = {}) {
103
90
  if (this._propagating) return
104
91
  this._propagating = true
105
92
  try {
106
93
  this._plot.setAxisDomain(this._name, domain)
107
- this._plot.scheduleRender()
94
+ this._plot.scheduleRender(sourcePlot)
108
95
  for (const cb of this._listeners) cb(domain)
109
96
  } finally {
110
97
  this._propagating = false
111
98
  }
112
99
  }
113
100
 
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. */
101
+ subscribe(callback) { this._listeners.add(callback) }
118
102
  unsubscribe(callback) { this._listeners.delete(callback) }
119
103
 
120
- // ─── Spatial axis rendering ───────────────────────────────────────────────
104
+ // ─── WebGL rendering ───────────────────────────────────────────────────────
121
105
 
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))
106
+ // Pre-pass: mark all labels this axis needs into the atlas (no flush).
107
+ // Called by Plot.render() for all axes before the single atlas.flush().
108
+ prepareAtlas(atlas, axisMvp, cw, ch) {
109
+ if (!this.isSpatial) return
110
+ const { axisRegistry, currentConfig } = this._plot
111
+ if (currentConfig?.axes?.[this._name]?.visible === false) return
112
+ const scale = axisRegistry.getScale(this._name)
113
+ if (!scale) return
114
+
115
+ const screenLen = this._projectedLength(axisMvp, cw, ch)
116
+ const pxPerTick = AXIS_GEOMETRY[this._name].dir === 'y' ? 27 : 40
117
+ const tickCount = Math.max(2, Math.floor(screenLen / pxPerTick))
118
+ const ticks = this._computeTicks(scale, tickCount)
119
+ atlas.markLabels(ticks.map(t => formatTick(t)))
120
+
121
+ const qk = axisRegistry.axisQuantityKinds[this._name]
122
+ if (qk) {
123
+ const axisConfig = currentConfig?.axes?.[this._name] ?? {}
124
+ const unitLabel = axisConfig.label ?? getAxisQuantityKind(qk).label
125
+ if (unitLabel) atlas.markLabels(String(unitLabel).split('\n'))
126
126
  }
127
- const pixelsPerTick = rotate ? 28 : 40
128
- return Math.max(2, Math.floor(plotWidth / pixelsPerTick))
129
127
  }
130
128
 
131
- _makeD3Axis(scale, { rotate = false } = {}) {
129
+ // Compute the approximate projected screen length of this axis (for tick density).
130
+ _projectedLength(axisMvp, cw, ch) {
131
+ const { start, end } = axisEndpoints(this._name)
132
+ const s = projectToScreen(start, axisMvp, cw, ch)
133
+ const e = projectToScreen(end, axisMvp, cw, ch)
134
+ if (!s || !e) return 0
135
+ const dx = e[0] - s[0], dy = e[1] - s[1]
136
+ return Math.sqrt(dx * dx + dy * dy)
137
+ }
138
+
139
+ // Returns tick values as an array of numbers.
140
+ _computeTicks(scale, count) {
132
141
  const isLog = typeof scale.base === 'function'
133
- const count = this._tickCount(rotate)
134
- const gen = AXIS_CONSTRUCTORS[this._name](scale).tickFormat(formatTick)
135
142
  if (isLog) {
136
143
  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)
144
+ if (tv !== null) return tv
146
145
  }
147
- return gen
146
+ if (count <= 2) return scale.domain()
147
+ return scale.ticks(count)
148
148
  }
149
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
- }
150
+ // Returns the outward screen-space unit direction [dx, dy] (HTML coords, y down).
151
+ _outwardScreenDir(axisMvp, cw, ch) {
152
+ const { start, end } = axisEndpoints(this._name)
153
+ const mid3D = [(start[0]+end[0])/2, (start[1]+end[1])/2, (start[2]+end[2])/2]
154
+ const ow = AXIS_GEOMETRY[this._name].outward
155
+ const tip3D = [mid3D[0] + ow[0]*0.2, mid3D[1] + ow[1]*0.2, mid3D[2] + ow[2]*0.2]
156
+ const midS = projectToScreen(mid3D, axisMvp, cw, ch)
157
+ const tipS = projectToScreen(tip3D, axisMvp, cw, ch)
158
+ if (!midS || !tipS) return [0, 1]
159
+ const dx = tipS[0] - midS[0], dy = tipS[1] - midS[1]
160
+ const len = Math.sqrt(dx*dx + dy*dy)
161
+ return len > 0.5 ? [dx/len, dy/len] : [0, 1]
162
+ }
180
163
 
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)
164
+ // Greedy screen-space overlap rejection. Returns an array of indices into `ticks`
165
+ // that can be rendered without their label boxes overlapping.
166
+ _visibleTickIndices(ticks, screenPositions, tickLabelAtlas) {
167
+ const accepted = [], boxes = []
168
+ for (let i = 0; i < ticks.length; i++) {
169
+ const sp = screenPositions[i]
170
+ if (!sp) continue
171
+ const label = formatTick(ticks[i])
172
+ const entry = tickLabelAtlas.getEntry(label)
173
+ const pw = entry ? entry.pw : 48, ph = entry ? entry.ph : 16
174
+ const box = [sp[0] - pw/2, sp[1] - ph/2, sp[0] + pw/2, sp[1] + ph/2]
175
+ if (boxes.some(b => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1])) continue
176
+ accepted.push(i)
177
+ boxes.push(box)
196
178
  }
197
-
198
- text.attr("y", yOffset)
179
+ return accepted
199
180
  }
200
181
 
201
182
  /**
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.
183
+ * Render this axis using the shared WebGL draw commands supplied by Plot.
184
+ *
185
+ * @param {object} regl - regl instance
186
+ * @param {Float32Array} axisMvp - MVP that maps model space to full-canvas NDC
187
+ * @param {number} cw - canvas width in pixels
188
+ * @param {number} ch - canvas height in pixels
189
+ * @param {boolean} is3D - enables depth testing (3D) vs always-on-top (2D)
190
+ * @param {TickLabelAtlas} atlas - shared label atlas
191
+ * @param {Function} lineCmd - compiled regl command for axis/tick lines
192
+ * @param {Function} billboardCmd- compiled regl command for label billboards
204
193
  */
205
- render() {
194
+ render(regl, axisMvp, cw, ch, is3D, atlas, lineCmd, billboardCmd) {
206
195
  if (!this.isSpatial) return
207
- const { svg, margin, plotWidth, plotHeight, axisRegistry, currentConfig } = this._plot
196
+ const { axisRegistry, currentConfig } = this._plot
197
+ if (currentConfig?.axes?.[this._name]?.visible === false) return
208
198
  const scale = axisRegistry.getScale(this._name)
209
199
  if (!scale) return
210
200
 
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
201
+ const geom = AXIS_GEOMETRY[this._name]
202
+ const isLog = typeof scale.base === 'function'
203
+ const domain = scale.domain()
204
+ const { start, end } = axisEndpoints(this._name)
205
+ const ow = geom.outward // [ox, oy, oz]
206
+
207
+ // ── 0. Outward screen direction + model-space offset resolution ──────────
208
+ const mid3D = [(start[0]+end[0])/2, (start[1]+end[1])/2, (start[2]+end[2])/2]
209
+ const tip3D = [mid3D[0]+ow[0]*0.2, mid3D[1]+ow[1]*0.2, mid3D[2]+ow[2]*0.2]
210
+ const midS = projectToScreen(mid3D, axisMvp, cw, ch)
211
+ const tipS = projectToScreen(tip3D, axisMvp, cw, ch)
212
+ const outDirVec = (midS && tipS) ? [tipS[0]-midS[0], tipS[1]-midS[1]] : [0, 1]
213
+ const outDirLen = Math.sqrt(outDirVec[0]**2 + outDirVec[1]**2)
214
+ const outDir = outDirLen > 0.5 ? [outDirVec[0]/outDirLen, outDirVec[1]/outDirLen] : [0, 1]
215
+ const pxPerModelUnit = outDirLen / 0.2
216
+
217
+ let tickModelLen, labelModelDist, titleModelDist, titleLineStep
218
+ if (!is3D && midS && pxPerModelUnit > 0) {
219
+ // In 2D: make all offsets proportional to the pixel distance from the axis
220
+ // to the nearest canvas edge (i.e. the margin width in pixels).
221
+ const tx = outDir[0] > 1e-6 ? (cw - midS[0]) / outDir[0]
222
+ : outDir[0] < -1e-6 ? -midS[0] / outDir[0]
223
+ : Infinity
224
+ const ty = outDir[1] > 1e-6 ? (ch - midS[1]) / outDir[1]
225
+ : outDir[1] < -1e-6 ? -midS[1] / outDir[1]
226
+ : Infinity
227
+ const distToEdge = Math.max(4, Math.min(tx, ty))
228
+ const scale2d = distToEdge / pxPerModelUnit
229
+ tickModelLen = TICK_LEN_FRAC * scale2d
230
+ labelModelDist = TICK_LABEL_FRAC * scale2d
231
+ titleModelDist = AXIS_TITLE_FRAC * scale2d
232
+ titleLineStep = TITLE_LINE_SPACING_FRAC * scale2d
233
+ } else {
234
+ // In 3D: fixed model-space offsets (original behaviour).
235
+ tickModelLen = TICK_LEN_3D
236
+ labelModelDist = LABEL_DIST_3D
237
+ titleModelDist = LABEL_DIST_3D + TITLE_EXTRA_3D
238
+ titleLineStep = TITLE_LINE_STEP_3D
239
+ }
240
+
241
+ // ── 1. Tick count based on projected screen length ──────────────────────
242
+ const screenLen = this._projectedLength(axisMvp, cw, ch)
243
+ const pxPerTick = geom.dir === 'y' ? 27 : 40
244
+ const tickCount = Math.max(2, Math.floor(screenLen / pxPerTick))
245
+ const ticks = this._computeTicks(scale, tickCount)
246
+
247
+ // ── 2. Build axis line + tick-mark geometry ─────────────────────────────
248
+ // Each pair of consecutive floats = one endpoint of a line segment (primitive: 'lines').
249
+ const lineVerts = []
250
+
251
+ // Main axis line
252
+ lineVerts.push(...start, ...end)
253
+
254
+ // Tick marks
255
+ for (const t of ticks) {
256
+ const n = normaliseValue(t, domain, isLog)
257
+ if (!isFinite(n)) continue
258
+ const pos = axisPosAtN(this._name, n)
259
+ lineVerts.push(
260
+ pos[0], pos[1], pos[2],
261
+ pos[0] + ow[0]*tickModelLen, pos[1] + ow[1]*tickModelLen, pos[2] + ow[2]*tickModelLen,
262
+ )
263
+ }
264
+
265
+ const fullViewport = { x: 0, y: 0, width: cw, height: ch }
266
+
267
+ lineCmd({
268
+ positions: new Float32Array(lineVerts),
269
+ mvp: axisMvp,
270
+ color: [0, 0, 0, 1],
271
+ viewport: fullViewport,
272
+ count: lineVerts.length / 3,
273
+ depthEnable: is3D,
274
+ })
275
+
276
+ // ── 3. Tick labels ──────────────────────────────────────────────────────
277
+ const labels = ticks.map(t => formatTick(t))
278
+
279
+ if (!atlas.texture) return
280
+
281
+ // Compute label anchor positions in model space and project to screen.
282
+ const anchors3D = ticks.map((t) => {
283
+ const n = normaliseValue(t, domain, isLog)
284
+ if (!isFinite(n)) return null
285
+ const pos = axisPosAtN(this._name, n)
286
+ return [pos[0] + ow[0]*labelModelDist, pos[1] + ow[1]*labelModelDist, pos[2] + ow[2]*labelModelDist]
287
+ })
288
+ const screenPositions = anchors3D.map(a => a ? projectToScreen(a, axisMvp, cw, ch) : null)
289
+
290
+ const visIdx = this._visibleTickIndices(ticks, screenPositions, atlas)
291
+
292
+ // Build billboard vertex arrays.
293
+ const aAnchor = [], aOffsetPx = [], aUV = []
294
+
295
+ for (const i of visIdx) {
296
+ const anchor3D = anchors3D[i]
297
+ if (!anchor3D) continue
298
+ const entry = atlas.getEntry(labels[i])
299
+ if (!entry) continue
300
+ const { pw, ph, u, v, uw, vh } = entry
301
+ const hw = pw / 2, hh = ph / 2
302
+ // 6 vertices (2 triangles): TL TR BL TR BR BL
303
+ const corners = [
304
+ [-hw, -hh, u, v ],
305
+ [+hw, -hh, u+uw, v ],
306
+ [-hw, +hh, u, v+vh],
307
+ [+hw, -hh, u+uw, v ],
308
+ [+hw, +hh, u+uw, v+vh],
309
+ [-hw, +hh, u, v+vh],
310
+ ]
311
+ for (const [ox, oy, tu, tv] of corners) {
312
+ aAnchor.push(...anchor3D)
313
+ aOffsetPx.push(ox, oy)
314
+ aUV.push(tu, tv)
315
+ }
227
316
  }
228
317
 
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)")
318
+ if (aAnchor.length > 0) {
319
+ billboardCmd({
320
+ anchors: new Float32Array(aAnchor),
321
+ offsetsPx: new Float32Array(aOffsetPx),
322
+ uvs: new Float32Array(aUV),
323
+ mvp: axisMvp,
324
+ canvasSize: [cw, ch],
325
+ atlas: atlas.texture,
326
+ viewport: fullViewport,
327
+ count: aAnchor.length / 3,
328
+ depthEnable: is3D,
329
+ })
249
330
  }
250
331
 
251
- this._renderLabel(g, availableMargin)
332
+ // ── 4. Axis title ───────────────────────────────────────────────────────
333
+ const qk = axisRegistry.axisQuantityKinds[this._name]
334
+ if (!qk) return
335
+ const axisConfig = currentConfig?.axes?.[this._name] ?? {}
336
+ const unitLabel = axisConfig.label ?? getAxisQuantityKind(qk).label
337
+ if (!unitLabel) return
338
+
339
+ const titleLines = String(unitLabel).split('\n')
340
+
341
+ // Place title at axis midpoint + larger outward offset
342
+ const titleAnchorBase = [
343
+ mid3D[0] + ow[0] * titleModelDist,
344
+ mid3D[1] + ow[1] * titleModelDist,
345
+ mid3D[2] + ow[2] * titleModelDist,
346
+ ]
347
+
348
+ const titleAnchor = [], titleOffsets = [], titleUVs = []
349
+ let lineOffset = 0
350
+ for (const line of titleLines) {
351
+ const entry = atlas.getEntry(line)
352
+ if (!entry) continue
353
+ const { pw, ph, u, v, uw, vh } = entry
354
+ const hw = pw / 2, hh = ph / 2
355
+ // Shift successive lines along the outward screen direction
356
+ const anchor3D = [
357
+ titleAnchorBase[0] + ow[0]*lineOffset,
358
+ titleAnchorBase[1] + ow[1]*lineOffset,
359
+ titleAnchorBase[2] + ow[2]*lineOffset,
360
+ ]
361
+ const rawCorners = [
362
+ [-hw, -hh, u, v ],
363
+ [+hw, -hh, u+uw, v ],
364
+ [-hw, +hh, u, v+vh],
365
+ [+hw, -hh, u+uw, v ],
366
+ [+hw, +hh, u+uw, v+vh],
367
+ [-hw, +hh, u, v+vh],
368
+ ]
369
+ // Rotate the title 90° CW when the axis is more vertical than horizontal
370
+ // in screen space (works for 2D and 3D). Rotation: (ox, oy) → (oy, -ox)
371
+ const sStart = projectToScreen(start, axisMvp, cw, ch)
372
+ const sEnd = projectToScreen(end, axisMvp, cw, ch)
373
+ const axisDx = sEnd ? sEnd[0] - sStart[0] : 0
374
+ const axisDy = sEnd ? sEnd[1] - sStart[1] : 0
375
+ const isVertical = Math.abs(axisDy) > Math.abs(axisDx)
376
+ const corners = isVertical
377
+ ? rawCorners.map(([ox, oy, tu, tv]) => [oy, -ox, tu, tv])
378
+ : rawCorners
379
+ for (const [ox, oy, tu, tv] of corners) {
380
+ titleAnchor.push(...anchor3D)
381
+ titleOffsets.push(ox, oy)
382
+ titleUVs.push(tu, tv)
383
+ }
384
+ lineOffset += titleLineStep // shift next line further out in model space
385
+ }
386
+
387
+ if (titleAnchor.length > 0) {
388
+ billboardCmd({
389
+ anchors: new Float32Array(titleAnchor),
390
+ offsetsPx: new Float32Array(titleOffsets),
391
+ uvs: new Float32Array(titleUVs),
392
+ mvp: axisMvp,
393
+ canvasSize: [cw, ch],
394
+ atlas: atlas.texture,
395
+ viewport: fullViewport,
396
+ count: titleAnchor.length / 3,
397
+ depthEnable: is3D,
398
+ })
399
+ }
252
400
  }
253
401
  }
@@ -16,16 +16,20 @@ export function linkAxes(axis1, axis2) {
16
16
  throw new Error(`Cannot link axes with incompatible quantity kinds: ${qk1} vs ${qk2}`)
17
17
  }
18
18
 
19
- const cb1 = (domain) => axis2.setDomain(domain)
20
- const cb2 = (domain) => axis1.setDomain(domain)
19
+ const cb1 = (domain) => axis2.setDomain(domain, { sourcePlot: axis1._plot })
20
+ const cb2 = (domain) => axis1.setDomain(domain, { sourcePlot: axis2._plot })
21
21
 
22
22
  axis1.subscribe(cb1)
23
23
  axis2.subscribe(cb2)
24
+ axis1._linkedAxes.add(axis2)
25
+ axis2._linkedAxes.add(axis1)
24
26
 
25
27
  return {
26
28
  unlink() {
27
29
  axis1.unsubscribe(cb1)
28
30
  axis2.unsubscribe(cb2)
31
+ axis1._linkedAxes.delete(axis2)
32
+ axis2._linkedAxes.delete(axis1)
29
33
  }
30
34
  }
31
35
  }