@uoa-css-lab/duckscatter 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.github/dependabot.yml +42 -0
  2. package/.github/workflows/ci.yaml +111 -0
  3. package/.github/workflows/release.yml +55 -0
  4. package/.prettierrc +11 -0
  5. package/LICENSE +22 -0
  6. package/README.md +250 -0
  7. package/dist/data/data-layer.d.ts +169 -0
  8. package/dist/data/data-layer.js +402 -0
  9. package/dist/data/index.d.ts +2 -0
  10. package/dist/data/index.js +2 -0
  11. package/dist/data/repository.d.ts +48 -0
  12. package/dist/data/repository.js +109 -0
  13. package/dist/diagnostics.d.ts +27 -0
  14. package/dist/diagnostics.js +71 -0
  15. package/dist/errors.d.ts +22 -0
  16. package/dist/errors.js +58 -0
  17. package/dist/event-emitter.d.ts +62 -0
  18. package/dist/event-emitter.js +82 -0
  19. package/dist/index.d.ts +12 -0
  20. package/dist/index.js +13 -0
  21. package/dist/renderer/gpu-layer.d.ts +204 -0
  22. package/dist/renderer/gpu-layer.js +611 -0
  23. package/dist/renderer/index.d.ts +3 -0
  24. package/dist/renderer/index.js +3 -0
  25. package/dist/renderer/shaders.d.ts +13 -0
  26. package/dist/renderer/shaders.js +216 -0
  27. package/dist/renderer/webgpu-context.d.ts +20 -0
  28. package/dist/renderer/webgpu-context.js +88 -0
  29. package/dist/scatter-plot.d.ts +210 -0
  30. package/dist/scatter-plot.js +450 -0
  31. package/dist/types.d.ts +171 -0
  32. package/dist/types.js +1 -0
  33. package/dist/ui/index.d.ts +1 -0
  34. package/dist/ui/index.js +1 -0
  35. package/dist/ui/label-layer.d.ts +176 -0
  36. package/dist/ui/label-layer.js +488 -0
  37. package/docs/image.png +0 -0
  38. package/eslint.config.js +72 -0
  39. package/examples/next/README.md +36 -0
  40. package/examples/next/app/components/ColorExpressionInput.tsx +41 -0
  41. package/examples/next/app/components/ControlPanel.tsx +30 -0
  42. package/examples/next/app/components/HoverControlPanel.tsx +69 -0
  43. package/examples/next/app/components/HoverInfoDisplay.tsx +40 -0
  44. package/examples/next/app/components/LabelFilterInput.tsx +46 -0
  45. package/examples/next/app/components/LabelList.tsx +106 -0
  46. package/examples/next/app/components/PointAlphaSlider.tsx +21 -0
  47. package/examples/next/app/components/PointLimitSlider.tsx +23 -0
  48. package/examples/next/app/components/PointList.tsx +105 -0
  49. package/examples/next/app/components/PointSizeScaleSlider.tsx +22 -0
  50. package/examples/next/app/components/ScatterPlotCanvas.tsx +150 -0
  51. package/examples/next/app/components/SearchBox.tsx +46 -0
  52. package/examples/next/app/components/Slider.tsx +76 -0
  53. package/examples/next/app/components/StatsDisplay.tsx +15 -0
  54. package/examples/next/app/components/TimeFilterSlider.tsx +169 -0
  55. package/examples/next/app/context/ScatterPlotContext.tsx +402 -0
  56. package/examples/next/app/favicon.ico +0 -0
  57. package/examples/next/app/globals.css +23 -0
  58. package/examples/next/app/layout.tsx +35 -0
  59. package/examples/next/app/page.tsx +15 -0
  60. package/examples/next/eslint.config.mjs +18 -0
  61. package/examples/next/next.config.ts +7 -0
  62. package/examples/next/package-lock.json +6572 -0
  63. package/examples/next/package.json +27 -0
  64. package/examples/next/postcss.config.mjs +7 -0
  65. package/examples/next/scripts/generate_labels.py +167 -0
  66. package/examples/next/tsconfig.json +34 -0
  67. package/package.json +43 -0
  68. package/src/data/data-layer.ts +515 -0
  69. package/src/data/index.ts +2 -0
  70. package/src/data/repository.ts +146 -0
  71. package/src/diagnostics.ts +108 -0
  72. package/src/errors.ts +69 -0
  73. package/src/event-emitter.ts +88 -0
  74. package/src/index.ts +40 -0
  75. package/src/renderer/gpu-layer.ts +757 -0
  76. package/src/renderer/index.ts +3 -0
  77. package/src/renderer/shaders.ts +219 -0
  78. package/src/renderer/webgpu-context.ts +98 -0
  79. package/src/scatter-plot.ts +533 -0
  80. package/src/types.ts +218 -0
  81. package/src/ui/index.ts +1 -0
  82. package/src/ui/label-layer.ts +648 -0
  83. package/tsconfig.json +19 -0
