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 +4 -2
- package/package.json +3 -1
- package/src/AxisRegistry.js +2 -2
- package/src/Data.js +67 -0
- package/src/EpsgUtils.js +123 -0
- package/src/Layer.js +2 -1
- package/src/LayerType.js +5 -2
- package/src/Plot.js +1 -1
- package/src/ScatterLayer.js +45 -18
- package/src/TileLayer.js +708 -0
- package/src/index.js +3 -0
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
|
-
- 📏
|
|
17
|
-
-
|
|
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.
|
|
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
|
|
package/src/AxisRegistry.js
CHANGED
|
@@ -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
|
+
}
|
package/src/EpsgUtils.js
ADDED
|
@@ -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
|
}
|
package/src/ScatterLayer.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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, [
|
|
118
|
+
attributes: { x, y, [vQK]: v },
|
|
97
119
|
uniforms: {},
|
|
120
|
+
domains,
|
|
98
121
|
nameMap: {
|
|
99
|
-
[
|
|
100
|
-
[`colorscale_${
|
|
101
|
-
[`color_range_${
|
|
102
|
-
[`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
|
})
|
package/src/TileLayer.js
ADDED
|
@@ -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"
|