@fjader/svelte 0.2.1 → 0.2.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.
@@ -3,6 +3,7 @@
3
3
  import { RendererHost } from './RendererHost.svelte';
4
4
  import { getGPUContext } from '../../gpu';
5
5
  import { getCurveWGSL, getCurveResolution } from '../../gpu/CurveShaderLib';
6
+ import { NAN_WGSL } from '../../gpu/NanShaderLib';
6
7
  import { DEFAULT_FILL_COLOR } from '../../scales/color-utils';
7
8
  import type { ChartContext, WebGPUCanvasContext, CurveType } from '../../types';
8
9
 
@@ -159,9 +160,7 @@
159
160
  @group(1) @binding(4) var<storage, read> colorValues: array<f32>;
160
161
  {{FACET_BINDING}}
161
162
 
162
- fn isNaN(v: f32) -> bool {
163
- return v != v;
164
- }
163
+ ${NAN_WGSL}
165
164
 
166
165
  // Map (dimension, boundary) to (x, y) based on orientation
167
166
  fn makePoint(dimVal: f32, boundaryVal: f32) -> vec2<f32> {
@@ -3,6 +3,7 @@
3
3
  import { RendererHost } from './RendererHost.svelte';
4
4
  import { getGPUContext } from '../../gpu';
5
5
  import { getCurveWGSL, getCurveResolution } from '../../gpu/CurveShaderLib';
6
+ import { NAN_WGSL } from '../../gpu/NanShaderLib';
6
7
  import { DEFAULT_STROKE_COLOR } from '../../scales/color-utils';
7
8
  import type { ChartContext, WebGPUCanvasContext, CurveType } from '../../types';
8
9
  import type { M4Decimator } from '../../gpu/decimation/M4Decimator.svelte';
@@ -156,9 +157,7 @@
156
157
  @group(1) @binding(3) var<storage, read> colorValues: array<f32>;
157
158
  {{FACET_BINDING}}
158
159
 
159
- fn isNaN(v: f32) -> bool {
160
- return v != v;
161
- }
160
+ ${NAN_WGSL}
162
161
 
163
162
  const DISCARD_POS = vec4<f32>(2.0, 2.0, 2.0, 1.0);
164
163
 
@@ -3,6 +3,7 @@
3
3
  import { RendererHost } from './RendererHost.svelte';
4
4
  import { getGPUContext } from '../../gpu';
5
5
  import { getCurveWGSL, getCurveResolution } from '../../gpu/CurveShaderLib';
6
+ import { NAN_WGSL } from '../../gpu/NanShaderLib';
6
7
  import { DEFAULT_STROKE_COLOR } from '../../scales/color-utils';
7
8
  import type { ChartContext, CurveType, WebGPUCanvasContext } from '../../types';
8
9
 
@@ -123,7 +124,7 @@
123
124
  // writes; only linear math is applied to them today.
124
125
  @group(1) @binding(3) var<uniform> axisScales: array<ScaleParam, ${MAX_AXES}>;
125
126
 
126
- fn isNaN(v: f32) -> bool { return v != v; }
127
+ ${NAN_WGSL}
127
128
  const DISCARD_POS = vec4<f32>(2.0, 2.0, 2.0, 1.0);
128
129
 
129
130
  fn getAxisOrder(visualIdx: u32) -> u32 {
@@ -29,7 +29,7 @@ export class ArrowDataAccessor {
29
29
  // FAST PATH: Single chunk numeric - try to return underlying buffer directly.
30
30
  // NULL handling: Query-level COALESCE(CAST(expr AS REAL), 'NaN'::REAL)
31
31
  // converts SQL NULLs to the NaN float value, so Arrow columns from Fjader
32
- // queries have nullCount=0 and clean buffers. GPU shaders detect NaN (v != v)
32
+ // queries have nullCount=0 and clean buffers. GPU shaders detect NaN by its bit pattern (NanShaderLib)
33
33
  // and discard those vertices to create gaps in lines/areas.
34
34
  if (!isStringType && col.data.length === 1) {
35
35
  const chunk = col.data[0];
@@ -17,4 +17,4 @@ export declare const COLOR_CONFIG_SLOT_BASE_OFFSET = 32;
17
17
  export declare const NO_SLOT_TABLE = 4294967295;
18
18
  export declare const COLOR_SCALE_STRUCTS = "\nstruct ColorConfig {\n domainMin: f32, // byte 0 \u2014 pre-transformed for non-linear types\n domainMax: f32, // byte 4 \u2014 pre-transformed for non-linear types\n rampOffset: u32, // byte 8\n paletteOffset: u32, // byte 12\n paletteSize: u32, // byte 16\n scaleType: u32, // byte 20 \u2014 scheme rendering type (ordinal/sequential/diverging/cyclical)\n transformType: u32, // byte 24 \u2014 normalization transform (linear/log/symlog/sqrt/pow)\n transformParam: f32, // byte 28 \u2014 symlog constant C, or pow exponent\n slotBase: u32, // byte 32 \u2014 base into identitySlots, or NO_SLOT_TABLE\n _pad0: u32, // byte 36\n _pad1: u32, // byte 40\n _pad2: u32, // byte 44\n}\n\n/**\n * slotBase when this scale's channel already carries a palette index rather\n * than an identity id. Distinct from base 0, which is a real block \u2014 the\n * permanently empty one \u2014 so it cannot be confused with \"not placed\".\n */\nconst NO_SLOT_TABLE: u32 = 0xFFFFFFFFu;\n\nconst COLOR_SCALE_ORDINAL: u32 = 0u;\nconst COLOR_SCALE_SEQUENTIAL: u32 = 1u;\nconst COLOR_SCALE_DIVERGING: u32 = 2u;\nconst COLOR_SCALE_CYCLICAL: u32 = 3u;\n\nconst COLOR_TRANSFORM_LINEAR: u32 = 0u;\nconst COLOR_TRANSFORM_LOG: u32 = 1u;\nconst COLOR_TRANSFORM_SYMLOG: u32 = 2u;\nconst COLOR_TRANSFORM_SQRT: u32 = 3u;\nconst COLOR_TRANSFORM_POW: u32 = 4u;\n";
19
19
  export declare const COLOR_SCALE_BINDINGS = "\n@group(2) @binding(1) var<storage, read> colorConfigs: array<ColorConfig>;\n@group(2) @binding(2) var<storage, read> colorRamps: array<vec4<f32>>;\n@group(2) @binding(3) var<storage, read> ordinalPalettes: array<vec4<f32>>;\n";
20
- export declare const COLOR_SCALE_FUNCTIONS = "\nfn applyColorTransform(config: ColorConfig, value: f32) -> f32 {\n switch config.transformType {\n case COLOR_TRANSFORM_LOG: {\n return log(max(value, 1e-38));\n }\n case COLOR_TRANSFORM_SYMLOG: {\n let c = config.transformParam;\n return sign(value) * log(1.0 + abs(value / c));\n }\n case COLOR_TRANSFORM_SQRT: {\n return sign(value) * sqrt(abs(value));\n }\n case COLOR_TRANSFORM_POW: {\n return sign(value) * pow(abs(value), config.transformParam);\n }\n default: {\n return value;\n }\n }\n}\n\nfn normalizeValue(config: ColorConfig, value: f32) -> f32 {\n let transformed = applyColorTransform(config, value);\n\n let range = config.domainMax - config.domainMin;\n\n if (abs(range) < 0.0001) {\n return 0.5;\n }\n\n let t_raw = (transformed - config.domainMin) / range;\n\n if (config.scaleType == COLOR_SCALE_CYCLICAL) {\n return fract(t_raw);\n }\n\n return clamp(t_raw, 0.0, 1.0);\n}\n\nfn sampleRamp(config: ColorConfig, t: f32) -> vec4<f32> {\n let idx = min(u32(t * 255.0), 255u); // Must match COLOR_RAMP_RESOLUTION (256 stops)\n return colorRamps[config.rampOffset + idx];\n}\n\nfn samplePalette(config: ColorConfig, categoryIndex: u32) -> vec4<f32> {\n // The channel carries an identity id, which is stable and has nothing to do\n // with this scale's palette. Resolve it to this scale's own rank first; the\n // palette is indexed by rank, so the plotted categories take palette colours\n // in order and the wrap below is over the PLOTTED count, not the dim's.\n var idx = categoryIndex;\n if (config.slotBase != NO_SLOT_TABLE) {\n let slot = identitySlot(config.slotBase, categoryIndex);\n if (slot < 0.0) {\n // Not ranked by this scale: a category outside its domain.\n return vec4<f32>(0.0, 0.0, 0.0, 0.0);\n }\n idx = u32(slot);\n }\n return ordinalPalettes[config.paletteOffset + (idx % config.paletteSize)];\n}\n\nfn getColorFromScale(scaleIndex: u32, value: f32, nullColor: vec4<f32>) -> vec4<f32> {\n let config = colorConfigs[scaleIndex];\n\n if (config.scaleType == COLOR_SCALE_ORDINAL) {\n // 0-based indexing: -1 = null, 0+ = valid categories\n if (value < 0.0) {\n return nullColor;\n }\n let catIdx = u32(value);\n return samplePalette(config, catIdx);\n }\n\n if (value != value) {\n return nullColor;\n }\n\n // Log requires strictly positive values \u2014 treat \u22640 as unmappable\n if (config.transformType == COLOR_TRANSFORM_LOG && value <= 0.0) {\n return nullColor;\n }\n\n let t = normalizeValue(config, value);\n return sampleRamp(config, t);\n}\n\nfn getOrdinalColor(scaleIndex: u32, categoryIndex: u32, nullColor: vec4<f32>) -> vec4<f32> {\n // Note: This function expects u32, so caller must handle -1 check before casting\n let config = colorConfigs[scaleIndex];\n return samplePalette(config, categoryIndex);\n}\n\nfn getContinuousColor(scaleIndex: u32, value: f32, nullColor: vec4<f32>) -> vec4<f32> {\n if (value != value) {\n return nullColor;\n }\n let config = colorConfigs[scaleIndex];\n if (config.transformType == COLOR_TRANSFORM_LOG && value <= 0.0) {\n return nullColor;\n }\n let t = normalizeValue(config, value);\n return sampleRamp(config, t);\n}\n";
20
+ export declare const COLOR_SCALE_FUNCTIONS: string;
@@ -1,3 +1,4 @@
1
+ import { isNanExpr } from './NanShaderLib';
1
2
  // src/lib/gpu/ColorScaleShaderLib.ts
2
3
  // ---------------------------------------------------------------------------
3
4
  // Design note: branching vs JIT-compiled shaders for color normalization
@@ -148,7 +149,7 @@ fn getColorFromScale(scaleIndex: u32, value: f32, nullColor: vec4<f32>) -> vec4<
148
149
  return samplePalette(config, catIdx);
149
150
  }
150
151
 
151
- if (value != value) {
152
+ if (${isNanExpr('value')}) {
152
153
  return nullColor;
153
154
  }
154
155
 
@@ -168,7 +169,7 @@ fn getOrdinalColor(scaleIndex: u32, categoryIndex: u32, nullColor: vec4<f32>) ->
168
169
  }
169
170
 
170
171
  fn getContinuousColor(scaleIndex: u32, value: f32, nullColor: vec4<f32>) -> vec4<f32> {
171
- if (value != value) {
172
+ if (${isNanExpr('value')}) {
172
173
  return nullColor;
173
174
  }
174
175
  let config = colorConfigs[scaleIndex];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * NaN tests for WGSL that a shader compiler cannot remove.
3
+ *
4
+ * fjader sends nulls to the GPU as NaN. WGSL lets an implementation assume
5
+ * floats are finite, and Apple's Metal compilers act on that: `v != v` and
6
+ * `!(v == v)` can compile to `false`, so a NaN goes undetected. Whether that
7
+ * happens depends on the GPU generation and on the code around the test, so a
8
+ * check that works on one Mac can be gone on another. /test/nan-probe shows
9
+ * what a given GPU does.
10
+ *
11
+ * These test the bit pattern instead: an exponent of all ones and a non-zero
12
+ * mantissa. That is integer arithmetic, which no float assumption touches.
13
+ *
14
+ * `NAN_BITS` is the quiet NaN written wherever a shader needs to produce one.
15
+ * Writing it as bits into a u32 view of the buffer keeps it exact; producing
16
+ * a NaN through float arithmetic is not guaranteed to.
17
+ */
18
+ /** The quiet NaN fjader writes, as bits. */
19
+ export declare const NAN_BITS_HEX = "0x7FC00000u";
20
+ /**
21
+ * `isNanBits(u)` for a value read through a u32 view, and `isNaN(v)` for one
22
+ * already loaded as f32. Prefer the u32 view where a shader can read the
23
+ * buffer that way: it never loads the NaN as a float at all.
24
+ */
25
+ export declare const NAN_WGSL = "\nconst NAN_BITS: u32 = 0x7FC00000u;\n\nfn isNanBits(u: u32) -> bool {\n\treturn (u & 0x7fffffffu) > 0x7f800000u;\n}\n\nfn isNaN(v: f32) -> bool {\n\treturn isNanBits(bitcast<u32>(v));\n}\n";
26
+ /**
27
+ * The same test as an expression, for shader libraries that are pasted into
28
+ * other shaders and so cannot define functions those shaders may define too.
29
+ */
30
+ export declare function isNanExpr(f32Expr: string): string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * NaN tests for WGSL that a shader compiler cannot remove.
3
+ *
4
+ * fjader sends nulls to the GPU as NaN. WGSL lets an implementation assume
5
+ * floats are finite, and Apple's Metal compilers act on that: `v != v` and
6
+ * `!(v == v)` can compile to `false`, so a NaN goes undetected. Whether that
7
+ * happens depends on the GPU generation and on the code around the test, so a
8
+ * check that works on one Mac can be gone on another. /test/nan-probe shows
9
+ * what a given GPU does.
10
+ *
11
+ * These test the bit pattern instead: an exponent of all ones and a non-zero
12
+ * mantissa. That is integer arithmetic, which no float assumption touches.
13
+ *
14
+ * `NAN_BITS` is the quiet NaN written wherever a shader needs to produce one.
15
+ * Writing it as bits into a u32 view of the buffer keeps it exact; producing
16
+ * a NaN through float arithmetic is not guaranteed to.
17
+ */
18
+ /** The quiet NaN fjader writes, as bits. */
19
+ export const NAN_BITS_HEX = '0x7FC00000u';
20
+ /**
21
+ * `isNanBits(u)` for a value read through a u32 view, and `isNaN(v)` for one
22
+ * already loaded as f32. Prefer the u32 view where a shader can read the
23
+ * buffer that way: it never loads the NaN as a float at all.
24
+ */
25
+ export const NAN_WGSL = /* wgsl */ `
26
+ const NAN_BITS: u32 = ${NAN_BITS_HEX};
27
+
28
+ fn isNanBits(u: u32) -> bool {
29
+ return (u & 0x7fffffffu) > 0x7f800000u;
30
+ }
31
+
32
+ fn isNaN(v: f32) -> bool {
33
+ return isNanBits(bitcast<u32>(v));
34
+ }
35
+ `;
36
+ /**
37
+ * The same test as an expression, for shader libraries that are pasted into
38
+ * other shaders and so cannot define functions those shaders may define too.
39
+ */
40
+ export function isNanExpr(f32Expr) {
41
+ return `((bitcast<u32>(${f32Expr}) & 0x7fffffffu) > 0x7f800000u)`;
42
+ }
@@ -69,7 +69,7 @@ export declare const RESOLVE_IMMEDIATE_SIZE = 12;
69
69
  * hand every dispatch the last one's values. Immediates are pass state, set in
70
70
  * command order, so each dispatch reads its own.
71
71
  */
72
- export declare const RESOLVE_SHADER = "\nrequires immediate_address_space;\n\nstruct Params {\n // Elements in this range. The rest of the buffer is left alone.\n count: u32,\n // 0 -> from, 1 -> to. Shared by every element of the range.\n t: f32,\n // Where the range starts, in all three buffers alike.\n offset: u32,\n};\n\n@group(0) @binding(0) var<storage, read> valueFrom: array<f32>;\n@group(0) @binding(1) var<storage, read> valueTo: array<f32>;\n@group(0) @binding(2) var<storage, read_write> resolved: array<f32>;\nvar<immediate> params: Params;\n\n@compute @workgroup_size(64)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.count) {\n return;\n }\n let j = params.offset + gid.x;\n let a = valueFrom[j];\n let b = valueTo[j];\n // A NaN on either side means one end of this transition is not a number, so\n // there is nothing to interpolate along. Take the destination and be done.\n // Note the test has to be explicit: every comparison against NaN is false, so\n // mix() would silently propagate it instead.\n // Equal ends take b too, so they come out exact on any driver \u2014 see above.\n let takeTo = a == b || a != a || b != b;\n resolved[j] = select(mix(a, b, params.t), b, takeTo);\n}\n";
72
+ export declare const RESOLVE_SHADER: string;
73
73
  /**
74
74
  * The same expression in JS, and the reason it exists.
75
75
  *
@@ -1,3 +1,4 @@
1
+ import { isNanExpr } from './NanShaderLib';
1
2
  /**
2
3
  * The resolve kernel: `resolved[j] = mix(from[j], to[j], t)` over one range.
3
4
  *
@@ -96,10 +97,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
96
97
  let b = valueTo[j];
97
98
  // A NaN on either side means one end of this transition is not a number, so
98
99
  // there is nothing to interpolate along. Take the destination and be done.
99
- // Note the test has to be explicit: every comparison against NaN is false, so
100
- // mix() would silently propagate it instead.
100
+ // Note the test has to be explicit, and on the bits: mix() would silently
101
+ // propagate a NaN, and a compiler may fold a float test like a != a to false.
101
102
  // Equal ends take b too, so they come out exact on any driver — see above.
102
- let takeTo = a == b || a != a || b != b;
103
+ let takeTo = a == b || ${isNanExpr('a')} || ${isNanExpr('b')};
103
104
  resolved[j] = select(mix(a, b, params.t), b, takeTo);
104
105
  }
105
106
  `;
@@ -28,4 +28,4 @@ export declare const MAX_SIZE_SLOTS = 64;
28
28
  * in WGSL, and the shared slot table already relies on the same thing.
29
29
  */
30
30
  export declare const SIZE_SCALE_BINDINGS = "\n@group(2) @binding(4) var<uniform> sizeConfigs: array<SizeConfig, 64>;\n";
31
- export declare const SIZE_SCALE_FUNCTIONS = "\nfn getSizeFromScale(scaleIndex: u32, value: f32) -> f32 {\n let config = sizeConfigs[scaleIndex];\n\n // Handle NaN \u2192 rangeMin\n if (value != value) {\n return config.rangeMin;\n }\n\n let range = config.domainMax - config.domainMin;\n\n // Degenerate domain \u2192 midpoint of range\n if (abs(range) < 0.0001) {\n return (config.rangeMin + config.rangeMax) * 0.5;\n }\n\n // Normalize to [0, 1]\n let t = clamp((value - config.domainMin) / range, 0.0, 1.0);\n\n // Sqrt for area-proportional scaling\n let s = sqrt(t);\n\n // Interpolate to pixel range\n return config.rangeMin + s * (config.rangeMax - config.rangeMin);\n}\n";
31
+ export declare const SIZE_SCALE_FUNCTIONS: string;
@@ -1,3 +1,4 @@
1
+ import { isNanExpr } from './NanShaderLib';
1
2
  // src/lib/gpu/SizeScaleShaderLib.js
2
3
  export const SIZE_SCALE_STRUCTS = /* wgsl */ `
3
4
  struct SizeConfig {
@@ -43,7 +44,7 @@ fn getSizeFromScale(scaleIndex: u32, value: f32) -> f32 {
43
44
  let config = sizeConfigs[scaleIndex];
44
45
 
45
46
  // Handle NaN → rangeMin
46
- if (value != value) {
47
+ if (${isNanExpr('value')}) {
47
48
  return config.rangeMin;
48
49
  }
49
50
 
@@ -27,6 +27,12 @@
27
27
  *
28
28
  * The x scale's params reach passes 1 and 2 as immediates, pushed before each
29
29
  * dispatch, rather than as an index into the global scale table.
30
+ *
31
+ * Every pass reads and writes the data through u32 views. NaN (a null, or an
32
+ * empty bin) is found by its bit pattern and moved as bits, never compared as
33
+ * a float: Metal compilers may fold `x != x` to false (see NanShaderLib), and
34
+ * where they do, compaction keeps the empty bins and the line loses every
35
+ * segment that touches one.
30
36
  */
31
37
  declare const WORKGROUP_SIZE = 256;
32
38
  /** Bytes of immediate data passes 1 and 2 read: one ScaleParam. */
@@ -44,7 +50,7 @@ declare const X_SCALE_IMMEDIATE_SIZE = 16;
44
50
  * Group 0: inputLayout (data dummies + config + bins)
45
51
  * Group 1: controlLayout (control)
46
52
  */
47
- declare const INIT_BINS_SHADER = "\n\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\nstruct M4Bin {\n\tfirstIdx: atomic<u32>,\n\tlastIdx: atomic<u32>,\n\tminYSortable: atomic<u32>,\n\tmaxYSortable: atomic<u32>,\n\tminYIdx: atomic<u32>,\n\tmaxYIdx: atomic<u32>,\n};\n\nfn floatToSortableUint(f: f32) -> u32 {\n\tlet bits = bitcast<u32>(f);\n\treturn select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);\n}\n\n\n@group(0) @binding(4) var<uniform> config: M4Config;\n@group(0) @binding(5) var<storage, read_write> bins: array<M4Bin>;\n@group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\tlet binKey = gid.x;\n\tlet totalBins = config.numSeries * config.numBins;\n\n\t// Thread 0: read JS decision, set instance count\n\tif (binKey == 0u) {\n\t\tlet outputSize = config.numBins * config.numSeries * 4u;\n\t\t// JS sets _pad: 1 = decimate, 2 = passthrough\n\t\tlet shouldDec = config.decimationMode == 1u;\n\t\tatomicStore(&control[4], select(0u, 1u, shouldDec));\n\t\tatomicStore(&control[1], select(0u, outputSize, shouldDec));\n\t}\n\n\t// Always init bins \u2014 cheap and ensures clean state for potential decimation.\n\tif (binKey >= totalBins) { return; }\n\n\tatomicStore(&bins[binKey].firstIdx, 0xFFFFFFFFu);\n\tatomicStore(&bins[binKey].lastIdx, 0u);\n\tatomicStore(&bins[binKey].minYSortable, 0xFFFFFFFFu);\n\tatomicStore(&bins[binKey].maxYSortable, 0u);\n\tatomicStore(&bins[binKey].minYIdx, 0xFFFFFFFFu);\n\tatomicStore(&bins[binKey].maxYIdx, 0xFFFFFFFFu);\n}\n";
53
+ declare const INIT_BINS_SHADER = "\n\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\nstruct M4Bin {\n\tfirstIdx: atomic<u32>,\n\tlastIdx: atomic<u32>,\n\tminYSortable: atomic<u32>,\n\tmaxYSortable: atomic<u32>,\n\tminYIdx: atomic<u32>,\n\tmaxYIdx: atomic<u32>,\n};\n\nfn sortableFromBits(bits: u32) -> u32 {\n\treturn select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);\n}\n\n\nconst NAN_BITS: u32 = 0x7FC00000u;\n\nfn isNanBits(u: u32) -> bool {\n\treturn (u & 0x7fffffffu) > 0x7f800000u;\n}\n\nfn isNaN(v: f32) -> bool {\n\treturn isNanBits(bitcast<u32>(v));\n}\n\n\n\n@group(0) @binding(4) var<uniform> config: M4Config;\n@group(0) @binding(5) var<storage, read_write> bins: array<M4Bin>;\n@group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\tlet binKey = gid.x;\n\tlet totalBins = config.numSeries * config.numBins;\n\n\t// Thread 0: read JS decision, set instance count\n\tif (binKey == 0u) {\n\t\tlet outputSize = config.numBins * config.numSeries * 4u;\n\t\t// JS sets _pad: 1 = decimate, 2 = passthrough\n\t\tlet shouldDec = config.decimationMode == 1u;\n\t\tatomicStore(&control[4], select(0u, 1u, shouldDec));\n\t\tatomicStore(&control[1], select(0u, outputSize, shouldDec));\n\t}\n\n\t// Always init bins \u2014 cheap and ensures clean state for potential decimation.\n\tif (binKey >= totalBins) { return; }\n\n\tatomicStore(&bins[binKey].firstIdx, 0xFFFFFFFFu);\n\tatomicStore(&bins[binKey].lastIdx, 0u);\n\tatomicStore(&bins[binKey].minYSortable, 0xFFFFFFFFu);\n\tatomicStore(&bins[binKey].maxYSortable, 0u);\n\tatomicStore(&bins[binKey].minYIdx, 0xFFFFFFFFu);\n\tatomicStore(&bins[binKey].maxYIdx, 0xFFFFFFFFu);\n}\n";
48
54
  /**
49
55
  * Pass 1: Bin each point by scaled x position.
50
56
  *
@@ -55,7 +61,7 @@ declare const INIT_BINS_SHADER = "\n\nstruct M4Config {\n\tpointCount: u32,\n\tn
55
61
  * Group 1: controlLayout (control)
56
62
  * Immediates: the x scale (ScaleParam)
57
63
  */
58
- declare const BIN_POINTS_SHADER = "\nrequires immediate_address_space;\n\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\nstruct M4Bin {\n\tfirstIdx: atomic<u32>,\n\tlastIdx: atomic<u32>,\n\tminYSortable: atomic<u32>,\n\tmaxYSortable: atomic<u32>,\n\tminYIdx: atomic<u32>,\n\tmaxYIdx: atomic<u32>,\n};\n\nfn floatToSortableUint(f: f32) -> u32 {\n\tlet bits = bitcast<u32>(f);\n\treturn select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);\n}\n\n\nstruct ScaleParam {\n\tp1: f32,\n\tp2: f32,\n\tp3: f32,\n\tp4: f32,\n};\n\nvar<immediate> xScale: ScaleParam;\n\n\n@group(0) @binding(0) var<storage, read> xValues: array<f32>;\n@group(0) @binding(1) var<storage, read> yValues: array<f32>;\n@group(0) @binding(2) var<storage, read> seriesValues: array<f32>;\n@group(0) @binding(4) var<uniform> config: M4Config;\n@group(0) @binding(5) var<storage, read_write> bins: array<M4Bin>;\n@group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\t// Only bin if decimating\n\tif (atomicLoad(&control[4]) == 0u) { return; }\n\n\tlet idx = gid.x;\n\tif (idx >= config.pointCount) { return; }\n\n\tlet x = xValues[idx];\n\tlet y = yValues[idx];\n\n\t// Skip NaN points\n\tif (x != x || y != y) { return; }\n\n\tlet domainMin = xScale.p1;\n\tlet domainMax = xScale.p2;\n\n\tlet t = (x - domainMin) / (domainMax - domainMin);\n\n\t// Series: 0-indexed from ViewManager (-1 = null/unknown, 0+ = categories)\n\tlet seriesF = seriesValues[idx];\n\tif (seriesF < 0.0 || seriesF != seriesF) { return; }\n\tlet series = u32(seriesF);\n\tif (series >= config.numSeries) { return; }\n\n\t// Bin assignment: boundary bins capture off-screen points for line connectivity.\n\t// Bins 0 and numBins-1 are boundary bins; bins 1..numBins-2 map to viewport pixels.\n\tvar bin: u32;\n\tif (t < 0.0) {\n\t\tbin = 0u;\n\t} else if (t > 1.0) {\n\t\tbin = config.numBins - 1u;\n\t} else {\n\t\tlet innerBins = config.numBins - 2u;\n\t\tbin = 1u + min(u32(floor(t * f32(innerBins))), innerBins - 1u);\n\t}\n\tlet binKey = series * config.numBins + bin;\n\n\tatomicMin(&bins[binKey].firstIdx, idx);\n\tatomicMax(&bins[binKey].lastIdx, idx);\n\n\tlet ySortable = floatToSortableUint(y);\n\tatomicMin(&bins[binKey].minYSortable, ySortable);\n\tatomicMax(&bins[binKey].maxYSortable, ySortable);\n}\n";
64
+ declare const BIN_POINTS_SHADER = "\nrequires immediate_address_space;\n\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\nstruct M4Bin {\n\tfirstIdx: atomic<u32>,\n\tlastIdx: atomic<u32>,\n\tminYSortable: atomic<u32>,\n\tmaxYSortable: atomic<u32>,\n\tminYIdx: atomic<u32>,\n\tmaxYIdx: atomic<u32>,\n};\n\nfn sortableFromBits(bits: u32) -> u32 {\n\treturn select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);\n}\n\n\nconst NAN_BITS: u32 = 0x7FC00000u;\n\nfn isNanBits(u: u32) -> bool {\n\treturn (u & 0x7fffffffu) > 0x7f800000u;\n}\n\nfn isNaN(v: f32) -> bool {\n\treturn isNanBits(bitcast<u32>(v));\n}\n\n\n\nstruct ScaleParam {\n\tp1: f32,\n\tp2: f32,\n\tp3: f32,\n\tp4: f32,\n};\n\nvar<immediate> xScale: ScaleParam;\n\n\n@group(0) @binding(0) var<storage, read> xBits: array<u32>;\n@group(0) @binding(1) var<storage, read> yBits: array<u32>;\n@group(0) @binding(2) var<storage, read> seriesBits: array<u32>;\n@group(0) @binding(4) var<uniform> config: M4Config;\n@group(0) @binding(5) var<storage, read_write> bins: array<M4Bin>;\n@group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\t// Only bin if decimating\n\tif (atomicLoad(&control[4]) == 0u) { return; }\n\n\tlet idx = gid.x;\n\tif (idx >= config.pointCount) { return; }\n\n\t// Skip NaN points, tested on the bits before either becomes a float\n\tlet xb = xBits[idx];\n\tlet yb = yBits[idx];\n\tif (isNanBits(xb) || isNanBits(yb)) { return; }\n\tlet x = bitcast<f32>(xb);\n\n\tlet domainMin = xScale.p1;\n\tlet domainMax = xScale.p2;\n\n\tlet t = (x - domainMin) / (domainMax - domainMin);\n\n\t// Series: 0-indexed from ViewManager (-1 = null/unknown, 0+ = categories)\n\tlet seriesB = seriesBits[idx];\n\tif (isNanBits(seriesB)) { return; }\n\tlet seriesF = bitcast<f32>(seriesB);\n\tif (seriesF < 0.0) { return; }\n\tlet series = u32(seriesF);\n\tif (series >= config.numSeries) { return; }\n\n\t// Bin assignment: boundary bins capture off-screen points for line connectivity.\n\t// Bins 0 and numBins-1 are boundary bins; bins 1..numBins-2 map to viewport pixels.\n\tvar bin: u32;\n\tif (t < 0.0) {\n\t\tbin = 0u;\n\t} else if (t > 1.0) {\n\t\tbin = config.numBins - 1u;\n\t} else {\n\t\tlet innerBins = config.numBins - 2u;\n\t\tbin = 1u + min(u32(floor(t * f32(innerBins))), innerBins - 1u);\n\t}\n\tlet binKey = series * config.numBins + bin;\n\n\tatomicMin(&bins[binKey].firstIdx, idx);\n\tatomicMax(&bins[binKey].lastIdx, idx);\n\n\tlet ySortable = sortableFromBits(yb);\n\tatomicMin(&bins[binKey].minYSortable, ySortable);\n\tatomicMax(&bins[binKey].maxYSortable, ySortable);\n}\n";
59
65
  /**
60
66
  * Pass 2: Find indices of min/max Y points.
61
67
  * Each thread re-checks if its Y value matches the bin's min/max.
@@ -67,7 +73,7 @@ declare const BIN_POINTS_SHADER = "\nrequires immediate_address_space;\n\nstruct
67
73
  * Group 1: controlLayout (control — only reads shouldDecimate)
68
74
  * Immediates: the x scale (ScaleParam)
69
75
  */
70
- declare const FIND_MINMAX_SHADER = "\nrequires immediate_address_space;\n\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\nstruct M4Bin {\n\tfirstIdx: atomic<u32>,\n\tlastIdx: atomic<u32>,\n\tminYSortable: atomic<u32>,\n\tmaxYSortable: atomic<u32>,\n\tminYIdx: atomic<u32>,\n\tmaxYIdx: atomic<u32>,\n};\n\nfn floatToSortableUint(f: f32) -> u32 {\n\tlet bits = bitcast<u32>(f);\n\treturn select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);\n}\n\n\nstruct ScaleParam {\n\tp1: f32,\n\tp2: f32,\n\tp3: f32,\n\tp4: f32,\n};\n\nvar<immediate> xScale: ScaleParam;\n\n\n@group(0) @binding(0) var<storage, read> xValues: array<f32>;\n@group(0) @binding(1) var<storage, read> yValues: array<f32>;\n@group(0) @binding(2) var<storage, read> seriesValues: array<f32>;\n@group(0) @binding(4) var<uniform> config: M4Config;\n@group(0) @binding(5) var<storage, read_write> bins: array<M4Bin>;\n@group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\tif (atomicLoad(&control[4]) == 0u) { return; }\n\n\tlet idx = gid.x;\n\tif (idx >= config.pointCount) { return; }\n\n\tlet x = xValues[idx];\n\tlet y = yValues[idx];\n\n\tif (x != x || y != y) { return; }\n\n\tlet domainMin = xScale.p1;\n\tlet domainMax = xScale.p2;\n\n\tlet t = (x - domainMin) / (domainMax - domainMin);\n\n\t// Series: 0-indexed (matches binPoints pass)\n\tlet seriesF = seriesValues[idx];\n\tif (seriesF < 0.0 || seriesF != seriesF) { return; }\n\tlet series = u32(seriesF);\n\tif (series >= config.numSeries) { return; }\n\n\t// Boundary bin clamping (matches binPoints pass)\n\tvar bin: u32;\n\tif (t < 0.0) {\n\t\tbin = 0u;\n\t} else if (t > 1.0) {\n\t\tbin = config.numBins - 1u;\n\t} else {\n\t\tlet innerBins = config.numBins - 2u;\n\t\tbin = 1u + min(u32(floor(t * f32(innerBins))), innerBins - 1u);\n\t}\n\tlet binKey = series * config.numBins + bin;\n\n\tlet ySortable = floatToSortableUint(y);\n\n\tif (ySortable == atomicLoad(&bins[binKey].minYSortable)) {\n\t\tatomicMin(&bins[binKey].minYIdx, idx);\n\t}\n\tif (ySortable == atomicLoad(&bins[binKey].maxYSortable)) {\n\t\tatomicMin(&bins[binKey].maxYIdx, idx);\n\t}\n}\n";
76
+ declare const FIND_MINMAX_SHADER = "\nrequires immediate_address_space;\n\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\nstruct M4Bin {\n\tfirstIdx: atomic<u32>,\n\tlastIdx: atomic<u32>,\n\tminYSortable: atomic<u32>,\n\tmaxYSortable: atomic<u32>,\n\tminYIdx: atomic<u32>,\n\tmaxYIdx: atomic<u32>,\n};\n\nfn sortableFromBits(bits: u32) -> u32 {\n\treturn select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);\n}\n\n\nconst NAN_BITS: u32 = 0x7FC00000u;\n\nfn isNanBits(u: u32) -> bool {\n\treturn (u & 0x7fffffffu) > 0x7f800000u;\n}\n\nfn isNaN(v: f32) -> bool {\n\treturn isNanBits(bitcast<u32>(v));\n}\n\n\n\nstruct ScaleParam {\n\tp1: f32,\n\tp2: f32,\n\tp3: f32,\n\tp4: f32,\n};\n\nvar<immediate> xScale: ScaleParam;\n\n\n@group(0) @binding(0) var<storage, read> xBits: array<u32>;\n@group(0) @binding(1) var<storage, read> yBits: array<u32>;\n@group(0) @binding(2) var<storage, read> seriesBits: array<u32>;\n@group(0) @binding(4) var<uniform> config: M4Config;\n@group(0) @binding(5) var<storage, read_write> bins: array<M4Bin>;\n@group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\tif (atomicLoad(&control[4]) == 0u) { return; }\n\n\tlet idx = gid.x;\n\tif (idx >= config.pointCount) { return; }\n\n\t// NaN skip on the bits (matches binPoints pass)\n\tlet xb = xBits[idx];\n\tlet yb = yBits[idx];\n\tif (isNanBits(xb) || isNanBits(yb)) { return; }\n\tlet x = bitcast<f32>(xb);\n\n\tlet domainMin = xScale.p1;\n\tlet domainMax = xScale.p2;\n\n\tlet t = (x - domainMin) / (domainMax - domainMin);\n\n\t// Series: 0-indexed (matches binPoints pass)\n\tlet seriesB = seriesBits[idx];\n\tif (isNanBits(seriesB)) { return; }\n\tlet seriesF = bitcast<f32>(seriesB);\n\tif (seriesF < 0.0) { return; }\n\tlet series = u32(seriesF);\n\tif (series >= config.numSeries) { return; }\n\n\t// Boundary bin clamping (matches binPoints pass)\n\tvar bin: u32;\n\tif (t < 0.0) {\n\t\tbin = 0u;\n\t} else if (t > 1.0) {\n\t\tbin = config.numBins - 1u;\n\t} else {\n\t\tlet innerBins = config.numBins - 2u;\n\t\tbin = 1u + min(u32(floor(t * f32(innerBins))), innerBins - 1u);\n\t}\n\tlet binKey = series * config.numBins + bin;\n\n\tlet ySortable = sortableFromBits(yb);\n\n\tif (ySortable == atomicLoad(&bins[binKey].minYSortable)) {\n\t\tatomicMin(&bins[binKey].minYIdx, idx);\n\t}\n\tif (ySortable == atomicLoad(&bins[binKey].maxYSortable)) {\n\t\tatomicMin(&bins[binKey].maxYIdx, idx);\n\t}\n}\n";
71
77
  /**
72
78
  * Pass 3: Emit decimated output arrays.
73
79
  * Each thread processes one bin, collecting up to 4 unique indices
@@ -82,7 +88,7 @@ declare const FIND_MINMAX_SHADER = "\nrequires immediate_address_space;\n\nstruc
82
88
  * Group 0: emitInputLayout (x, y, color, config, bins)
83
89
  * Group 1: outputLayout (xOut, yOut, seriesOut, colorOut)
84
90
  */
85
- declare const EMIT_OUTPUT_SHADER = "\n\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\nstruct M4Bin {\n\tfirstIdx: atomic<u32>,\n\tlastIdx: atomic<u32>,\n\tminYSortable: atomic<u32>,\n\tmaxYSortable: atomic<u32>,\n\tminYIdx: atomic<u32>,\n\tmaxYIdx: atomic<u32>,\n};\n\nfn floatToSortableUint(f: f32) -> u32 {\n\tlet bits = bitcast<u32>(f);\n\treturn select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);\n}\n\n\n// Note: seriesValues not bound here \u2014 series is derived from binKey / numBins.\n// This keeps total storage buffers within WebGPU default limit.\n@group(0) @binding(0) var<storage, read> xValues: array<f32>;\n@group(0) @binding(1) var<storage, read> yValues: array<f32>;\n@group(0) @binding(2) var<storage, read> colorValues: array<f32>;\n@group(0) @binding(3) var<uniform> config: M4Config;\n@group(0) @binding(4) var<storage, read_write> bins: array<M4Bin>;\n@group(1) @binding(0) var<storage, read_write> xOut: array<f32>;\n@group(1) @binding(1) var<storage, read_write> yOut: array<f32>;\n@group(1) @binding(2) var<storage, read_write> seriesOut: array<f32>;\n@group(1) @binding(3) var<storage, read_write> colorOut: array<f32>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\tlet binKey = gid.x;\n\tlet totalBins = config.numSeries * config.numBins;\n\tif (binKey >= totalBins) { return; }\n\n\tlet outBase = binKey * 4u;\n\tlet firstIdx = atomicLoad(&bins[binKey].firstIdx);\n\n\t// Empty bin: write NaN to all 4 output slots\n\t// Use var to avoid const-expression NaN (WGSL disallows NaN in const context)\n\tif (firstIdx == 0xFFFFFFFFu) {\n\t\tvar nanBits = 0x7FC00000u;\n\t\tlet nan = bitcast<f32>(nanBits);\n\t\tfor (var i = 0u; i < 4u; i++) {\n\t\t\txOut[outBase + i] = nan;\n\t\t\tyOut[outBase + i] = nan;\n\t\t\tseriesOut[outBase + i] = nan;\n\t\t\tcolorOut[outBase + i] = nan;\n\t\t}\n\t\treturn;\n\t}\n\n\tlet lastIdx = atomicLoad(&bins[binKey].lastIdx);\n\tlet minYIdx = atomicLoad(&bins[binKey].minYIdx);\n\tlet maxYIdx = atomicLoad(&bins[binKey].maxYIdx);\n\n\t// Collect up to 4 unique indices, sorted by original index (= x-order)\n\tvar indices: array<u32, 4>;\n\tvar count = 0u;\n\n\t// Insert in sorted order, skip duplicates\n\tvar candidates = array<u32, 4>(firstIdx, lastIdx, minYIdx, maxYIdx);\n\n\t// Simple insertion sort for up to 4 candidates\n\tfor (var c = 0u; c < 4u; c++) {\n\t\tlet candidate = candidates[c];\n\t\t// Check if already present\n\t\tvar isDuplicate = false;\n\t\tfor (var k = 0u; k < count; k++) {\n\t\t\tif (indices[k] == candidate) {\n\t\t\t\tisDuplicate = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (isDuplicate) { continue; }\n\n\t\t// Insert in sorted position\n\t\tvar insertPos = count;\n\t\tfor (var k = 0u; k < count; k++) {\n\t\t\tif (candidate < indices[k]) {\n\t\t\t\tinsertPos = k;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\t// Shift elements right\n\t\tfor (var k = count; k > insertPos; k--) {\n\t\t\tindices[k] = indices[k - 1u];\n\t\t}\n\t\tindices[insertPos] = candidate;\n\t\tcount++;\n\t}\n\n\t// Derive series value from bin key (0-indexed, matching ViewManager convention).\n\t// Vertex shader uses != comparison for series breaks, so any distinct values work.\n\tlet seriesVal = f32(binKey / config.numBins);\n\n\t// Write unique points, fill remaining slots with last unique point\n\tfor (var i = 0u; i < 4u; i++) {\n\t\tlet srcIdx = indices[min(i, count - 1u)];\n\t\txOut[outBase + i] = xValues[srcIdx];\n\t\tyOut[outBase + i] = yValues[srcIdx];\n\t\tseriesOut[outBase + i] = seriesVal;\n\t\tcolorOut[outBase + i] = colorValues[srcIdx];\n\t}\n}\n";
91
+ declare const EMIT_OUTPUT_SHADER = "\n\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\nstruct M4Bin {\n\tfirstIdx: atomic<u32>,\n\tlastIdx: atomic<u32>,\n\tminYSortable: atomic<u32>,\n\tmaxYSortable: atomic<u32>,\n\tminYIdx: atomic<u32>,\n\tmaxYIdx: atomic<u32>,\n};\n\nfn sortableFromBits(bits: u32) -> u32 {\n\treturn select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);\n}\n\n\nconst NAN_BITS: u32 = 0x7FC00000u;\n\nfn isNanBits(u: u32) -> bool {\n\treturn (u & 0x7fffffffu) > 0x7f800000u;\n}\n\nfn isNaN(v: f32) -> bool {\n\treturn isNanBits(bitcast<u32>(v));\n}\n\n\n\n// Note: seriesValues not bound here \u2014 series is derived from binKey / numBins.\n// This keeps total storage buffers within WebGPU default limit.\n@group(0) @binding(0) var<storage, read> xBits: array<u32>;\n@group(0) @binding(1) var<storage, read> yBits: array<u32>;\n@group(0) @binding(2) var<storage, read> colorBits: array<u32>;\n@group(0) @binding(3) var<uniform> config: M4Config;\n@group(0) @binding(4) var<storage, read_write> bins: array<M4Bin>;\n@group(1) @binding(0) var<storage, read_write> xOut: array<u32>;\n@group(1) @binding(1) var<storage, read_write> yOut: array<u32>;\n@group(1) @binding(2) var<storage, read_write> seriesOut: array<u32>;\n@group(1) @binding(3) var<storage, read_write> colorOut: array<u32>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\tlet binKey = gid.x;\n\tlet totalBins = config.numSeries * config.numBins;\n\tif (binKey >= totalBins) { return; }\n\n\tlet outBase = binKey * 4u;\n\tlet firstIdx = atomicLoad(&bins[binKey].firstIdx);\n\n\t// Empty bin: NaN in all 4 output slots, written as bits\n\tif (firstIdx == 0xFFFFFFFFu) {\n\t\tfor (var i = 0u; i < 4u; i++) {\n\t\t\txOut[outBase + i] = NAN_BITS;\n\t\t\tyOut[outBase + i] = NAN_BITS;\n\t\t\tseriesOut[outBase + i] = NAN_BITS;\n\t\t\tcolorOut[outBase + i] = NAN_BITS;\n\t\t}\n\t\treturn;\n\t}\n\n\tlet lastIdx = atomicLoad(&bins[binKey].lastIdx);\n\tlet minYIdx = atomicLoad(&bins[binKey].minYIdx);\n\tlet maxYIdx = atomicLoad(&bins[binKey].maxYIdx);\n\n\t// Collect up to 4 unique indices, sorted by original index (= x-order)\n\tvar indices: array<u32, 4>;\n\tvar count = 0u;\n\n\t// Insert in sorted order, skip duplicates\n\tvar candidates = array<u32, 4>(firstIdx, lastIdx, minYIdx, maxYIdx);\n\n\t// Simple insertion sort for up to 4 candidates\n\tfor (var c = 0u; c < 4u; c++) {\n\t\tlet candidate = candidates[c];\n\t\t// Check if already present\n\t\tvar isDuplicate = false;\n\t\tfor (var k = 0u; k < count; k++) {\n\t\t\tif (indices[k] == candidate) {\n\t\t\t\tisDuplicate = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (isDuplicate) { continue; }\n\n\t\t// Insert in sorted position\n\t\tvar insertPos = count;\n\t\tfor (var k = 0u; k < count; k++) {\n\t\t\tif (candidate < indices[k]) {\n\t\t\t\tinsertPos = k;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\t// Shift elements right\n\t\tfor (var k = count; k > insertPos; k--) {\n\t\t\tindices[k] = indices[k - 1u];\n\t\t}\n\t\tindices[insertPos] = candidate;\n\t\tcount++;\n\t}\n\n\t// Derive series value from bin key (0-indexed, matching ViewManager convention).\n\t// Vertex shader uses != comparison for series breaks, so any distinct values work.\n\tlet seriesVal = bitcast<u32>(f32(binKey / config.numBins));\n\n\t// Write unique points, fill remaining slots with last unique point.\n\t// Copied as bits, so a value arrives exactly as it left.\n\tfor (var i = 0u; i < 4u; i++) {\n\t\tlet srcIdx = indices[min(i, count - 1u)];\n\t\txOut[outBase + i] = xBits[srcIdx];\n\t\tyOut[outBase + i] = yBits[srcIdx];\n\t\tseriesOut[outBase + i] = seriesVal;\n\t\tcolorOut[outBase + i] = colorBits[srcIdx];\n\t}\n}\n";
86
92
  /**
87
93
  * Pass 4: Compact non-NaN output points and deduplicate.
88
94
  * After the emit pass, empty bins contain NaN. This pass packs real data
@@ -100,7 +106,7 @@ declare const EMIT_OUTPUT_SHADER = "\n\nstruct M4Config {\n\tpointCount: u32,\n\
100
106
  * Group 0: fillLayout (config + output arrays)
101
107
  * Group 1: controlLayout (control)
102
108
  */
103
- declare const COMPACT_SHADER = "\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\n@group(0) @binding(0) var<uniform> config: M4Config;\n@group(0) @binding(1) var<storage, read_write> xOut: array<f32>;\n@group(0) @binding(2) var<storage, read_write> yOut: array<f32>;\n@group(0) @binding(3) var<storage, read_write> seriesOut: array<f32>;\n@group(0) @binding(4) var<storage, read_write> colorOut: array<f32>;\n@group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;\n\n@compute @workgroup_size(1)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\tif (atomicLoad(&control[4]) == 0u) { return; }\n\n\tlet series = gid.x;\n\tif (series >= config.numSeries) { return; }\n\n\tlet start = series * 4u * config.numBins;\n\tlet end = start + 4u * config.numBins;\n\n\t// Pack non-NaN points to the front, skipping consecutive duplicates.\n\tvar writeIdx = start;\n\tvar nanBits = 0x7FC00000u;\n\tvar lastX: f32 = bitcast<f32>(nanBits);\n\tvar lastY: f32 = bitcast<f32>(nanBits);\n\n\tfor (var i = start; i < end; i++) {\n\t\tlet x = xOut[i];\n\t\tif (x != x) { continue; }\n\t\tlet y = yOut[i];\n\t\tif (x == lastX && y == lastY) { continue; }\n\n\t\tif (writeIdx != i) {\n\t\t\txOut[writeIdx] = x;\n\t\t\tyOut[writeIdx] = y;\n\t\t\tseriesOut[writeIdx] = seriesOut[i];\n\t\t\tcolorOut[writeIdx] = colorOut[i];\n\t\t}\n\t\tlastX = x;\n\t\tlastY = y;\n\t\twriteIdx++;\n\t}\n\n\t// NaN-pad remaining slots\n\tlet nan = bitcast<f32>(nanBits);\n\tfor (var i = writeIdx; i < end; i++) {\n\t\txOut[i] = nan;\n\t\tyOut[i] = nan;\n\t\tseriesOut[i] = nan;\n\t\tcolorOut[i] = nan;\n\t}\n\n\t// Instance count is set by pass 0 (outputSize) \u2014 compact only reorganizes\n\t// data within each series block, it doesn't change the total count.\n\t// The vertex shader discards NaN-padded entries at the end of each block.\n}\n";
109
+ declare const COMPACT_SHADER = "\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\n\nconst NAN_BITS: u32 = 0x7FC00000u;\n\nfn isNanBits(u: u32) -> bool {\n\treturn (u & 0x7fffffffu) > 0x7f800000u;\n}\n\nfn isNaN(v: f32) -> bool {\n\treturn isNanBits(bitcast<u32>(v));\n}\n\n\n@group(0) @binding(0) var<uniform> config: M4Config;\n@group(0) @binding(1) var<storage, read_write> xOut: array<u32>;\n@group(0) @binding(2) var<storage, read_write> yOut: array<u32>;\n@group(0) @binding(3) var<storage, read_write> seriesOut: array<u32>;\n@group(0) @binding(4) var<storage, read_write> colorOut: array<u32>;\n@group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;\n\n@compute @workgroup_size(1)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\tif (atomicLoad(&control[4]) == 0u) { return; }\n\n\tlet series = gid.x;\n\tif (series >= config.numSeries) { return; }\n\n\tlet start = series * 4u * config.numBins;\n\tlet end = start + 4u * config.numBins;\n\n\t// Pack non-NaN points to the front, skipping consecutive duplicates.\n\t// All on bits: NaN by its pattern, duplicates by identical bits. lastX\n\t// starts as NaN, which no kept x can equal.\n\tvar writeIdx = start;\n\tvar lastX = NAN_BITS;\n\tvar lastY = NAN_BITS;\n\n\tfor (var i = start; i < end; i++) {\n\t\tlet x = xOut[i];\n\t\tif (isNanBits(x)) { continue; }\n\t\tlet y = yOut[i];\n\t\tif (x == lastX && y == lastY) { continue; }\n\n\t\tif (writeIdx != i) {\n\t\t\txOut[writeIdx] = x;\n\t\t\tyOut[writeIdx] = y;\n\t\t\tseriesOut[writeIdx] = seriesOut[i];\n\t\t\tcolorOut[writeIdx] = colorOut[i];\n\t\t}\n\t\tlastX = x;\n\t\tlastY = y;\n\t\twriteIdx++;\n\t}\n\n\t// NaN-pad remaining slots\n\tfor (var i = writeIdx; i < end; i++) {\n\t\txOut[i] = NAN_BITS;\n\t\tyOut[i] = NAN_BITS;\n\t\tseriesOut[i] = NAN_BITS;\n\t\tcolorOut[i] = NAN_BITS;\n\t}\n\n\t// Instance count is set by pass 0 (outputSize) \u2014 compact only reorganizes\n\t// data within each series block, it doesn't change the total count.\n\t// The vertex shader discards NaN-padded entries at the end of each block.\n}\n";
104
110
  /**
105
111
  * Pass 5a: Passthrough copy — raw x,y → output + set indirect count.
106
112
  *
@@ -113,7 +119,7 @@ declare const COMPACT_SHADER = "\nstruct M4Config {\n\tpointCount: u32,\n\tnumBi
113
119
  * Group 0: passthroughInputLayout (data0, data1, config, control)
114
120
  * Group 1: halfOutputLayout (out0, out1)
115
121
  */
116
- declare const PASSTHROUGH_XY_SHADER = "\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\n@group(0) @binding(0) var<storage, read> xIn: array<f32>;\n@group(0) @binding(1) var<storage, read> yIn: array<f32>;\n@group(0) @binding(2) var<uniform> config: M4Config;\n@group(0) @binding(3) var<storage, read_write> control: array<atomic<u32>>;\n@group(1) @binding(0) var<storage, read_write> xOut: array<f32>;\n@group(1) @binding(1) var<storage, read_write> yOut: array<f32>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\t// Only run in passthrough mode\n\tif (atomicLoad(&control[4]) == 1u) { return; }\n\n\tlet idx = gid.x;\n\n\t// Thread 0 sets instance count to raw point count\n\tif (idx == 0u) {\n\t\tatomicStore(&control[1], config.pointCount);\n\t}\n\n\tif (idx >= config.pointCount) { return; }\n\n\txOut[idx] = xIn[idx];\n\tyOut[idx] = yIn[idx];\n}\n";
122
+ declare const PASSTHROUGH_XY_SHADER = "\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\n// Copied as bits, so a null's NaN arrives exactly as it left.\n@group(0) @binding(0) var<storage, read> xIn: array<u32>;\n@group(0) @binding(1) var<storage, read> yIn: array<u32>;\n@group(0) @binding(2) var<uniform> config: M4Config;\n@group(0) @binding(3) var<storage, read_write> control: array<atomic<u32>>;\n@group(1) @binding(0) var<storage, read_write> xOut: array<u32>;\n@group(1) @binding(1) var<storage, read_write> yOut: array<u32>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\t// Only run in passthrough mode\n\tif (atomicLoad(&control[4]) == 1u) { return; }\n\n\tlet idx = gid.x;\n\n\t// Thread 0 sets instance count to raw point count\n\tif (idx == 0u) {\n\t\tatomicStore(&control[1], config.pointCount);\n\t}\n\n\tif (idx >= config.pointCount) { return; }\n\n\txOut[idx] = xIn[idx];\n\tyOut[idx] = yIn[idx];\n}\n";
117
123
  /**
118
124
  * Pass 5b: Passthrough copy — raw series,color → output.
119
125
  *
@@ -125,5 +131,5 @@ declare const PASSTHROUGH_XY_SHADER = "\nstruct M4Config {\n\tpointCount: u32,\n
125
131
  * Group 0: passthroughInputLayout (data0, data1, config, control)
126
132
  * Group 1: halfOutputLayout (out0, out1)
127
133
  */
128
- declare const PASSTHROUGH_SC_SHADER = "\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\n@group(0) @binding(0) var<storage, read> seriesIn: array<f32>;\n@group(0) @binding(1) var<storage, read> colorIn: array<f32>;\n@group(0) @binding(2) var<uniform> config: M4Config;\n@group(0) @binding(3) var<storage, read_write> control: array<atomic<u32>>;\n@group(1) @binding(0) var<storage, read_write> seriesOut: array<f32>;\n@group(1) @binding(1) var<storage, read_write> colorOut: array<f32>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\t// Only run in passthrough mode\n\tif (atomicLoad(&control[4]) == 1u) { return; }\n\n\tlet idx = gid.x;\n\tif (idx >= config.pointCount) { return; }\n\n\tseriesOut[idx] = seriesIn[idx];\n\tcolorOut[idx] = colorIn[idx];\n}\n";
134
+ declare const PASSTHROUGH_SC_SHADER = "\nstruct M4Config {\n\tpointCount: u32,\n\tnumBins: u32,\n\tnumSeries: u32,\n\t_reserved: u32,\n\t_reserved0: u32,\n\t_reserved1: u32,\n\t_reserved2: u32,\n\tdecimationMode: u32,\n};\n\n// Copied as bits, like pass 5a.\n@group(0) @binding(0) var<storage, read> seriesIn: array<u32>;\n@group(0) @binding(1) var<storage, read> colorIn: array<u32>;\n@group(0) @binding(2) var<uniform> config: M4Config;\n@group(0) @binding(3) var<storage, read_write> control: array<atomic<u32>>;\n@group(1) @binding(0) var<storage, read_write> seriesOut: array<u32>;\n@group(1) @binding(1) var<storage, read_write> colorOut: array<u32>;\n\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n\t// Only run in passthrough mode\n\tif (atomicLoad(&control[4]) == 1u) { return; }\n\n\tlet idx = gid.x;\n\tif (idx >= config.pointCount) { return; }\n\n\tseriesOut[idx] = seriesIn[idx];\n\tcolorOut[idx] = colorIn[idx];\n}\n";
129
135
  export { WORKGROUP_SIZE, X_SCALE_IMMEDIATE_SIZE, INIT_BINS_SHADER, BIN_POINTS_SHADER, FIND_MINMAX_SHADER, EMIT_OUTPUT_SHADER, COMPACT_SHADER, PASSTHROUGH_XY_SHADER, PASSTHROUGH_SC_SHADER };
@@ -27,7 +27,14 @@
27
27
  *
28
28
  * The x scale's params reach passes 1 and 2 as immediates, pushed before each
29
29
  * dispatch, rather than as an index into the global scale table.
30
+ *
31
+ * Every pass reads and writes the data through u32 views. NaN (a null, or an
32
+ * empty bin) is found by its bit pattern and moved as bits, never compared as
33
+ * a float: Metal compilers may fold `x != x` to false (see NanShaderLib), and
34
+ * where they do, compaction keeps the empty bins and the line loses every
35
+ * segment that touches one.
30
36
  */
37
+ import { NAN_WGSL } from '../NanShaderLib';
31
38
  const WORKGROUP_SIZE = 256;
32
39
  /**
33
40
  * Shared type definitions used across all kernels.
@@ -53,10 +60,11 @@ struct M4Bin {
53
60
  maxYIdx: atomic<u32>,
54
61
  };
55
62
 
56
- fn floatToSortableUint(f: f32) -> u32 {
57
- let bits = bitcast<u32>(f);
63
+ fn sortableFromBits(bits: u32) -> u32 {
58
64
  return select(~bits, bits ^ 0x80000000u, (bits & 0x80000000u) == 0u);
59
65
  }
66
+
67
+ ${NAN_WGSL}
60
68
  `;
61
69
  /** Bytes of immediate data passes 1 and 2 read: one ScaleParam. */
62
70
  const X_SCALE_IMMEDIATE_SIZE = 16;
@@ -135,9 +143,9 @@ requires immediate_address_space;
135
143
  ${SHARED_TYPES}
136
144
  ${X_SCALE_IMMEDIATE}
137
145
 
138
- @group(0) @binding(0) var<storage, read> xValues: array<f32>;
139
- @group(0) @binding(1) var<storage, read> yValues: array<f32>;
140
- @group(0) @binding(2) var<storage, read> seriesValues: array<f32>;
146
+ @group(0) @binding(0) var<storage, read> xBits: array<u32>;
147
+ @group(0) @binding(1) var<storage, read> yBits: array<u32>;
148
+ @group(0) @binding(2) var<storage, read> seriesBits: array<u32>;
141
149
  @group(0) @binding(4) var<uniform> config: M4Config;
142
150
  @group(0) @binding(5) var<storage, read_write> bins: array<M4Bin>;
143
151
  @group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;
@@ -150,11 +158,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
150
158
  let idx = gid.x;
151
159
  if (idx >= config.pointCount) { return; }
152
160
 
153
- let x = xValues[idx];
154
- let y = yValues[idx];
155
-
156
- // Skip NaN points
157
- if (x != x || y != y) { return; }
161
+ // Skip NaN points, tested on the bits before either becomes a float
162
+ let xb = xBits[idx];
163
+ let yb = yBits[idx];
164
+ if (isNanBits(xb) || isNanBits(yb)) { return; }
165
+ let x = bitcast<f32>(xb);
158
166
 
159
167
  let domainMin = xScale.p1;
160
168
  let domainMax = xScale.p2;
@@ -162,8 +170,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
162
170
  let t = (x - domainMin) / (domainMax - domainMin);
163
171
 
164
172
  // Series: 0-indexed from ViewManager (-1 = null/unknown, 0+ = categories)
165
- let seriesF = seriesValues[idx];
166
- if (seriesF < 0.0 || seriesF != seriesF) { return; }
173
+ let seriesB = seriesBits[idx];
174
+ if (isNanBits(seriesB)) { return; }
175
+ let seriesF = bitcast<f32>(seriesB);
176
+ if (seriesF < 0.0) { return; }
167
177
  let series = u32(seriesF);
168
178
  if (series >= config.numSeries) { return; }
169
179
 
@@ -183,7 +193,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
183
193
  atomicMin(&bins[binKey].firstIdx, idx);
184
194
  atomicMax(&bins[binKey].lastIdx, idx);
185
195
 
186
- let ySortable = floatToSortableUint(y);
196
+ let ySortable = sortableFromBits(yb);
187
197
  atomicMin(&bins[binKey].minYSortable, ySortable);
188
198
  atomicMax(&bins[binKey].maxYSortable, ySortable);
189
199
  }
@@ -204,9 +214,9 @@ requires immediate_address_space;
204
214
  ${SHARED_TYPES}
205
215
  ${X_SCALE_IMMEDIATE}
206
216
 
207
- @group(0) @binding(0) var<storage, read> xValues: array<f32>;
208
- @group(0) @binding(1) var<storage, read> yValues: array<f32>;
209
- @group(0) @binding(2) var<storage, read> seriesValues: array<f32>;
217
+ @group(0) @binding(0) var<storage, read> xBits: array<u32>;
218
+ @group(0) @binding(1) var<storage, read> yBits: array<u32>;
219
+ @group(0) @binding(2) var<storage, read> seriesBits: array<u32>;
210
220
  @group(0) @binding(4) var<uniform> config: M4Config;
211
221
  @group(0) @binding(5) var<storage, read_write> bins: array<M4Bin>;
212
222
  @group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;
@@ -218,10 +228,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
218
228
  let idx = gid.x;
219
229
  if (idx >= config.pointCount) { return; }
220
230
 
221
- let x = xValues[idx];
222
- let y = yValues[idx];
223
-
224
- if (x != x || y != y) { return; }
231
+ // NaN skip on the bits (matches binPoints pass)
232
+ let xb = xBits[idx];
233
+ let yb = yBits[idx];
234
+ if (isNanBits(xb) || isNanBits(yb)) { return; }
235
+ let x = bitcast<f32>(xb);
225
236
 
226
237
  let domainMin = xScale.p1;
227
238
  let domainMax = xScale.p2;
@@ -229,8 +240,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
229
240
  let t = (x - domainMin) / (domainMax - domainMin);
230
241
 
231
242
  // Series: 0-indexed (matches binPoints pass)
232
- let seriesF = seriesValues[idx];
233
- if (seriesF < 0.0 || seriesF != seriesF) { return; }
243
+ let seriesB = seriesBits[idx];
244
+ if (isNanBits(seriesB)) { return; }
245
+ let seriesF = bitcast<f32>(seriesB);
246
+ if (seriesF < 0.0) { return; }
234
247
  let series = u32(seriesF);
235
248
  if (series >= config.numSeries) { return; }
236
249
 
@@ -246,7 +259,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
246
259
  }
247
260
  let binKey = series * config.numBins + bin;
248
261
 
249
- let ySortable = floatToSortableUint(y);
262
+ let ySortable = sortableFromBits(yb);
250
263
 
251
264
  if (ySortable == atomicLoad(&bins[binKey].minYSortable)) {
252
265
  atomicMin(&bins[binKey].minYIdx, idx);
@@ -275,15 +288,15 @@ ${SHARED_TYPES}
275
288
 
276
289
  // Note: seriesValues not bound here — series is derived from binKey / numBins.
277
290
  // This keeps total storage buffers within WebGPU default limit.
278
- @group(0) @binding(0) var<storage, read> xValues: array<f32>;
279
- @group(0) @binding(1) var<storage, read> yValues: array<f32>;
280
- @group(0) @binding(2) var<storage, read> colorValues: array<f32>;
291
+ @group(0) @binding(0) var<storage, read> xBits: array<u32>;
292
+ @group(0) @binding(1) var<storage, read> yBits: array<u32>;
293
+ @group(0) @binding(2) var<storage, read> colorBits: array<u32>;
281
294
  @group(0) @binding(3) var<uniform> config: M4Config;
282
295
  @group(0) @binding(4) var<storage, read_write> bins: array<M4Bin>;
283
- @group(1) @binding(0) var<storage, read_write> xOut: array<f32>;
284
- @group(1) @binding(1) var<storage, read_write> yOut: array<f32>;
285
- @group(1) @binding(2) var<storage, read_write> seriesOut: array<f32>;
286
- @group(1) @binding(3) var<storage, read_write> colorOut: array<f32>;
296
+ @group(1) @binding(0) var<storage, read_write> xOut: array<u32>;
297
+ @group(1) @binding(1) var<storage, read_write> yOut: array<u32>;
298
+ @group(1) @binding(2) var<storage, read_write> seriesOut: array<u32>;
299
+ @group(1) @binding(3) var<storage, read_write> colorOut: array<u32>;
287
300
 
288
301
  @compute @workgroup_size(${WORKGROUP_SIZE})
289
302
  fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -294,16 +307,13 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
294
307
  let outBase = binKey * 4u;
295
308
  let firstIdx = atomicLoad(&bins[binKey].firstIdx);
296
309
 
297
- // Empty bin: write NaN to all 4 output slots
298
- // Use var to avoid const-expression NaN (WGSL disallows NaN in const context)
310
+ // Empty bin: NaN in all 4 output slots, written as bits
299
311
  if (firstIdx == 0xFFFFFFFFu) {
300
- var nanBits = 0x7FC00000u;
301
- let nan = bitcast<f32>(nanBits);
302
312
  for (var i = 0u; i < 4u; i++) {
303
- xOut[outBase + i] = nan;
304
- yOut[outBase + i] = nan;
305
- seriesOut[outBase + i] = nan;
306
- colorOut[outBase + i] = nan;
313
+ xOut[outBase + i] = NAN_BITS;
314
+ yOut[outBase + i] = NAN_BITS;
315
+ seriesOut[outBase + i] = NAN_BITS;
316
+ colorOut[outBase + i] = NAN_BITS;
307
317
  }
308
318
  return;
309
319
  }
@@ -350,15 +360,16 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
350
360
 
351
361
  // Derive series value from bin key (0-indexed, matching ViewManager convention).
352
362
  // Vertex shader uses != comparison for series breaks, so any distinct values work.
353
- let seriesVal = f32(binKey / config.numBins);
363
+ let seriesVal = bitcast<u32>(f32(binKey / config.numBins));
354
364
 
355
- // Write unique points, fill remaining slots with last unique point
365
+ // Write unique points, fill remaining slots with last unique point.
366
+ // Copied as bits, so a value arrives exactly as it left.
356
367
  for (var i = 0u; i < 4u; i++) {
357
368
  let srcIdx = indices[min(i, count - 1u)];
358
- xOut[outBase + i] = xValues[srcIdx];
359
- yOut[outBase + i] = yValues[srcIdx];
369
+ xOut[outBase + i] = xBits[srcIdx];
370
+ yOut[outBase + i] = yBits[srcIdx];
360
371
  seriesOut[outBase + i] = seriesVal;
361
- colorOut[outBase + i] = colorValues[srcIdx];
372
+ colorOut[outBase + i] = colorBits[srcIdx];
362
373
  }
363
374
  }
364
375
  `;
@@ -391,11 +402,13 @@ struct M4Config {
391
402
  decimationMode: u32,
392
403
  };
393
404
 
405
+ ${NAN_WGSL}
406
+
394
407
  @group(0) @binding(0) var<uniform> config: M4Config;
395
- @group(0) @binding(1) var<storage, read_write> xOut: array<f32>;
396
- @group(0) @binding(2) var<storage, read_write> yOut: array<f32>;
397
- @group(0) @binding(3) var<storage, read_write> seriesOut: array<f32>;
398
- @group(0) @binding(4) var<storage, read_write> colorOut: array<f32>;
408
+ @group(0) @binding(1) var<storage, read_write> xOut: array<u32>;
409
+ @group(0) @binding(2) var<storage, read_write> yOut: array<u32>;
410
+ @group(0) @binding(3) var<storage, read_write> seriesOut: array<u32>;
411
+ @group(0) @binding(4) var<storage, read_write> colorOut: array<u32>;
399
412
  @group(1) @binding(0) var<storage, read_write> control: array<atomic<u32>>;
400
413
 
401
414
  @compute @workgroup_size(1)
@@ -409,14 +422,15 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
409
422
  let end = start + 4u * config.numBins;
410
423
 
411
424
  // Pack non-NaN points to the front, skipping consecutive duplicates.
425
+ // All on bits: NaN by its pattern, duplicates by identical bits. lastX
426
+ // starts as NaN, which no kept x can equal.
412
427
  var writeIdx = start;
413
- var nanBits = 0x7FC00000u;
414
- var lastX: f32 = bitcast<f32>(nanBits);
415
- var lastY: f32 = bitcast<f32>(nanBits);
428
+ var lastX = NAN_BITS;
429
+ var lastY = NAN_BITS;
416
430
 
417
431
  for (var i = start; i < end; i++) {
418
432
  let x = xOut[i];
419
- if (x != x) { continue; }
433
+ if (isNanBits(x)) { continue; }
420
434
  let y = yOut[i];
421
435
  if (x == lastX && y == lastY) { continue; }
422
436
 
@@ -432,12 +446,11 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
432
446
  }
433
447
 
434
448
  // NaN-pad remaining slots
435
- let nan = bitcast<f32>(nanBits);
436
449
  for (var i = writeIdx; i < end; i++) {
437
- xOut[i] = nan;
438
- yOut[i] = nan;
439
- seriesOut[i] = nan;
440
- colorOut[i] = nan;
450
+ xOut[i] = NAN_BITS;
451
+ yOut[i] = NAN_BITS;
452
+ seriesOut[i] = NAN_BITS;
453
+ colorOut[i] = NAN_BITS;
441
454
  }
442
455
 
443
456
  // Instance count is set by pass 0 (outputSize) — compact only reorganizes
@@ -469,12 +482,13 @@ struct M4Config {
469
482
  decimationMode: u32,
470
483
  };
471
484
 
472
- @group(0) @binding(0) var<storage, read> xIn: array<f32>;
473
- @group(0) @binding(1) var<storage, read> yIn: array<f32>;
485
+ // Copied as bits, so a null's NaN arrives exactly as it left.
486
+ @group(0) @binding(0) var<storage, read> xIn: array<u32>;
487
+ @group(0) @binding(1) var<storage, read> yIn: array<u32>;
474
488
  @group(0) @binding(2) var<uniform> config: M4Config;
475
489
  @group(0) @binding(3) var<storage, read_write> control: array<atomic<u32>>;
476
- @group(1) @binding(0) var<storage, read_write> xOut: array<f32>;
477
- @group(1) @binding(1) var<storage, read_write> yOut: array<f32>;
490
+ @group(1) @binding(0) var<storage, read_write> xOut: array<u32>;
491
+ @group(1) @binding(1) var<storage, read_write> yOut: array<u32>;
478
492
 
479
493
  @compute @workgroup_size(${WORKGROUP_SIZE})
480
494
  fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -517,12 +531,13 @@ struct M4Config {
517
531
  decimationMode: u32,
518
532
  };
519
533
 
520
- @group(0) @binding(0) var<storage, read> seriesIn: array<f32>;
521
- @group(0) @binding(1) var<storage, read> colorIn: array<f32>;
534
+ // Copied as bits, like pass 5a.
535
+ @group(0) @binding(0) var<storage, read> seriesIn: array<u32>;
536
+ @group(0) @binding(1) var<storage, read> colorIn: array<u32>;
522
537
  @group(0) @binding(2) var<uniform> config: M4Config;
523
538
  @group(0) @binding(3) var<storage, read_write> control: array<atomic<u32>>;
524
- @group(1) @binding(0) var<storage, read_write> seriesOut: array<f32>;
525
- @group(1) @binding(1) var<storage, read_write> colorOut: array<f32>;
539
+ @group(1) @binding(0) var<storage, read_write> seriesOut: array<u32>;
540
+ @group(1) @binding(1) var<storage, read_write> colorOut: array<u32>;
526
541
 
527
542
  @compute @workgroup_size(${WORKGROUP_SIZE})
528
543
  fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
package/package.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "type": "git",
19
19
  "url": "git+https://github.com/dlyngman/fjader.git"
20
20
  },
21
- "version": "0.2.1",
21
+ "version": "0.2.2",
22
22
  "type": "module",
23
23
  "svelte": "./dist/index.js",
24
24
  "exports": {