@@ -0,0 +1,757 @@
1
+ import { WebGPUContext } from './webgpu-context.js';
2
+ import { scatterVertexShader, filterComputeShader, updateIndirectShader } from './shaders.js';
3
+ import type { ColorRGBA } from '../types.js';
4
+
5
+ /**
6
+ * GPU用に処理されたポイントデータ
7
+ */
8
+ export interface AllPointsData {
9
+ /** インスタンスデータ (x, y, color, size) の Float32Array */
10
+ instanceData: Float32Array;
11
+ /** 全ポイント数 */
12
+ totalCount: number;
13
+ }
14
+
15
+ /**
16
+ * GpuLayerの設定オプション
17
+ */
18
+ export interface GpuLayerOptions {
19
+ /** 描画対象のHTMLCanvasElement */
20
+ canvas: HTMLCanvasElement;
21
+ /** 背景色(デフォルト: 透明な黒) */
22
+ backgroundColor?: ColorRGBA;
23
+ /** 表示可能なポイントの最大数(デフォルト: 5000000) */
24
+ visiblePointLimit?: number;
25
+ }
26
+
27
+ // ビューポート境界のマージン(クリップ空間)
28
+ const VIEWPORT_MARGIN = 0.1;
29
+
30
+ /**
31
+ * WebGPUレンダリングを担当するレイヤー
32
+ * 責務:
33
+ * - WebGPUコンテキストとパイプラインの管理
34
+ * - GPUバッファの作成と管理
35
+ * - コンピュートシェーダーによるLODフィルタリング
36
+ * - Indirect Drawingによるレンダリング
37
+ */
38
+ export class GpuLayer {
39
+ /** WebGPUコンテキスト */
40
+ private context: WebGPUContext;
41
+ /** 描画対象のキャンバス */
42
+ private readonly canvas: HTMLCanvasElement;
43
+
44
+ /** レンダーパイプライン */
45
+ private renderPipeline: GPURenderPipeline | null = null;
46
+ /** フィルタリング用コンピュートパイプライン */
47
+ private filterPipeline: GPUComputePipeline | null = null;
48
+ /** Indirect Buffer更新用コンピュートパイプライン */
49
+ private updateIndirectPipeline: GPUComputePipeline | null = null;
50
+
51
+ /** クワッド頂点バッファ(stepMode: 'vertex') */
52
+ private quadVertexBuffer: GPUBuffer | null = null;
53
+ /** 全ポイントデータバッファ (Storage) */
54
+ private allPointsBuffer: GPUBuffer | null = null;
55
+ /** 可視ポイントインデックスバッファ (Storage) */
56
+ private visibleIndicesBuffer: GPUBuffer | null = null;
57
+ /** アトミックカウンターバッファ (Storage) */
58
+ private atomicCounterBuffer: GPUBuffer | null = null;
59
+ /** Indirect Drawingパラメータバッファ */
60
+ private indirectBuffer: GPUBuffer | null = null;
61
+ /** インデックスバッファ */
62
+ private indexBuffer: GPUBuffer | null = null;
63
+ /** レンダリング用ユニフォームバッファ */
64
+ private renderUniformBuffer: GPUBuffer | null = null;
65
+ /** コンピュート用ユニフォームバッファ */
66
+ private computeUniformBuffer: GPUBuffer | null = null;
67
+ /** GPUフィルターカラムデータバッファ */
68
+ private filterColumnsBuffer: GPUBuffer | null = null;
69
+
70
+ /** GPUフィルター条件 */
71
+ private gpuFilterConditions: { columnIndex: number; min: number; max: number }[] = [];
72
+ /** GPUフィルターカラム数 */
73
+ private gpuFilterColumnCount: number = 0;
74
+
75
+ /** レンダリング用バインドグループ */
76
+ private renderBindGroup: GPUBindGroup | null = null;
77
+ /** フィルタリング用バインドグループ */
78
+ private filterBindGroup: GPUBindGroup | null = null;
79
+ /** Indirect更新用バインドグループ */
80
+ private updateIndirectBindGroup: GPUBindGroup | null = null;
81
+
82
+ /** 全ポイント数 */
83
+ private totalPointCount: number = 0;
84
+ /** 背景色 */
85
+ private backgroundColor: ColorRGBA = { r: 0, g: 0, b: 0, a: 0 };
86
+ /** 表示可能なポイントの最大数 */
87
+ private visiblePointLimit: number = 5000000;
88
+ /** フィルタリング結果が有効かどうか */
89
+ private filterResultValid: boolean = false;
90
+
91
+ /** 現在のズームレベル */
92
+ private zoom: number = 1.0;
93
+ /** 現在のX方向パンオフセット */
94
+ private panX: number = 0.0;
95
+ /** 現在のY方向パンオフセット */
96
+ private panY: number = 0.0;
97
+ /** グローバル透明度 (0.0-1.0) */
98
+ private pointAlpha: number = 1.0;
99
+ /** グローバルサイズスケール */
100
+ private pointSizeScale: number = 1.0;
101
+
102
+ /**
103
+ * GpuLayerインスタンスを作成する
104
+ * @param options 設定オプション
105
+ */
106
+ constructor(options: GpuLayerOptions) {
107
+ this.canvas = options.canvas;
108
+ this.context = new WebGPUContext();
109
+ this.backgroundColor = options.backgroundColor ?? { r: 0, g: 0, b: 0, a: 0 };
110
+ this.visiblePointLimit = options.visiblePointLimit ?? 5000000;
111
+ }
112
+
113
+ /**
114
+ * WebGPUを初期化し、レンダリングリソースを作成する
115
+ * @param initialData 初期データ
116
+ */
117
+ async initialize(initialData: AllPointsData): Promise<void> {
118
+ await this.context.initialize(this.canvas);
119
+ this.createPipelines();
120
+ await this.createBuffers(initialData);
121
+ this.createBindGroups();
122
+ }
123
+
124
+ /**
125
+ * パイプラインを作成する
126
+ */
127
+ private createPipelines(): void {
128
+ if (!this.context.device) {
129
+ throw new Error('WebGPU device not initialized');
130
+ }
131
+
132
+ const filterShaderModule = this.context.device.createShaderModule({
133
+ code: filterComputeShader,
134
+ });
135
+ this.filterPipeline = this.context.device.createComputePipeline({
136
+ layout: 'auto',
137
+ compute: {
138
+ module: filterShaderModule,
139
+ entryPoint: 'main',
140
+ },
141
+ });
142
+
143
+ const updateIndirectShaderModule = this.context.device.createShaderModule({
144
+ code: updateIndirectShader,
145
+ });
146
+ this.updateIndirectPipeline = this.context.device.createComputePipeline({
147
+ layout: 'auto',
148
+ compute: {
149
+ module: updateIndirectShaderModule,
150
+ entryPoint: 'main',
151
+ },
152
+ });
153
+
154
+ const renderShaderModule = this.context.device.createShaderModule({
155
+ code: scatterVertexShader,
156
+ });
157
+
158
+ const quadVertexBufferLayout: GPUVertexBufferLayout = {
159
+ arrayStride: 8,
160
+ stepMode: 'vertex',
161
+ attributes: [
162
+ {
163
+ format: 'float32x2',
164
+ offset: 0,
165
+ shaderLocation: 0,
166
+ },
167
+ ],
168
+ };
169
+
170
+ this.renderPipeline = this.context.device.createRenderPipeline({
171
+ layout: 'auto',
172
+ vertex: {
173
+ module: renderShaderModule,
174
+ entryPoint: 'vertexMain',
175
+ buffers: [quadVertexBufferLayout],
176
+ },
177
+ fragment: {
178
+ module: renderShaderModule,
179
+ entryPoint: 'fragmentMain',
180
+ targets: [
181
+ {
182
+ format: this.context.format,
183
+ blend: {
184
+ color: {
185
+ srcFactor: 'src-alpha',
186
+ dstFactor: 'one-minus-src-alpha',
187
+ operation: 'add',
188
+ },
189
+ alpha: {
190
+ srcFactor: 'one',
191
+ dstFactor: 'one-minus-src-alpha',
192
+ operation: 'add',
193
+ },
194
+ },
195
+ },
196
+ ],
197
+ },
198
+ primitive: {
199
+ topology: 'triangle-list',
200
+ },
201
+ });
202
+ }
203
+
204
+ /**
205
+ * バッファを作成する
206
+ * @param data 初期データ
207
+ */
208
+ private async createBuffers(data: AllPointsData): Promise<void> {
209
+ if (!this.context.device) return;
210
+
211
+ this.totalPointCount = data.totalCount;
212
+
213
+ const quadVertices = new Float32Array([-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0]);
214
+ this.quadVertexBuffer = this.context.device.createBuffer({
215
+ size: quadVertices.byteLength,
216
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
217
+ });
218
+ this.context.device.queue.writeBuffer(this.quadVertexBuffer, 0, quadVertices);
219
+
220
+ const pointsBufferSize = data.totalCount * 16;
221
+ this.allPointsBuffer = this.context.device.createBuffer({
222
+ size: pointsBufferSize,
223
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
224
+ });
225
+ this.context.device.queue.writeBuffer(
226
+ this.allPointsBuffer,
227
+ 0,
228
+ data.instanceData.buffer,
229
+ data.instanceData.byteOffset,
230
+ data.instanceData.byteLength
231
+ );
232
+
233
+ const indicesBufferSize = data.totalCount * 4;
234
+ this.visibleIndicesBuffer = this.context.device.createBuffer({
235
+ size: indicesBufferSize,
236
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
237
+ });
238
+
239
+ this.atomicCounterBuffer = this.context.device.createBuffer({
240
+ size: 4,
241
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
242
+ });
243
+
244
+ this.indirectBuffer = this.context.device.createBuffer({
245
+ size: 20,
246
+ usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
247
+ });
248
+
249
+ const indices = new Uint16Array([0, 1, 2, 2, 1, 3]);
250
+ this.indexBuffer = this.context.device.createBuffer({
251
+ size: indices.byteLength,
252
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
253
+ });
254
+ this.context.device.queue.writeBuffer(this.indexBuffer, 0, indices);
255
+
256
+ this.renderUniformBuffer = this.context.device.createBuffer({
257
+ size: 96,
258
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
259
+ });
260
+
261
+ this.computeUniformBuffer = this.context.device.createBuffer({
262
+ size: 64,
263
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
264
+ });
265
+
266
+ const filterColumnsBufferSize = Math.max(16, data.totalCount * 16);
267
+ this.filterColumnsBuffer = this.context.device.createBuffer({
268
+ size: filterColumnsBufferSize,
269
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
270
+ });
271
+
272
+ this.updateUniforms();
273
+ }
274
+
275
+ /**
276
+ * バインドグループを作成する
277
+ */
278
+ private createBindGroups(): void {
279
+ if (
280
+ !this.context.device ||
281
+ !this.filterPipeline ||
282
+ !this.updateIndirectPipeline ||
283
+ !this.renderPipeline ||
284
+ !this.allPointsBuffer ||
285
+ !this.visibleIndicesBuffer ||
286
+ !this.atomicCounterBuffer ||
287
+ !this.indirectBuffer ||
288
+ !this.computeUniformBuffer ||
289
+ !this.renderUniformBuffer ||
290
+ !this.filterColumnsBuffer
291
+ ) {
292
+ return;
293
+ }
294
+
295
+ this.filterBindGroup = this.context.device.createBindGroup({
296
+ layout: this.filterPipeline.getBindGroupLayout(0),
297
+ entries: [
298
+ { binding: 0, resource: { buffer: this.allPointsBuffer } },
299
+ { binding: 1, resource: { buffer: this.visibleIndicesBuffer } },
300
+ { binding: 2, resource: { buffer: this.atomicCounterBuffer } },
301
+ { binding: 3, resource: { buffer: this.computeUniformBuffer } },
302
+ { binding: 4, resource: { buffer: this.filterColumnsBuffer } },
303
+ ],
304
+ });
305
+
306
+ this.updateIndirectBindGroup = this.context.device.createBindGroup({
307
+ layout: this.updateIndirectPipeline.getBindGroupLayout(0),
308
+ entries: [
309
+ { binding: 0, resource: { buffer: this.atomicCounterBuffer } },
310
+ { binding: 1, resource: { buffer: this.indirectBuffer } },
311
+ ],
312
+ });
313
+
314
+ this.renderBindGroup = this.context.device.createBindGroup({
315
+ layout: this.renderPipeline.getBindGroupLayout(0),
316
+ entries: [
317
+ { binding: 0, resource: { buffer: this.renderUniformBuffer } },
318
+ { binding: 1, resource: { buffer: this.allPointsBuffer } },
319
+ { binding: 2, resource: { buffer: this.visibleIndicesBuffer } },
320
+ ],
321
+ });
322
+ }
323
+
324
+ /**
325
+ * 全ポイントデータをアップロードする
326
+ * @param data 新しいポイントデータ
327
+ */
328
+ uploadAllPoints(data: AllPointsData): void {
329
+ if (!this.context.device) return;
330
+
331
+ const newTotalCount = data.totalCount;
332
+
333
+ if (newTotalCount > this.totalPointCount) {
334
+ this.allPointsBuffer?.destroy();
335
+ this.visibleIndicesBuffer?.destroy();
336
+
337
+ const pointsBufferSize = newTotalCount * 16;
338
+ this.allPointsBuffer = this.context.device.createBuffer({
339
+ size: pointsBufferSize,
340
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
341
+ });
342
+
343
+ const indicesBufferSize = newTotalCount * 4;
344
+ this.visibleIndicesBuffer = this.context.device.createBuffer({
345
+ size: indicesBufferSize,
346
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
347
+ });
348
+
349
+ this.createBindGroups();
350
+ }
351
+
352
+ this.totalPointCount = newTotalCount;
353
+ this.filterResultValid = false;
354
+
355
+ if (this.allPointsBuffer) {
356
+ this.context.device.queue.writeBuffer(
357
+ this.allPointsBuffer,
358
+ 0,
359
+ data.instanceData.buffer,
360
+ data.instanceData.byteOffset,
361
+ data.instanceData.byteLength
362
+ );
363
+ }
364
+ }
365
+
366
+ /**
367
+ * ビュー行列を作成する
368
+ */
369
+ private createViewMatrix(): Float32Array {
370
+ const aspectRatio = this.canvas.width / this.canvas.height;
371
+ return new Float32Array([
372
+ this.zoom / aspectRatio,
373
+ 0,
374
+ 0,
375
+ 0,
376
+ 0,
377
+ this.zoom,
378
+ 0,
379
+ 0,
380
+ 0,
381
+ 0,
382
+ 1,
383
+ 0,
384
+ this.panX,
385
+ this.panY,
386
+ 0,
387
+ 1,
388
+ ]);
389
+ }
390
+
391
+ /**
392
+ * LOD閾値を計算する
393
+ * ズームレベルに応じて表示するポイント数を制限する
394
+ */
395
+ private calculateLodThreshold(): number {
396
+ // 画面上に表示する最大ポイント数の目標値
397
+ let targetPoints = this.visiblePointLimit;
398
+
399
+ // ズームアウト(zoom < 1.0)時は、画面内に大量の点が密集するため、
400
+ // 重なり合いによるフラグメントシェーダーの過負荷を防ぐため目標点数を減らす。
401
+ if (this.zoom < 1.0) {
402
+ targetPoints = Math.floor(this.visiblePointLimit * Math.pow(this.zoom, 0.6));
403
+ // 最低限の分布が見えるライン(visiblePointLimitの10%)
404
+ targetPoints = Math.max(Math.floor(this.visiblePointLimit * 0.1), targetPoints);
405
+ }
406
+
407
+ // 全ポイント数が目標以下なら常に全表示(フィルタリング不要)
408
+ if (this.totalPointCount <= targetPoints) {
409
+ return 0xffffffff;
410
+ }
411
+
412
+ // ズームレベルに基づく表示領域の割合(概算)
413
+ // zoom=1.0を一単位として、ズームするほど表示範囲は狭くなる
414
+ const visibleAreaFraction = 1.0 / (this.zoom * this.zoom);
415
+
416
+ // その領域に含まれると予想されるポイント数
417
+ const expectedPointsInView = Math.min(
418
+ this.totalPointCount,
419
+ this.totalPointCount * visibleAreaFraction
420
+ );
421
+
422
+ // 目標内なら間引きなし
423
+ if (expectedPointsInView <= targetPoints) {
424
+ return 0xffffffff;
425
+ }
426
+
427
+ // 目標点数に抑えるための維持率 (Keep Ratio)
428
+ let keepRatio = targetPoints / expectedPointsInView;
429
+
430
+ // 1.0(全表示)を超えないようにクランプ
431
+ keepRatio = Math.min(1.0, keepRatio);
432
+
433
+ // u32の最大値に対する閾値を計算
434
+ return Math.floor(0xffffffff * keepRatio);
435
+ }
436
+
437
+ /**
438
+ * ユニフォームを更新する
439
+ */
440
+ updateUniforms(): void {
441
+ if (!this.context.device || !this.renderUniformBuffer || !this.computeUniformBuffer) {
442
+ return;
443
+ }
444
+
445
+ this.filterResultValid = false;
446
+
447
+ const viewMatrix = this.createViewMatrix();
448
+
449
+ const renderUniformData = new Float32Array(24);
450
+ renderUniformData.set(viewMatrix, 0);
451
+ // Vertex Shaderで pow(zoom, 0.3) を計算するコストを避けるため、CPUで事前に計算して渡す
452
+ renderUniformData[16] = Math.pow(this.zoom, 0.3);
453
+ renderUniformData[17] = this.canvas.width;
454
+ renderUniformData[18] = this.canvas.height;
455
+ renderUniformData[19] = this.pointAlpha;
456
+ renderUniformData[20] = this.pointSizeScale;
457
+ this.context.device.queue.writeBuffer(this.renderUniformBuffer, 0, renderUniformData);
458
+
459
+ // 逆変換を行ってワールド空間での境界を計算し、シェーダー内での行列演算を削除する
460
+ const aspectRatio = this.canvas.width / this.canvas.height;
461
+ const clipMinX = -1 - VIEWPORT_MARGIN;
462
+ const clipMinY = -1 - VIEWPORT_MARGIN;
463
+ const clipMaxX = 1 + VIEWPORT_MARGIN;
464
+ const clipMaxY = 1 + VIEWPORT_MARGIN;
465
+
466
+ const scaleX = this.zoom / aspectRatio;
467
+ const scaleY = this.zoom;
468
+
469
+ const worldMinX = (clipMinX - this.panX) / scaleX;
470
+ const worldMaxX = (clipMaxX - this.panX) / scaleX;
471
+ const worldMinY = (clipMinY - this.panY) / scaleY;
472
+ const worldMaxY = (clipMaxY - this.panY) / scaleY;
473
+
474
+ const computeUniformData = new ArrayBuffer(64);
475
+ const computeFloatView = new Float32Array(computeUniformData);
476
+ const computeUint32View = new Uint32Array(computeUniformData);
477
+
478
+ computeFloatView[0] = worldMinX;
479
+ computeFloatView[1] = worldMinY;
480
+ computeFloatView[2] = worldMaxX;
481
+ computeFloatView[3] = worldMaxY;
482
+ computeUint32View[4] = this.calculateLodThreshold();
483
+ computeUint32View[5] = this.totalPointCount;
484
+
485
+ let activeFilterMask = 0;
486
+ const filterRangeMin = [-Infinity, -Infinity, -Infinity, -Infinity];
487
+ const filterRangeMax = [Infinity, Infinity, Infinity, Infinity];
488
+
489
+ for (const condition of this.gpuFilterConditions) {
490
+ if (condition.columnIndex >= 0 && condition.columnIndex < 4) {
491
+ activeFilterMask |= 1 << condition.columnIndex;
492
+ filterRangeMin[condition.columnIndex] = condition.min;
493
+ filterRangeMax[condition.columnIndex] = condition.max;
494
+ }
495
+ }
496
+
497
+ computeUint32View[6] = activeFilterMask;
498
+
499
+ computeFloatView[8] = filterRangeMin[0];
500
+ computeFloatView[9] = filterRangeMin[1];
501
+ computeFloatView[10] = filterRangeMin[2];
502
+ computeFloatView[11] = filterRangeMin[3];
503
+
504
+ computeFloatView[12] = filterRangeMax[0];
505
+ computeFloatView[13] = filterRangeMax[1];
506
+ computeFloatView[14] = filterRangeMax[2];
507
+ computeFloatView[15] = filterRangeMax[3];
508
+
509
+ this.context.device.queue.writeBuffer(this.computeUniformBuffer, 0, computeUniformData);
510
+ }
511
+
512
+ /**
513
+ * 散布図をレンダリングする
514
+ */
515
+ render(): void {
516
+ if (
517
+ !this.context.device ||
518
+ !this.context.context ||
519
+ !this.filterPipeline ||
520
+ !this.updateIndirectPipeline ||
521
+ !this.renderPipeline ||
522
+ !this.quadVertexBuffer ||
523
+ !this.filterBindGroup ||
524
+ !this.updateIndirectBindGroup ||
525
+ !this.renderBindGroup ||
526
+ !this.atomicCounterBuffer ||
527
+ !this.indirectBuffer
528
+ ) {
529
+ return;
530
+ }
531
+
532
+ const commandEncoder = this.context.device.createCommandEncoder();
533
+
534
+ if (!this.filterResultValid) {
535
+ this.context.device.queue.writeBuffer(this.atomicCounterBuffer, 0, new Uint32Array([0]));
536
+
537
+ {
538
+ const computePass = commandEncoder.beginComputePass();
539
+ computePass.setPipeline(this.filterPipeline);
540
+ computePass.setBindGroup(0, this.filterBindGroup);
541
+ const workgroupCount = Math.ceil(this.totalPointCount / 256);
542
+ computePass.dispatchWorkgroups(workgroupCount);
543
+ computePass.end();
544
+ }
545
+
546
+ {
547
+ const computePass = commandEncoder.beginComputePass();
548
+ computePass.setPipeline(this.updateIndirectPipeline);
549
+ computePass.setBindGroup(0, this.updateIndirectBindGroup);
550
+ computePass.dispatchWorkgroups(1);
551
+ computePass.end();
552
+ }
553
+
554
+ this.filterResultValid = true;
555
+ }
556
+
557
+ {
558
+ const textureView = this.context.context.getCurrentTexture().createView();
559
+ const renderPass = commandEncoder.beginRenderPass({
560
+ colorAttachments: [
561
+ {
562
+ view: textureView,
563
+ clearValue: {
564
+ r: this.backgroundColor.r,
565
+ g: this.backgroundColor.g,
566
+ b: this.backgroundColor.b,
567
+ a: this.backgroundColor.a,
568
+ },
569
+ loadOp: 'clear',
570
+ storeOp: 'store',
571
+ },
572
+ ],
573
+ });
574
+
575
+ renderPass.setPipeline(this.renderPipeline);
576
+ renderPass.setVertexBuffer(0, this.quadVertexBuffer);
577
+ renderPass.setIndexBuffer(this.indexBuffer!, 'uint16');
578
+ renderPass.setBindGroup(0, this.renderBindGroup);
579
+ renderPass.drawIndexedIndirect(this.indirectBuffer, 0);
580
+ renderPass.end();
581
+ }
582
+
583
+ this.context.device.queue.submit([commandEncoder.finish()]);
584
+ }
585
+
586
+ /**
587
+ * キャンバスをリサイズし、ビューポートを更新する
588
+ */
589
+ resize(width: number, height: number): void {
590
+ this.canvas.width = width;
591
+ this.canvas.height = height;
592
+ this.updateUniforms();
593
+ }
594
+
595
+ /**
596
+ * ズームレベルを設定する
597
+ */
598
+ setZoom(zoom: number): void {
599
+ this.zoom = Math.max(0.01, Math.min(100, zoom));
600
+ this.updateUniforms();
601
+ }
602
+
603
+ /**
604
+ * 現在のズームレベルを取得する
605
+ */
606
+ getZoom(): number {
607
+ return this.zoom;
608
+ }
609
+
610
+ /**
611
+ * パンオフセットを設定する
612
+ */
613
+ setPan(x: number, y: number): void {
614
+ this.panX = x;
615
+ this.panY = y;
616
+ this.updateUniforms();
617
+ }
618
+
619
+ /**
620
+ * 現在のパンオフセットを取得する
621
+ */
622
+ getPan(): { x: number; y: number } {
623
+ return { x: this.panX, y: this.panY };
624
+ }
625
+
626
+ /**
627
+ * 指定した画面座標を中心にズームする
628
+ */
629
+ zoomToPoint(newZoom: number, screenX: number, screenY: number): void {
630
+ const clampedZoom = Math.max(0.01, Math.min(100, newZoom));
631
+ const aspectRatio = this.canvas.width / this.canvas.height;
632
+
633
+ const ndcX = (screenX / this.canvas.width) * 2 - 1;
634
+ const ndcY = -((screenY / this.canvas.height) * 2 - 1);
635
+
636
+ const worldXBefore = ((ndcX - this.panX) * aspectRatio) / this.zoom;
637
+ const worldYBefore = (ndcY - this.panY) / this.zoom;
638
+
639
+ this.zoom = clampedZoom;
640
+
641
+ this.panX = ndcX - (worldXBefore * this.zoom) / aspectRatio;
642
+ this.panY = ndcY - worldYBefore * this.zoom;
643
+
644
+ this.updateUniforms();
645
+ }
646
+
647
+ /**
648
+ * GPUレイヤーの設定オプションを更新する
649
+ */
650
+ updateOptions(options: Partial<GpuLayerOptions>): void {
651
+ if (options.backgroundColor !== undefined) {
652
+ this.backgroundColor = options.backgroundColor;
653
+ }
654
+ if (options.visiblePointLimit !== undefined) {
655
+ this.visiblePointLimit = options.visiblePointLimit;
656
+ // LOD閾値が変わるのでフィルタ結果を無効化
657
+ this.filterResultValid = false;
658
+ }
659
+ }
660
+
661
+ /**
662
+ * GPUフィルターカラムデータをアップロードする
663
+ * @param data フィルターカラムデータ(各ポイントに4カラム分のf32、totalPoints * 4 floats)
664
+ * @param columnCount 有効なカラム数(0-4)
665
+ */
666
+ uploadFilterColumns(data: Float32Array, columnCount: number): void {
667
+ if (!this.context.device) return;
668
+
669
+ this.gpuFilterColumnCount = Math.min(4, columnCount);
670
+ const requiredSize = this.totalPointCount * 16;
671
+
672
+ if (!this.filterColumnsBuffer || data.byteLength > requiredSize) {
673
+ this.filterColumnsBuffer?.destroy();
674
+ this.filterColumnsBuffer = this.context.device.createBuffer({
675
+ size: Math.max(16, data.byteLength),
676
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
677
+ });
678
+ this.createBindGroups();
679
+ }
680
+
681
+ this.context.device.queue.writeBuffer(
682
+ this.filterColumnsBuffer,
683
+ 0,
684
+ data.buffer,
685
+ data.byteOffset,
686
+ data.byteLength
687
+ );
688
+ this.filterResultValid = false;
689
+ }
690
+
691
+ /**
692
+ * GPUフィルター条件を設定する
693
+ * @param conditions フィルター条件の配列
694
+ */
695
+ setGpuFilterConditions(conditions: { columnIndex: number; min: number; max: number }[]): void {
696
+ this.gpuFilterConditions = conditions;
697
+ this.filterResultValid = false;
698
+ this.updateUniforms();
699
+ }
700
+
701
+ /**
702
+ * GPUフィルター条件をクリアする
703
+ */
704
+ clearGpuFilterConditions(): void {
705
+ this.gpuFilterConditions = [];
706
+ this.filterResultValid = false;
707
+ this.updateUniforms();
708
+ }
709
+
710
+ /**
711
+ * グローバル透明度を設定する
712
+ * @param alpha 透明度 (0.0-1.0)
713
+ */
714
+ setPointAlpha(alpha: number): void {
715
+ this.pointAlpha = Math.max(0, Math.min(1, alpha));
716
+ this.updateUniforms();
717
+ }
718
+
719
+ /**
720
+ * 現在のグローバル透明度を取得する
721
+ */
722
+ getPointAlpha(): number {
723
+ return this.pointAlpha;
724
+ }
725
+
726
+ /**
727
+ * グローバルサイズスケールを設定する
728
+ * @param scale サイズスケール (0.01以上)
729
+ */
730
+ setPointSizeScale(scale: number): void {
731
+ this.pointSizeScale = Math.max(0.01, scale);
732
+ this.updateUniforms();
733
+ }
734
+
735
+ /**
736
+ * 現在のグローバルサイズスケールを取得する
737
+ */
738
+ getPointSizeScale(): number {
739
+ return this.pointSizeScale;
740
+ }
741
+
742
+ /**
743
+ * リソースを破棄する
744
+ */
745
+ destroy(): void {
746
+ this.quadVertexBuffer?.destroy();
747
+ this.allPointsBuffer?.destroy();
748
+ this.visibleIndicesBuffer?.destroy();
749
+ this.atomicCounterBuffer?.destroy();
750
+ this.indirectBuffer?.destroy();
751
+ this.indexBuffer?.destroy();
752
+ this.renderUniformBuffer?.destroy();
753
+ this.computeUniformBuffer?.destroy();
754
+ this.filterColumnsBuffer?.destroy();
755
+ this.context.destroy();
756
+ }
757
+ }