gladly-plot 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,8 +13,10 @@ Gladly combines WebGL rendering (via regl) with D3.js for interactive axes and z
13
13
  - 🎯 Interactive multi-axis support (up to 4 axes)
14
14
  - 🔍 Zoom and pan interactions
15
15
  - 🧩 Extensible layer type registry
16
- - 📏 Unit-aware axis management
17
- - ~250 lines of focused source code
16
+ - 📏 Quantity and unit-aware axis management
17
+ - 🎨 Supports all standard colorscales
18
+ - 🔗 Subplot axis linking
19
+ - 🌈 Axis to coloring or filtering linking
18
20
 
19
21
  ## Documentation
20
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gladly-plot",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "GPU-powered multi-axis plotting library with regl + d3",
5
5
  "type": "module",
6
6
 
@@ -18,6 +18,8 @@
18
18
 
19
19
  "dependencies": {
20
20
  "d3": "^7.8.5",
21
+ "proj4": "^2.15.0",
22
+ "projnames": "^0.0.2",
21
23
  "regl": "^2.1.0"
22
24
  },
23
25
 
@@ -16,9 +16,9 @@ export class AxisRegistry {
16
16
  }
17
17
 
18
18
  ensureAxis(axisName, axisQuantityKind, scaleOverride) {
19
- if (!AXES.includes(axisName)) throw `Unknown axis ${axisName}`
19
+ if (!AXES.includes(axisName)) throw new Error(`Unknown axis ${axisName}`)
20
20
  if (this.axisQuantityKinds[axisName] && this.axisQuantityKinds[axisName] !== axisQuantityKind)
21
- throw `Axis quantity kind mismatch on axis ${axisName}: ${this.axisQuantityKinds[axisName]} vs ${axisQuantityKind}`
21
+ throw new Error(`Axis quantity kind mismatch on axis ${axisName}: ${this.axisQuantityKinds[axisName]} vs ${axisQuantityKind}`)
22
22
 
23
23
  if (!this.scales[axisName]) {
24
24
  const quantityKindDef = getAxisQuantityKind(axisQuantityKind)
package/src/Data.js ADDED
@@ -0,0 +1,67 @@
1
+ export class Data {
2
+ constructor(raw) {
3
+ raw = raw ?? {}
4
+ // Columnar format: { data: {col: Float32Array}, quantity_kinds?: {...}, domains?: {...} }
5
+ // Detected by the presence of a top-level `data` property that is a plain object.
6
+ if (raw.data != null && typeof raw.data === 'object' && !(raw.data instanceof Float32Array)) {
7
+ this._columnar = true
8
+ this._data = raw.data
9
+ this._quantityKinds = raw.quantity_kinds ?? {}
10
+ this._rawDomains = raw.domains ?? {}
11
+ } else {
12
+ this._columnar = false
13
+ this._raw = raw
14
+ }
15
+ }
16
+
17
+ static wrap(data) {
18
+ if (data != null && typeof data.columns === 'function' && typeof data.getData === 'function') {
19
+ return data
20
+ }
21
+ return new Data(data)
22
+ }
23
+
24
+ _entry(col) {
25
+ if (this._columnar) {
26
+ const rawDomain = this._rawDomains[col]
27
+ let domain
28
+ if (Array.isArray(rawDomain)) {
29
+ domain = [rawDomain[0], rawDomain[1]]
30
+ } else if (rawDomain && typeof rawDomain === 'object') {
31
+ domain = [rawDomain.min, rawDomain.max]
32
+ }
33
+ return { data: this._data[col], quantityKind: this._quantityKinds[col], domain }
34
+ }
35
+
36
+ const v = this._raw[col]
37
+ if (v instanceof Float32Array) {
38
+ return { data: v, quantityKind: undefined, domain: undefined }
39
+ }
40
+ if (v && typeof v === 'object') {
41
+ let domain
42
+ if (Array.isArray(v.domain)) {
43
+ domain = [v.domain[0], v.domain[1]]
44
+ } else if (v.domain && typeof v.domain === 'object') {
45
+ domain = [v.domain.min, v.domain.max]
46
+ }
47
+ return { data: v.data, quantityKind: v.quantity_kind, domain }
48
+ }
49
+ return { data: undefined, quantityKind: undefined, domain: undefined }
50
+ }
51
+
52
+ columns() {
53
+ return this._columnar ? Object.keys(this._data) : Object.keys(this._raw)
54
+ }
55
+
56
+ getData(col) {
57
+ return this._entry(col).data
58
+ }
59
+
60
+ getQuantityKind(col) {
61
+ return this._entry(col).quantityKind
62
+ }
63
+
64
+ getDomain(col) {
65
+ return this._entry(col).domain
66
+ }
67
+ }
@@ -0,0 +1,123 @@
1
+ import proj4 from 'proj4'
2
+ import { byEpsg } from 'projnames'
3
+ import { registerAxisQuantityKind } from './AxisQuantityKindRegistry.js'
4
+
5
+ /**
6
+ * Parse an EPSG CRS string or number to a plain integer code.
7
+ * Accepts: 26911, "26911", "EPSG:26911", "epsg:26911"
8
+ */
9
+ export function parseCrsCode(crs) {
10
+ if (typeof crs === 'number') return crs
11
+ const m = String(crs).match(/(\d+)$/)
12
+ return m ? parseInt(m[1]) : null
13
+ }
14
+
15
+ /** "EPSG:26911" → "epsg_26911_x" */
16
+ export function crsToQkX(crs) {
17
+ return `epsg_${parseCrsCode(crs)}_x`
18
+ }
19
+
20
+ /** "EPSG:26911" → "epsg_26911_y" */
21
+ export function crsToQkY(crs) {
22
+ return `epsg_${parseCrsCode(crs)}_y`
23
+ }
24
+
25
+ /** "epsg_26911_x" or "epsg_26911_y" → 26911, or null if not matching */
26
+ export function qkToEpsgCode(qk) {
27
+ const m = String(qk).match(/^epsg_(\d+)_[xy]$/)
28
+ return m ? parseInt(m[1]) : null
29
+ }
30
+
31
+ /**
32
+ * Register a proj4 CRS definition and auto-register the matching
33
+ * epsg_CODE_x / epsg_CODE_y quantity kinds with labels from projnames.
34
+ * Useful for offline/air-gapped environments where network access is unavailable.
35
+ *
36
+ * @param {number} epsgCode - e.g. 26911
37
+ * @param {string} proj4string - e.g. "+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs"
38
+ */
39
+ export function registerEpsgDef(epsgCode, proj4string) {
40
+ proj4.defs(`EPSG:${epsgCode}`, proj4string)
41
+ _registerQuantityKinds(epsgCode)
42
+ }
43
+
44
+ // ─── Internal auto-fetch machinery ───────────────────────────────────────────
45
+
46
+ // Labels for EPSG codes absent from (or wrong in) the projnames package.
47
+ const EPSG_LABEL_OVERRIDES = {
48
+ 4326: 'WGS 84',
49
+ }
50
+
51
+ // Tracks codes whose quantity kinds have been registered (avoids duplicate work)
52
+ const _registeredQkCodes = new Set()
53
+
54
+ // In-flight fetch promises keyed by code (deduplicates concurrent requests)
55
+ const _pendingFetches = new Map()
56
+
57
+ function _registerQuantityKinds(code) {
58
+ if (_registeredQkCodes.has(code)) return
59
+ _registeredQkCodes.add(code)
60
+ const name = EPSG_LABEL_OVERRIDES[code] ?? byEpsg[code] ?? `EPSG:${code}`
61
+ registerAxisQuantityKind(`epsg_${code}_x`, { label: `${name} X`, scale: 'linear' })
62
+ registerAxisQuantityKind(`epsg_${code}_y`, { label: `${name} Y`, scale: 'linear' })
63
+ }
64
+
65
+ /**
66
+ * Ensure a CRS is defined in proj4 and has quantity kinds registered.
67
+ * - If already known to proj4, only registers quantity kinds (synchronous work).
68
+ * - Otherwise fetches the proj4 string from epsg.io (async).
69
+ * - Concurrent calls for the same code share a single in-flight request.
70
+ *
71
+ * @param {string|number} crs - e.g. "EPSG:26911", 26911
72
+ * @returns {Promise<void>}
73
+ */
74
+ export async function ensureCrsDefined(crs) {
75
+ const code = parseCrsCode(crs)
76
+ if (!code) throw new Error(`Cannot parse CRS code from: ${crs}`)
77
+
78
+ const key = `EPSG:${code}`
79
+
80
+ // Register quantity kinds first — this is always synchronous (projnames lookup)
81
+ _registerQuantityKinds(code)
82
+
83
+ // If proj4 already has this definition (including built-ins 4326 and 3857), we're done
84
+ if (proj4.defs(key)) return
85
+
86
+ // Deduplicate concurrent fetches for the same code
87
+ if (!_pendingFetches.has(code)) {
88
+ const p = fetch(`https://epsg.io/${code}.proj4`)
89
+ .then(res => {
90
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
91
+ return res.text()
92
+ })
93
+ .then(def => {
94
+ proj4.defs(key, def.trim())
95
+ })
96
+ .finally(() => {
97
+ _pendingFetches.delete(code)
98
+ })
99
+ _pendingFetches.set(code, p)
100
+ }
101
+
102
+ try {
103
+ await _pendingFetches.get(code)
104
+ } catch (err) {
105
+ throw new Error(`Failed to fetch proj4 definition for ${key}: ${err.message}`)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Reproject a [x, y] point from one CRS to another.
111
+ * Both CRS strings are parsed via parseCrsCode (accepts "EPSG:N", "N", N).
112
+ * The CRS must already be registered (via registerEpsgDef or ensureCrsDefined).
113
+ *
114
+ * @param {string|number} fromCrs
115
+ * @param {string|number} toCrs
116
+ * @param {[number, number]} point
117
+ * @returns {[number, number]}
118
+ */
119
+ export function reproject(fromCrs, toCrs, point) {
120
+ const from = `EPSG:${parseCrsCode(fromCrs)}`
121
+ const to = `EPSG:${parseCrsCode(toCrs)}`
122
+ return proj4(from, to).forward(point)
123
+ }
package/src/Layer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export class Layer {
2
- constructor({ type, attributes, uniforms, nameMap = {}, domains = {}, lineWidth = 1, primitive = "points", xAxis = "xaxis_bottom", yAxis = "yaxis_left", xAxisQuantityKind, yAxisQuantityKind, colorAxes = [], filterAxes = [], vertexCount = null, instanceCount = null, attributeDivisors = {} }) {
2
+ constructor({ type, attributes, uniforms, nameMap = {}, domains = {}, lineWidth = 1, primitive = "points", xAxis = "xaxis_bottom", yAxis = "yaxis_left", xAxisQuantityKind, yAxisQuantityKind, colorAxes = [], filterAxes = [], vertexCount = null, instanceCount = null, attributeDivisors = {}, blend = null }) {
3
3
  // Validate that all attributes are typed arrays
4
4
  for (const [key, value] of Object.entries(attributes)) {
5
5
  if (!(value instanceof Float32Array)) {
@@ -39,5 +39,6 @@ export class Layer {
39
39
  this.vertexCount = vertexCount
40
40
  this.instanceCount = instanceCount
41
41
  this.attributeDivisors = attributeDivisors
42
+ this.blend = blend
42
43
  }
43
44
  }
package/src/LayerType.js CHANGED
@@ -22,7 +22,7 @@ export class LayerType {
22
22
  // Optional dynamic resolver — overrides statics wherever it returns a non-undefined value
23
23
  getAxisConfig,
24
24
  // GPU rendering
25
- vert, frag, schema, createLayer
25
+ vert, frag, schema, createLayer, createDrawCommand
26
26
  }) {
27
27
  this.name = name
28
28
  // Static declarations stored as-is (undefined = not declared)
@@ -38,6 +38,7 @@ export class LayerType {
38
38
  if (schema) this._schema = schema
39
39
  if (createLayer) this._createLayer = createLayer
40
40
  if (getAxisConfig) this._getAxisConfig = getAxisConfig
41
+ if (createDrawCommand) this.createDrawCommand = createDrawCommand
41
42
  }
42
43
 
43
44
  createDrawCommand(regl, layer) {
@@ -100,7 +101,8 @@ export class LayerType {
100
101
  viewport: regl.prop("viewport"),
101
102
  primitive: layer.primitive,
102
103
  lineWidth: layer.lineWidth,
103
- count: regl.prop("count")
104
+ count: regl.prop("count"),
105
+ ...(layer.blend ? { blend: layer.blend } : {})
104
106
  }
105
107
 
106
108
  if (layer.instanceCount !== null) {
@@ -158,6 +160,7 @@ export class LayerType {
158
160
  vertexCount: gpuConfig.vertexCount ?? null,
159
161
  instanceCount: gpuConfig.instanceCount ?? null,
160
162
  attributeDivisors: gpuConfig.attributeDivisors ?? {},
163
+ blend: gpuConfig.blend ?? null,
161
164
  xAxis: axisConfig.xAxis,
162
165
  yAxis: axisConfig.yAxis,
163
166
  xAxisQuantityKind: axisConfig.xAxisQuantityKind,
package/src/Plot.js CHANGED
@@ -385,7 +385,7 @@ export class Plot {
385
385
 
386
386
  // Create one draw command per GPU config returned by the layer type.
387
387
  for (const layer of layerType.createLayer(parameters, data)) {
388
- layer.draw = layer.type.createDrawCommand(this.regl, layer)
388
+ layer.draw = layer.type.createDrawCommand(this.regl, layer, this)
389
389
  this.layers.push(layer)
390
390
  }
391
391
  }
@@ -1,18 +1,20 @@
1
1
  import { LayerType } from "./LayerType.js"
2
2
  import { AXES } from "./AxisRegistry.js"
3
3
  import { registerLayerType } from "./LayerTypeRegistry.js"
4
+ import { Data } from "./Data.js"
4
5
 
5
6
  export const scatterLayerType = new LayerType({
6
7
  name: "scatter",
7
8
 
8
- getAxisConfig: function(parameters) {
9
+ getAxisConfig: function(parameters, data) {
10
+ const d = Data.wrap(data)
9
11
  const { xData, yData, vData, xAxis, yAxis } = parameters
10
12
  return {
11
13
  xAxis,
12
- xAxisQuantityKind: xData,
14
+ xAxisQuantityKind: d.getQuantityKind(xData) ?? xData,
13
15
  yAxis,
14
- yAxisQuantityKind: yData,
15
- colorAxisQuantityKinds: [vData],
16
+ yAxisQuantityKind: d.getQuantityKind(yData) ?? yData,
17
+ colorAxisQuantityKinds: [d.getQuantityKind(vData) ?? vData],
16
18
  }
17
19
  },
18
20
 
@@ -41,11 +43,13 @@ export const scatterLayerType = new LayerType({
41
43
  uniform float color_scale_type;
42
44
  varying float value;
43
45
  void main() {
44
- gl_FragColor = map_color_s(colorscale, color_range, value, color_scale_type);
46
+ float t = clamp((value - color_range.x) / (color_range.y - color_range.x), 0.0, 1.0);
47
+ vec4 color = map_color_s(colorscale, color_range, value, color_scale_type);
48
+ gl_FragColor = vec4(color.rgb, t);
45
49
  }
46
50
  `,
47
51
  schema: (data) => {
48
- const dataProperties = data ? Object.keys(data) : []
52
+ const dataProperties = Data.wrap(data).columns()
49
53
  return {
50
54
  $schema: "https://json-schema.org/draft/2020-12/schema",
51
55
  type: "object",
@@ -76,31 +80,54 @@ export const scatterLayerType = new LayerType({
76
80
  enum: AXES.filter(a => a.includes("y")),
77
81
  default: "yaxis_left",
78
82
  description: "Which y-axis to use for this layer"
83
+ },
84
+ alphaBlend: {
85
+ type: "boolean",
86
+ default: false,
87
+ description: "Map the normalized color value to alpha so low values fade to transparent"
79
88
  }
80
89
  },
81
90
  required: ["xData", "yData", "vData"]
82
91
  }
83
92
  },
84
93
  createLayer: function(parameters, data) {
85
- const { xData, yData, vData } = parameters
94
+ const d = Data.wrap(data)
95
+ const { xData, yData, vData, alphaBlend = false } = parameters
96
+
97
+ const xQK = d.getQuantityKind(xData) ?? xData
98
+ const yQK = d.getQuantityKind(yData) ?? yData
99
+ const vQK = d.getQuantityKind(vData) ?? vData
100
+
101
+ const x = d.getData(xData)
102
+ const y = d.getData(yData)
103
+ const v = d.getData(vData)
86
104
 
87
- const x = data[xData]
88
- const y = data[yData]
89
- const v = data[vData]
105
+ if (!x) throw new Error(`Data column '${xData}' not found`)
106
+ if (!y) throw new Error(`Data column '${yData}' not found`)
107
+ if (!v) throw new Error(`Data column '${vData}' not found`)
90
108
 
91
- if (!x) throw new Error(`Data property '${xData}' not found in data object`)
92
- if (!y) throw new Error(`Data property '${yData}' not found in data object`)
93
- if (!v) throw new Error(`Data property '${vData}' not found in data object`)
109
+ const domains = {}
110
+ const xDomain = d.getDomain(xData)
111
+ const yDomain = d.getDomain(yData)
112
+ const vDomain = d.getDomain(vData)
113
+ if (xDomain) domains[xQK] = xDomain
114
+ if (yDomain) domains[yQK] = yDomain
115
+ if (vDomain) domains[vQK] = vDomain
94
116
 
95
117
  return [{
96
- attributes: { x, y, [vData]: v },
118
+ attributes: { x, y, [vQK]: v },
97
119
  uniforms: {},
120
+ domains,
98
121
  nameMap: {
99
- [vData]: 'color_data',
100
- [`colorscale_${vData}`]: 'colorscale',
101
- [`color_range_${vData}`]: 'color_range',
102
- [`color_scale_type_${vData}`]: 'color_scale_type',
122
+ [vQK]: 'color_data',
123
+ [`colorscale_${vQK}`]: 'colorscale',
124
+ [`color_range_${vQK}`]: 'color_range',
125
+ [`color_scale_type_${vQK}`]: 'color_scale_type',
103
126
  },
127
+ blend: alphaBlend ? {
128
+ enable: true,
129
+ func: { srcRGB: 'src alpha', dstRGB: 'one minus src alpha', srcAlpha: 0, dstAlpha: 1 },
130
+ } : null,
104
131
  }]
105
132
  }
106
133
  })
@@ -0,0 +1,708 @@
1
+ import proj4 from 'proj4'
2
+ import { LayerType } from './LayerType.js'
3
+ import { AXES } from './AxisRegistry.js'
4
+ import { registerLayerType } from './LayerTypeRegistry.js'
5
+ import { parseCrsCode, crsToQkX, crsToQkY, ensureCrsDefined } from './EpsgUtils.js'
6
+
7
+ // ─── Tile math (standard Web Mercator / "slippy map" grid) ────────────────────
8
+
9
+ const EARTH_RADIUS = 6378137 // metres (WGS84 semi-major axis)
10
+ const MERC_MAX = Math.PI * EARTH_RADIUS // ~20037508.34 m
11
+
12
+ function mercXToNorm(x) { return (x + MERC_MAX) / (2 * MERC_MAX) }
13
+ function mercYToNorm(y) { return (1 - y / MERC_MAX) / 2 }
14
+ function normToMercX(nx) { return nx * 2 * MERC_MAX - MERC_MAX }
15
+ function normToMercY(ny) { return (1 - 2 * ny) * MERC_MAX }
16
+
17
+ function mercToTileXY(x, y, z) {
18
+ const scale = Math.pow(2, z)
19
+ return [
20
+ Math.floor(mercXToNorm(x) * scale),
21
+ Math.floor(mercYToNorm(y) * scale),
22
+ ]
23
+ }
24
+
25
+ function tileToMercBbox(tx, ty, z) {
26
+ const scale = Math.pow(2, z)
27
+ return {
28
+ minX: normToMercX(tx / scale),
29
+ maxX: normToMercX((tx + 1) / scale),
30
+ minY: normToMercY((ty + 1) / scale), // tile y is top-down, merc y is bottom-up
31
+ maxY: normToMercY(ty / scale),
32
+ }
33
+ }
34
+
35
+ function optimalZoom(bboxInTileCrs, pixelWidth, pixelHeight, minZoom, maxZoom) {
36
+ // Assume tile grid is Web Mercator: one tile = 256 px at zoom 0.
37
+ // Use the minimum of the x- and y-derived zoom levels so that neither
38
+ // dimension produces an unmanageable number of tiles. A large y-extent
39
+ // with a small x-extent used to drive zoom to maxZoom via x alone, which
40
+ // could generate hundreds-of-thousands of tiles in the y direction.
41
+ const xExtent = Math.abs(bboxInTileCrs.maxX - bboxInTileCrs.minX)
42
+ const yExtent = Math.abs(bboxInTileCrs.maxY - bboxInTileCrs.minY)
43
+ const worldSize = 2 * MERC_MAX
44
+ const zx = xExtent > 0 ? Math.log2((pixelWidth / 256) * (worldSize / xExtent)) : Infinity
45
+ const zy = yExtent > 0 ? Math.log2((pixelHeight / 256) * (worldSize / yExtent)) : Infinity
46
+ const z = Math.min(zx, zy)
47
+ return Math.min(Math.max(Math.floor(isFinite(z) ? z : maxZoom), minZoom), maxZoom)
48
+ }
49
+
50
+ // ─── Source resolution ────────────────────────────────────────────────────────
51
+
52
+ // source is stored as { xyz: {...} } | { wms: {...} } | { wmts: {...} }
53
+ // Normalize to { type: 'xyz'|'wms'|'wmts', ...params } for the rest of the pipeline.
54
+ function resolveSource(source) {
55
+ const type = Object.keys(source).find(k => k === 'xyz' || k === 'wms' || k === 'wmts')
56
+ if (!type) throw new Error(`source must have exactly one key of: xyz, wms, wmts`)
57
+ return { type, ...source[type] }
58
+ }
59
+
60
+ // ─── URL builders ──────────────────────────────────────────────────────────────
61
+
62
+ function buildXyzUrl(source, z, x, y) {
63
+ const subdomains = source.subdomains ?? ['a', 'b', 'c']
64
+ const s = subdomains[Math.abs(x + y) % subdomains.length]
65
+ return source.url
66
+ .replace('{z}', z)
67
+ .replace('{x}', x)
68
+ .replace('{y}', y)
69
+ .replace('{s}', s)
70
+ }
71
+
72
+ function buildWmtsUrl(source, z, x, y) {
73
+ // Support both RESTful template ({TileMatrix}, {TileRow}, {TileCol}) and KVP
74
+ const url = source.url
75
+ if (url.includes('{TileMatrix}') || url.includes('{z}')) {
76
+ return url
77
+ .replace('{TileMatrix}', z).replace('{z}', z)
78
+ .replace('{TileRow}', y).replace('{y}', y)
79
+ .replace('{TileCol}', x).replace('{x}', x)
80
+ }
81
+ // KVP style
82
+ const params = new URLSearchParams({
83
+ SERVICE: 'WMTS',
84
+ REQUEST: 'GetTile',
85
+ VERSION: '1.0.0',
86
+ LAYER: source.layer,
87
+ STYLE: source.style ?? 'default',
88
+ FORMAT: source.format ?? 'image/png',
89
+ TILEMATRIXSET: source.tileMatrixSet ?? 'WebMercatorQuad',
90
+ TILEMATRIX: z,
91
+ TILEROW: y,
92
+ TILECOL: x,
93
+ })
94
+ return `${source.url}?${params}`
95
+ }
96
+
97
+ function buildWmsUrl(source, bbox, tileCrs, pixelWidth, pixelHeight) {
98
+ const version = source.version ?? '1.3.0'
99
+ const crsParam = `EPSG:${parseCrsCode(tileCrs)}`
100
+
101
+ // WMS 1.3.0 with geographic CRS (EPSG:4326) swaps axis order: BBOX is minLat,minLon,maxLat,maxLon
102
+ const is13 = version === '1.3.0'
103
+ const epsgCode = parseCrsCode(crsParam)
104
+ // EPSG:4326 and other geographic CRS have swapped axes in WMS 1.3.0
105
+ const swapAxes = is13 && (epsgCode === 4326)
106
+ const bboxStr = swapAxes
107
+ ? `${bbox.minY},${bbox.minX},${bbox.maxY},${bbox.maxX}`
108
+ : `${bbox.minX},${bbox.minY},${bbox.maxX},${bbox.maxY}`
109
+
110
+ const crsKey = is13 ? 'CRS' : 'SRS'
111
+ const params = new URLSearchParams({
112
+ SERVICE: 'WMS',
113
+ VERSION: version,
114
+ REQUEST: 'GetMap',
115
+ LAYERS: source.layers,
116
+ [crsKey]: crsParam,
117
+ BBOX: bboxStr,
118
+ WIDTH: Math.round(pixelWidth),
119
+ HEIGHT: Math.round(pixelHeight),
120
+ FORMAT: source.format ?? 'image/png',
121
+ TRANSPARENT: source.transparent !== false ? 'TRUE' : 'FALSE',
122
+ ...(source.styles ? { STYLES: source.styles } : {}),
123
+ })
124
+ return `${source.url}?${params}`
125
+ }
126
+
127
+ // ─── Tessellated mesh builder ──────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Build a tessellated mesh for one tile.
131
+ *
132
+ * @param {{ minX, maxX, minY, maxY }} tileBbox - bbox in tileCrs
133
+ * @param {string} tileCrs - e.g. "EPSG:3857"
134
+ * @param {string} plotCrs - e.g. "EPSG:26911" (may equal tileCrs)
135
+ * @param {number} N - tessellation grid size (N×N quads)
136
+ * @returns {{ positions: Float32Array, uvs: Float32Array, indices: Uint16Array }}
137
+ */
138
+ function buildTileMesh(tileBbox, tileCrs, plotCrs, N) {
139
+ const sameProj = `EPSG:${parseCrsCode(tileCrs)}` === `EPSG:${parseCrsCode(plotCrs)}`
140
+ const project = sameProj ? null : proj4(`EPSG:${parseCrsCode(tileCrs)}`, `EPSG:${parseCrsCode(plotCrs)}`).forward
141
+
142
+ const numVerts = (N + 1) * (N + 1)
143
+ const positions = new Float32Array(numVerts * 2)
144
+ const uvs = new Float32Array(numVerts * 2)
145
+
146
+ for (let j = 0; j <= N; j++) {
147
+ for (let i = 0; i <= N; i++) {
148
+ const u = i / N
149
+ const v = j / N
150
+ const tileX = tileBbox.minX + u * (tileBbox.maxX - tileBbox.minX)
151
+ const tileY = tileBbox.minY + v * (tileBbox.maxY - tileBbox.minY)
152
+
153
+ let px, py
154
+ if (project) {
155
+ ;[px, py] = project([tileX, tileY])
156
+ } else {
157
+ px = tileX
158
+ py = tileY
159
+ }
160
+
161
+ const vi = j * (N + 1) + i
162
+ positions[vi * 2] = px
163
+ positions[vi * 2 + 1] = py
164
+ uvs[vi * 2] = u
165
+ uvs[vi * 2 + 1] = 1 - v // flip v: tile y=0 is top, GL texture v=0 is bottom
166
+ }
167
+ }
168
+
169
+ // Two CCW triangles per cell: (BL, BR, TR) and (BL, TR, TL)
170
+ const numIndices = N * N * 6
171
+ const indices = new Uint16Array(numIndices)
172
+ let idx = 0
173
+ for (let j = 0; j < N; j++) {
174
+ for (let i = 0; i < N; i++) {
175
+ const bl = j * (N + 1) + i
176
+ const br = bl + 1
177
+ const tl = bl + (N + 1)
178
+ const tr = tl + 1
179
+ indices[idx++] = bl; indices[idx++] = br; indices[idx++] = tr
180
+ indices[idx++] = bl; indices[idx++] = tr; indices[idx++] = tl
181
+ }
182
+ }
183
+
184
+ return { positions, uvs, indices }
185
+ }
186
+
187
+ // ─── TileManager ──────────────────────────────────────────────────────────────
188
+
189
+ const MAX_TILE_CACHE = 50
190
+ const DOMAIN_CHANGE_THRESHOLD = 0.02 // 2% change triggers tile sync
191
+
192
+ class TileManager {
193
+ constructor({ regl, source, tileCrs, plotCrs, tessellation, onLoad }) {
194
+ this.regl = regl
195
+ this.source = source
196
+ this.tileCrs = tileCrs // CRS of the tile service (e.g. "EPSG:3857")
197
+ this.plotCrs = plotCrs // CRS of the plot axes
198
+ this.tessellation = tessellation
199
+ this.onLoad = onLoad
200
+
201
+ this.tiles = new Map() // tileKey → tile entry
202
+ this.accessOrder = [] // LRU tracking
203
+ this._neededKeys = new Set() // keys required by the most recent syncTiles call
204
+
205
+ this._lastXDomain = null
206
+ this._lastYDomain = null
207
+ this._lastViewport = null
208
+
209
+ // Pre-compute the proj4 converter from plotCrs to tileCrs for bbox conversion
210
+ const fromCode = parseCrsCode(plotCrs)
211
+ const toCode = parseCrsCode(tileCrs)
212
+ this._plotToTile = fromCode === toCode
213
+ ? (pt) => pt
214
+ : proj4(`EPSG:${fromCode}`, `EPSG:${toCode}`).forward
215
+ }
216
+
217
+ _domainChanged(xDomain, yDomain, viewport) {
218
+ if (!this._lastXDomain || !this._lastYDomain) return true
219
+ const viewChanged = !!(viewport && this._lastViewport && (
220
+ viewport.width !== this._lastViewport.width ||
221
+ viewport.height !== this._lastViewport.height
222
+ ))
223
+ const dx = Math.abs(xDomain[1] - xDomain[0])
224
+ const dy = Math.abs(yDomain[1] - yDomain[0])
225
+ // When a domain has zero width, relative change is undefined; fall back to
226
+ // exact equality so we don't treat every frame as "changed" (div-by-zero
227
+ // would give Infinity > threshold = true on every call).
228
+ if (dx === 0 || dy === 0) {
229
+ return xDomain[0] !== this._lastXDomain[0] ||
230
+ xDomain[1] !== this._lastXDomain[1] ||
231
+ yDomain[0] !== this._lastYDomain[0] ||
232
+ yDomain[1] !== this._lastYDomain[1] ||
233
+ viewChanged
234
+ }
235
+ const xChange = Math.max(
236
+ Math.abs(xDomain[0] - this._lastXDomain[0]) / dx,
237
+ Math.abs(xDomain[1] - this._lastXDomain[1]) / dx
238
+ )
239
+ const yChange = Math.max(
240
+ Math.abs(yDomain[0] - this._lastYDomain[0]) / dy,
241
+ Math.abs(yDomain[1] - this._lastYDomain[1]) / dy
242
+ )
243
+ return xChange > DOMAIN_CHANGE_THRESHOLD || yChange > DOMAIN_CHANGE_THRESHOLD || viewChanged
244
+ }
245
+
246
+ _plotBboxToTileBbox(xDomain, yDomain) {
247
+ // Reproject all four corners and take the axis-aligned bounding box
248
+ const corners = [
249
+ [xDomain[0], yDomain[0]],
250
+ [xDomain[1], yDomain[0]],
251
+ [xDomain[0], yDomain[1]],
252
+ [xDomain[1], yDomain[1]],
253
+ ].map(pt => this._plotToTile(pt))
254
+
255
+ return {
256
+ minX: Math.min(...corners.map(c => c[0])),
257
+ maxX: Math.max(...corners.map(c => c[0])),
258
+ minY: Math.min(...corners.map(c => c[1])),
259
+ maxY: Math.max(...corners.map(c => c[1])),
260
+ }
261
+ }
262
+
263
+ _computeNeededTiles(xDomain, yDomain, viewport) {
264
+ const tileBbox = this._plotBboxToTileBbox(xDomain, yDomain)
265
+ const source = this.source
266
+
267
+ if (source.type === 'wms') {
268
+ // WMS: one image covering the full viewport
269
+ const wmsUrl = buildWmsUrl(source, tileBbox, this.tileCrs, viewport.width, viewport.height)
270
+ return [{ key: wmsUrl, bbox: tileBbox, url: wmsUrl, type: 'wms' }]
271
+ }
272
+
273
+ // XYZ / WMTS: standard tile grid
274
+ const minZoom = source.minZoom ?? 0
275
+ const maxZoom = source.maxZoom ?? 19
276
+ const z = optimalZoom(tileBbox, viewport.width, viewport.height, minZoom, maxZoom)
277
+
278
+ const [xMin, yMax] = mercToTileXY(tileBbox.minX, tileBbox.minY, z) // note: minY → top tile
279
+ const [xMax, yMin] = mercToTileXY(tileBbox.maxX, tileBbox.maxY, z) // maxY → bottom tile
280
+ // Clamp to valid tile range
281
+ const tileCount = Math.pow(2, z)
282
+ const txMin = Math.max(0, xMin)
283
+ const txMax = Math.min(tileCount - 1, xMax)
284
+ const tyMin = Math.max(0, yMin)
285
+ const tyMax = Math.min(tileCount - 1, yMax)
286
+
287
+ // Safety cap: if the computed tile range is still unreasonably large (e.g.
288
+ // due to a degenerate bbox or an unsupported CRS), bail out rather than
289
+ // allocating millions of objects and crashing the tab.
290
+ const MAX_TILES = 512
291
+ if ((txMax - txMin + 1) * (tyMax - tyMin + 1) > MAX_TILES) {
292
+ console.warn(`[TileLayer] tile range too large (${txMax - txMin + 1}×${tyMax - tyMin + 1} at z=${z}), skipping`)
293
+ return []
294
+ }
295
+
296
+ const tiles = []
297
+ for (let ty = tyMin; ty <= tyMax; ty++) {
298
+ for (let tx = txMin; tx <= txMax; tx++) {
299
+ const url = source.type === 'wmts'
300
+ ? buildWmtsUrl(source, z, tx, ty)
301
+ : buildXyzUrl(source, z, tx, ty)
302
+ const bbox = tileToMercBbox(tx, ty, z)
303
+ tiles.push({ key: `${z}/${tx}/${ty}`, bbox, url, type: source.type })
304
+ }
305
+ }
306
+ return tiles
307
+ }
308
+
309
+ syncTiles(xDomain, yDomain, viewport) {
310
+ if (!this._domainChanged(xDomain, yDomain, viewport)) return
311
+
312
+ this._lastXDomain = xDomain.slice()
313
+ this._lastYDomain = yDomain.slice()
314
+ this._lastViewport = viewport ? { width: viewport.width, height: viewport.height } : null
315
+
316
+ const needed = this._computeNeededTiles(xDomain, yDomain, viewport)
317
+ this._neededKeys = new Set(needed.map(t => t.key))
318
+
319
+ // Start loading tiles not yet in cache
320
+ for (const tileSpec of needed) {
321
+ if (!this.tiles.has(tileSpec.key)) {
322
+ this._loadTile(tileSpec)
323
+ }
324
+ }
325
+ // Eviction is deferred to _loadTile() so old tiles stay visible while new ones load.
326
+ }
327
+
328
+ _evictTile(key) {
329
+ const tile = this.tiles.get(key)
330
+ if (!tile) return
331
+ if (tile.texture) tile.texture.destroy()
332
+ if (tile.posBuffer) tile.posBuffer.destroy()
333
+ if (tile.uvBuffer) tile.uvBuffer.destroy()
334
+ if (tile.elements) tile.elements.destroy()
335
+ this.tiles.delete(key)
336
+ const i = this.accessOrder.indexOf(key)
337
+ if (i >= 0) this.accessOrder.splice(i, 1)
338
+ }
339
+
340
+ async _loadTile(tileSpec) {
341
+ this.tiles.set(tileSpec.key, { status: 'loading' })
342
+ this.accessOrder.push(tileSpec.key)
343
+
344
+ try {
345
+ const img = new Image()
346
+ img.crossOrigin = 'anonymous'
347
+ await new Promise((resolve, reject) => {
348
+ img.onload = resolve
349
+ img.onerror = () => reject(new Error(`Failed to load tile: ${tileSpec.url}`))
350
+ img.src = tileSpec.url
351
+ })
352
+
353
+ // Check we haven't been evicted while loading
354
+ if (!this.tiles.has(tileSpec.key)) return
355
+
356
+ const mesh = buildTileMesh(tileSpec.bbox, this.tileCrs, this.plotCrs, this.tessellation)
357
+ const texture = this.regl.texture({ data: img, flipY: false, min: 'linear', mag: 'linear' })
358
+ const posBuffer = this.regl.buffer(mesh.positions)
359
+ const uvBuffer = this.regl.buffer(mesh.uvs)
360
+ const elements = this.regl.elements({ data: mesh.indices, type: 'uint16' })
361
+
362
+ this.tiles.set(tileSpec.key, {
363
+ status: 'loaded',
364
+ texture,
365
+ posBuffer,
366
+ uvBuffer,
367
+ elements,
368
+ indexCount: mesh.indices.length,
369
+ })
370
+
371
+ // Now that this tile is ready, evict old tiles that are no longer needed.
372
+ // Eviction is done here (not in syncTiles) so superseded tiles stay visible
373
+ // as a fallback while their replacements are still loading.
374
+ const neededKeys = this._neededKeys
375
+ if (this.source.type === 'wms') {
376
+ // WMS covers the whole viewport with one image; always evict non-needed tiles.
377
+ for (const key of [...this.tiles.keys()]) {
378
+ if (key !== tileSpec.key && !neededKeys.has(key)) this._evictTile(key)
379
+ }
380
+ } else if (this.tiles.size > MAX_TILE_CACHE) {
381
+ // XYZ/WMTS: LRU eviction only when the cache grows too large.
382
+ for (const key of this.accessOrder) {
383
+ if (key !== tileSpec.key && !neededKeys.has(key) && this.tiles.has(key)) {
384
+ this._evictTile(key)
385
+ if (this.tiles.size <= MAX_TILE_CACHE) break
386
+ }
387
+ }
388
+ }
389
+
390
+ this.onLoad()
391
+ } catch (err) {
392
+ // Mark as failed so we don't retry endlessly (remove from cache to allow future retry on pan)
393
+ this.tiles.delete(tileSpec.key)
394
+ const i = this.accessOrder.indexOf(tileSpec.key)
395
+ if (i >= 0) this.accessOrder.splice(i, 1)
396
+ console.warn(`[TileLayer] ${err.message}`)
397
+ }
398
+ }
399
+
400
+ get loadedTiles() {
401
+ return [...this.tiles.values()].filter(t => t.status === 'loaded')
402
+ }
403
+
404
+ destroy() {
405
+ for (const key of [...this.tiles.keys()]) {
406
+ this._evictTile(key)
407
+ }
408
+ }
409
+ }
410
+
411
+ // ─── GLSL ─────────────────────────────────────────────────────────────────────
412
+
413
+ const TILE_VERT = `
414
+ precision mediump float;
415
+ attribute vec2 position;
416
+ attribute vec2 uv;
417
+ uniform vec2 xDomain;
418
+ uniform vec2 yDomain;
419
+ uniform float xScaleType;
420
+ uniform float yScaleType;
421
+ varying vec2 vUv;
422
+
423
+ float normalize_axis(float v, vec2 domain, float scaleType) {
424
+ float vt = scaleType > 0.5 ? log(v) : v;
425
+ float d0 = scaleType > 0.5 ? log(domain.x) : domain.x;
426
+ float d1 = scaleType > 0.5 ? log(domain.y) : domain.y;
427
+ return (vt - d0) / (d1 - d0);
428
+ }
429
+
430
+ void main() {
431
+ float nx = normalize_axis(position.x, xDomain, xScaleType);
432
+ float ny = normalize_axis(position.y, yDomain, yScaleType);
433
+ gl_Position = vec4(nx * 2.0 - 1.0, ny * 2.0 - 1.0, 0.0, 1.0);
434
+ vUv = uv;
435
+ }
436
+ `
437
+
438
+ const TILE_FRAG = `
439
+ precision mediump float;
440
+ uniform sampler2D tileTexture;
441
+ uniform float opacity;
442
+ varying vec2 vUv;
443
+
444
+ void main() {
445
+ vec4 color = texture2D(tileTexture, vUv);
446
+ gl_FragColor = vec4(color.rgb, color.a * opacity);
447
+ }
448
+ `
449
+
450
+ // ─── TileLayerType ────────────────────────────────────────────────────────────
451
+
452
+ class TileLayerType extends LayerType {
453
+ constructor() {
454
+ super({ name: 'tile' })
455
+ }
456
+
457
+ schema(_data) {
458
+ return {
459
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
460
+ type: 'object',
461
+ properties: {
462
+ source: {
463
+ type: 'object',
464
+ description: 'Tile source configuration. Exactly one key (xyz, wms, or wmts) must be present.',
465
+ anyOf: [
466
+ {
467
+ title: 'XYZ',
468
+ properties: {
469
+ xyz: {
470
+ type: 'object',
471
+ properties: {
472
+ url: { type: 'string', default: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', description: 'URL template with {z}, {x}, {y}, optional {s}' },
473
+ subdomains: { type: 'array', items: { type: 'string' }, default: ['a', 'b', 'c'], description: 'Subdomain letters for {s}' },
474
+ minZoom: { type: 'integer', default: 0 },
475
+ maxZoom: { type: 'integer', default: 19 },
476
+ },
477
+ required: ['url'],
478
+ default: {
479
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
480
+ subdomains: ['a', 'b', 'c'],
481
+ },
482
+ },
483
+ },
484
+ required: ['xyz'],
485
+ additionalProperties: false,
486
+ },
487
+ {
488
+ title: 'WMS',
489
+ properties: {
490
+ wms: {
491
+ type: 'object',
492
+ properties: {
493
+ url: { type: 'string', default: 'https://gibs.earthdata.nasa.gov/wms/epsg3857/best/wms.cgi', description: 'WMS service base URL' },
494
+ layers: { type: 'string', default: 'BlueMarble_NextGeneration', description: 'Comma-separated layer names' },
495
+ styles: { type: 'string', default: '', description: 'Comma-separated style names (optional)' },
496
+ format: { type: 'string', default: 'image/jpeg' },
497
+ version: { type: 'string', enum: ['1.1.1', '1.3.0'], default: '1.1.1' },
498
+ transparent: { type: 'boolean', default: false },
499
+ },
500
+ required: ['url', 'layers'],
501
+ default: {
502
+ url: 'https://gibs.earthdata.nasa.gov/wms/epsg3857/best/wms.cgi',
503
+ layers: 'BlueMarble_NextGeneration',
504
+ format: 'image/jpeg',
505
+ transparent: false,
506
+ version: '1.1.1',
507
+ },
508
+ },
509
+ },
510
+ required: ['wms'],
511
+ additionalProperties: false,
512
+ },
513
+ {
514
+ title: 'WMTS',
515
+ properties: {
516
+ wmts: {
517
+ type: 'object',
518
+ properties: {
519
+ url: { type: 'string', default: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/WMTS', description: 'WMTS base URL (RESTful template or KVP endpoint)' },
520
+ layer: { type: 'string', default: 'USGSTopo' },
521
+ style: { type: 'string', default: 'default' },
522
+ format: { type: 'string', default: 'image/jpeg' },
523
+ tileMatrixSet: { type: 'string', default: 'GoogleMapsCompatible' },
524
+ minZoom: { type: 'integer', default: 0 },
525
+ maxZoom: { type: 'integer', default: 19 },
526
+ },
527
+ required: ['url', 'layer'],
528
+ default: {
529
+ url: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/WMTS',
530
+ layer: 'USGSTopo',
531
+ tileMatrixSet: 'GoogleMapsCompatible',
532
+ format: 'image/jpeg',
533
+ },
534
+ },
535
+ },
536
+ required: ['wmts'],
537
+ additionalProperties: false,
538
+ },
539
+ ],
540
+ default: {
541
+ xyz: {
542
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
543
+ subdomains: ['a', 'b', 'c'],
544
+ },
545
+ },
546
+ },
547
+ tileCrs: {
548
+ type: 'string',
549
+ default: 'EPSG:3857',
550
+ description: 'CRS of the tile service. For XYZ/WMTS this is the tile grid CRS; for WMS this becomes the CRS/SRS parameter in GetMap requests. Defaults to EPSG:3857 (Web Mercator).',
551
+ examples: ['EPSG:3857', 'EPSG:4326'],
552
+ },
553
+ plotCrs: {
554
+ type: 'string',
555
+ description: 'CRS of the plot axes (e.g. "EPSG:26911"). Defaults to tileCrs (no reprojection).',
556
+ },
557
+ tessellation: {
558
+ type: 'integer',
559
+ default: 8,
560
+ minimum: 1,
561
+ description: 'Grid resolution (N×N quads per tile) for reprojection accuracy.',
562
+ },
563
+ opacity: {
564
+ type: 'number',
565
+ default: 1.0,
566
+ minimum: 0,
567
+ maximum: 1,
568
+ },
569
+ xAxis: { type: 'string', enum: AXES.filter(a => a.includes('x')), default: 'xaxis_bottom' },
570
+ yAxis: { type: 'string', enum: AXES.filter(a => a.includes('y')), default: 'yaxis_left' },
571
+ },
572
+ required: ['source'],
573
+ }
574
+ }
575
+
576
+ resolveAxisConfig(parameters, _data) {
577
+ const {
578
+ xAxis = 'xaxis_bottom',
579
+ yAxis = 'yaxis_left',
580
+ plotCrs,
581
+ tileCrs,
582
+ } = parameters
583
+ const effectiveTileCrs = tileCrs ?? 'EPSG:3857'
584
+ const effectivePlotCrs = plotCrs ?? effectiveTileCrs
585
+ return {
586
+ xAxis,
587
+ xAxisQuantityKind: crsToQkX(effectivePlotCrs),
588
+ yAxis,
589
+ yAxisQuantityKind: crsToQkY(effectivePlotCrs),
590
+ colorAxisQuantityKinds: [],
591
+ filterAxisQuantityKinds: [],
592
+ }
593
+ }
594
+
595
+ createLayer(parameters, _data) {
596
+ const {
597
+ xAxis = 'xaxis_bottom',
598
+ yAxis = 'yaxis_left',
599
+ plotCrs,
600
+ tileCrs,
601
+ } = parameters
602
+ const effectiveTileCrs = tileCrs ?? 'EPSG:3857'
603
+ const effectivePlotCrs = plotCrs ?? effectiveTileCrs
604
+
605
+ // Return a plain object: compatible with the render loop but no Float32Array required.
606
+ return [{
607
+ type: this,
608
+ xAxis,
609
+ yAxis,
610
+ xAxisQuantityKind: crsToQkX(effectivePlotCrs),
611
+ yAxisQuantityKind: crsToQkY(effectivePlotCrs),
612
+ colorAxes: [],
613
+ filterAxes: [],
614
+ vertexCount: 0,
615
+ instanceCount: null,
616
+ attributes: {},
617
+ domains: {},
618
+ uniforms: {},
619
+ parameters,
620
+ }]
621
+ }
622
+
623
+ createDrawCommand(regl, layer, plot) {
624
+ const {
625
+ source: sourceSpec,
626
+ tileCrs,
627
+ plotCrs,
628
+ tessellation = 8,
629
+ opacity = 1.0,
630
+ } = layer.parameters
631
+ const source = resolveSource(sourceSpec)
632
+ const effectiveTileCrs = tileCrs ?? 'EPSG:3857'
633
+ const effectivePlotCrs = plotCrs ?? effectiveTileCrs
634
+
635
+ // Create the regl draw command immediately — it doesn't need CRS info
636
+ const drawTile = regl({
637
+ vert: TILE_VERT,
638
+ frag: TILE_FRAG,
639
+ attributes: {
640
+ position: regl.prop('posBuffer'),
641
+ uv: regl.prop('uvBuffer'),
642
+ },
643
+ elements: regl.prop('elements'),
644
+ uniforms: {
645
+ xDomain: regl.prop('xDomain'),
646
+ yDomain: regl.prop('yDomain'),
647
+ xScaleType: regl.prop('xScaleType'),
648
+ yScaleType: regl.prop('yScaleType'),
649
+ tileTexture: regl.prop('texture'),
650
+ opacity: opacity,
651
+ },
652
+ viewport: regl.prop('viewport'),
653
+ blend: {
654
+ enable: true,
655
+ func: { src: 'src alpha', dst: 'one minus src alpha' },
656
+ },
657
+ depth: { enable: false },
658
+ })
659
+
660
+ // TileManager is created once both CRS definitions are ready.
661
+ // Renders nothing until then; scheduleRender() triggers a repaint once ready.
662
+ let tileManager = null
663
+
664
+ Promise.all([
665
+ ensureCrsDefined(effectiveTileCrs),
666
+ ensureCrsDefined(effectivePlotCrs),
667
+ ]).then(() => {
668
+ try {
669
+ tileManager = new TileManager({
670
+ regl,
671
+ source,
672
+ tileCrs: effectiveTileCrs,
673
+ plotCrs: effectivePlotCrs,
674
+ tessellation,
675
+ onLoad: () => plot.scheduleRender(),
676
+ })
677
+ plot.scheduleRender()
678
+ } catch (_) {
679
+ // regl may have been destroyed if the plot was updated before CRS resolved
680
+ }
681
+ }).catch(err => {
682
+ console.error('[TileLayer] CRS initialization failed:', err)
683
+ })
684
+
685
+ return (props) => {
686
+ if (!tileManager) return
687
+ tileManager.syncTiles(props.xDomain, props.yDomain, props.viewport)
688
+ for (const tile of tileManager.loadedTiles) {
689
+ drawTile({
690
+ xDomain: props.xDomain,
691
+ yDomain: props.yDomain,
692
+ xScaleType: props.xScaleType,
693
+ yScaleType: props.yScaleType,
694
+ viewport: props.viewport,
695
+ posBuffer: tile.posBuffer,
696
+ uvBuffer: tile.uvBuffer,
697
+ elements: tile.elements,
698
+ texture: tile.texture,
699
+ })
700
+ }
701
+ }
702
+ }
703
+ }
704
+
705
+ export const tileLayerType = new TileLayerType()
706
+ registerLayerType('tile', tileLayerType)
707
+
708
+ export { TileLayerType }
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { LayerType } from "./LayerType.js"
2
2
  export { Layer } from "./Layer.js"
3
+ export { Data } from "./Data.js"
3
4
  export { AxisRegistry, AXES } from "./AxisRegistry.js"
4
5
  export { ColorAxisRegistry } from "./ColorAxisRegistry.js"
5
6
  export { FilterAxisRegistry, buildFilterGlsl } from "./FilterAxisRegistry.js"
@@ -16,6 +17,8 @@ export { Float } from "./Float.js"
16
17
  export { Filterbar } from "./Filterbar.js"
17
18
  export { filterbarLayerType } from "./FilterbarLayer.js"
18
19
  export { FilterbarFloat } from "./FilterbarFloat.js"
20
+ export { tileLayerType, TileLayerType } from "./TileLayer.js"
21
+ export { registerEpsgDef, parseCrsCode, crsToQkX, crsToQkY, qkToEpsgCode, reproject } from "./EpsgUtils.js"
19
22
 
20
23
  // Register all matplotlib colorscales (side-effect import)
21
24
  import "./MatplotlibColorscales.js"