@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,402 @@
1
+ import { createParquetReader } from './repository.js';
2
+ import { createError } from '../errors.js';
3
+ /** DataLayer未初期化エラーメッセージ */
4
+ const ERROR_NOT_INITIALIZED = 'DataLayer not initialized. Call initialize() first.';
5
+ /**
6
+ * データ取得とクエリ管理を担当するレイヤー
7
+ * 責務:
8
+ * - ParquetReaderを介したParquetデータの読み込みと管理
9
+ * - 全データをGPU用フォーマットに変換
10
+ * - データ変更の検知と通知
11
+ */
12
+ export class DataLayer {
13
+ /**
14
+ * DataLayerインスタンスを作成する
15
+ * @param options 設定オプション
16
+ */
17
+ constructor(options) {
18
+ /** Parquetデータへのアクセスを提供するリポジトリ */
19
+ this.repository = null;
20
+ /** ポイントサイズのSQL式 */
21
+ this.sizeSql = '3';
22
+ /** ポイント色のSQL式(ARGB形式: a=0.3, r=0.3, g=0.3, b=0.8) */
23
+ this.colorSql = '0x4D4D4DCC';
24
+ /** フィルタリング用のWHERE条件 */
25
+ this.whereConditions = [];
26
+ /** GPUでフィルタリングするカラム名 */
27
+ this.gpuFilterColumns = [];
28
+ /** 全ポイントデータのキャッシュ(ポイント検索用) */
29
+ this.allPointsCache = [];
30
+ /** ポイント識別用のカラム名 */
31
+ this.idColumn = '';
32
+ this.sizeSql = options.sizeSql ?? this.sizeSql;
33
+ this.colorSql = options.colorSql ?? this.colorSql;
34
+ this.whereConditions = options.whereConditions ?? [];
35
+ this.gpuFilterColumns = options.gpuFilterColumns ?? [];
36
+ this.idColumn = options.idColumn;
37
+ this.onError = options.onError;
38
+ this.onDataChanged = options.onDataChanged;
39
+ }
40
+ /**
41
+ * データレイヤーを初期化し、データを読み込む
42
+ * @param dataUrl Parquetファイルのurl
43
+ * @returns 処理済みの全データ
44
+ */
45
+ async initialize(dataUrl) {
46
+ this.repository = await createParquetReader();
47
+ await this.repository.loadParquetFromUrl(dataUrl, this.idColumn);
48
+ return await this.loadAllPoints();
49
+ }
50
+ /**
51
+ * GeoJSONラベルデータをDuckDBテーブルに読み込む
52
+ * @param geojson GeoJSON FeatureCollectionオブジェクト
53
+ */
54
+ async loadLabelData(geojson) {
55
+ if (!this.repository) {
56
+ throw new Error(ERROR_NOT_INITIALIZED);
57
+ }
58
+ await this.repository.loadGeoJson(geojson);
59
+ }
60
+ /**
61
+ * 単一の条件からWHERE句文字列を構築する
62
+ * @param condition WHERE条件
63
+ * @returns SQL WHERE句の文字列
64
+ */
65
+ buildWhereClauseString(condition) {
66
+ if (condition.type === 'numeric') {
67
+ return `${condition.column} ${condition.operator} ${condition.value}`;
68
+ }
69
+ else if (condition.type === 'raw') {
70
+ return condition.sql;
71
+ }
72
+ else {
73
+ const escapedValue = condition.value.replace(/'/g, "''");
74
+ switch (condition.operator) {
75
+ case 'equals':
76
+ return `${condition.column} = '${escapedValue}'`;
77
+ case 'contains':
78
+ return `${condition.column} LIKE '%${escapedValue}%'`;
79
+ case 'startsWith':
80
+ return `${condition.column} LIKE '${escapedValue}%'`;
81
+ case 'endsWith':
82
+ return `${condition.column} LIKE '%${escapedValue}'`;
83
+ }
84
+ }
85
+ }
86
+ /**
87
+ * 全データを読み込んでGPU用フォーマットに変換する
88
+ * @returns 処理済みの全データ
89
+ */
90
+ async loadAllPoints() {
91
+ if (!this.repository) {
92
+ return {
93
+ instanceData: new Float32Array(0),
94
+ totalCount: 0,
95
+ };
96
+ }
97
+ try {
98
+ const whereConditions = [];
99
+ for (const condition of this.whereConditions) {
100
+ whereConditions.push(this.buildWhereClauseString(condition));
101
+ }
102
+ const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
103
+ const sql = `SELECT x, y, CAST((${this.sizeSql}) AS DOUBLE) AS __size__, CAST((${this.colorSql}) AS INTEGER) AS __color__, ${this.idColumn} FROM parquet_data ${whereClause}`;
104
+ const data = await this.repository.query({ toString: () => sql });
105
+ if (!data) {
106
+ return {
107
+ instanceData: new Float32Array(0),
108
+ totalCount: 0,
109
+ };
110
+ }
111
+ return this.processDataToGpuFormat(data);
112
+ }
113
+ catch (e) {
114
+ if (this.onError) {
115
+ this.onError(createError('QUERY_FAILED', 'Failed to load all points', {
116
+ cause: e instanceof Error ? e : undefined,
117
+ }));
118
+ }
119
+ return {
120
+ instanceData: new Float32Array(0),
121
+ totalCount: 0,
122
+ };
123
+ }
124
+ }
125
+ /**
126
+ * GPUフィルターカラムデータを読み込む
127
+ * @returns フィルターカラムデータとカラム数、カラムが指定されていない場合はnull
128
+ */
129
+ async loadGpuFilterColumns() {
130
+ if (this.gpuFilterColumns.length === 0 || !this.repository) {
131
+ return null;
132
+ }
133
+ const columns = this.gpuFilterColumns.slice(0, 4);
134
+ try {
135
+ const columnSelects = columns
136
+ .map((col, i) => `CAST(${col} AS DOUBLE) AS __filter_col_${i}__`)
137
+ .join(', ');
138
+ const sql = `SELECT ${columnSelects} FROM parquet_data`;
139
+ const data = await this.repository.query({ toString: () => sql });
140
+ if (!data || data.rowCount === 0) {
141
+ return null;
142
+ }
143
+ const filterData = new Float32Array(data.rowCount * 4);
144
+ for (let i = 0; i < data.rowCount; i++) {
145
+ const baseIndex = i * 4;
146
+ for (let j = 0; j < 4; j++) {
147
+ if (j < columns.length) {
148
+ const colData = data.columnData.get(`__filter_col_${j}__`);
149
+ filterData[baseIndex + j] = colData?.get(i) ?? 0;
150
+ }
151
+ else {
152
+ filterData[baseIndex + j] = 0;
153
+ }
154
+ }
155
+ }
156
+ const columnMapping = new Map();
157
+ columns.forEach((col, i) => columnMapping.set(col, i));
158
+ return {
159
+ data: filterData,
160
+ columnCount: columns.length,
161
+ columnMapping,
162
+ };
163
+ }
164
+ catch (e) {
165
+ if (this.onError) {
166
+ this.onError(createError('QUERY_FAILED', 'Failed to load GPU filter columns', {
167
+ cause: e instanceof Error ? e : undefined,
168
+ }));
169
+ }
170
+ return null;
171
+ }
172
+ }
173
+ /**
174
+ * カスタムSQLクエリを実行する
175
+ * @param query SQLクエリ
176
+ * @returns クエリ結果のParquetData
177
+ */
178
+ async executeQuery(query) {
179
+ if (!this.repository) {
180
+ return undefined;
181
+ }
182
+ const queryObj = typeof query === 'string' ? { toString: () => query } : query;
183
+ return this.repository.query(queryObj);
184
+ }
185
+ /**
186
+ * カラム形式のデータをGPU用インスタンスデータフォーマットに変換する
187
+ * フォーマット: ポイントごとに [x (f32), y (f32), color (u32), size (f32)]
188
+ * @param data ParquetData形式のデータ
189
+ * @returns 処理済みデータ
190
+ */
191
+ processDataToGpuFormat(data) {
192
+ const xColumn = data.columnData.get('x');
193
+ const yColumn = data.columnData.get('y');
194
+ const sizeColumn = data.columnData.get('__size__');
195
+ const colorColumn = data.columnData.get('__color__');
196
+ const idColumn = data.columnData.get(this.idColumn);
197
+ if (!xColumn || !yColumn || !sizeColumn || !colorColumn || !idColumn) {
198
+ return {
199
+ instanceData: new Float32Array(0),
200
+ totalCount: 0,
201
+ };
202
+ }
203
+ const cachedData = new Array(data.rowCount);
204
+ const buffer = new ArrayBuffer(data.rowCount * 16);
205
+ const floatView = new Float32Array(buffer);
206
+ const uint32View = new Uint32Array(buffer);
207
+ for (let i = 0; i < data.rowCount; i++) {
208
+ const x = xColumn.get(i);
209
+ const y = yColumn.get(i);
210
+ const size = sizeColumn.get(i);
211
+ const argbRaw = colorColumn.get(i);
212
+ const argb = typeof argbRaw === 'bigint' ? Number(argbRaw) : argbRaw;
213
+ const baseIndex = i * 4;
214
+ floatView[baseIndex + 0] = x;
215
+ floatView[baseIndex + 1] = y;
216
+ uint32View[baseIndex + 2] = argb >>> 0;
217
+ floatView[baseIndex + 3] = size;
218
+ cachedData[i] = {
219
+ id: idColumn.get(i),
220
+ x: x,
221
+ y: y,
222
+ size: size,
223
+ };
224
+ }
225
+ this.allPointsCache = cachedData;
226
+ return {
227
+ instanceData: floatView,
228
+ totalCount: data.rowCount,
229
+ };
230
+ }
231
+ /**
232
+ * 設定オプションを更新する
233
+ * @param options 更新する設定オプション
234
+ * @returns GPUフィルターカラムが変更された場合はtrue
235
+ */
236
+ updateOptions(options) {
237
+ let needsReload = false;
238
+ let gpuFilterColumnsChanged = false;
239
+ if (options.sizeSql !== undefined && options.sizeSql !== this.sizeSql) {
240
+ this.sizeSql = options.sizeSql;
241
+ needsReload = true;
242
+ }
243
+ if (options.colorSql !== undefined && options.colorSql !== this.colorSql) {
244
+ this.colorSql = options.colorSql;
245
+ needsReload = true;
246
+ }
247
+ if (options.whereConditions !== undefined) {
248
+ const oldConditions = JSON.stringify(this.whereConditions);
249
+ const newConditions = JSON.stringify(options.whereConditions);
250
+ if (oldConditions !== newConditions) {
251
+ this.whereConditions = options.whereConditions;
252
+ needsReload = true;
253
+ }
254
+ }
255
+ if (options.gpuFilterColumns !== undefined) {
256
+ const oldColumns = this.gpuFilterColumns.join(',');
257
+ const newColumns = options.gpuFilterColumns.join(',');
258
+ if (oldColumns !== newColumns) {
259
+ this.gpuFilterColumns = options.gpuFilterColumns;
260
+ gpuFilterColumnsChanged = true;
261
+ }
262
+ }
263
+ if (options.idColumn !== undefined) {
264
+ this.idColumn = options.idColumn;
265
+ }
266
+ if (options.onDataChanged !== undefined) {
267
+ this.onDataChanged = options.onDataChanged;
268
+ }
269
+ if (needsReload && this.onDataChanged) {
270
+ this.onDataChanged();
271
+ }
272
+ return { gpuFilterColumnsChanged };
273
+ }
274
+ /**
275
+ * 行データからポイントの色を取得する
276
+ * @param row 行データ
277
+ * @param columns カラム名の配列
278
+ * @returns RGBAカラーオブジェクト
279
+ */
280
+ getPointColor(row, columns) {
281
+ const colorIdx = columns.indexOf('__color__');
282
+ if (colorIdx === -1) {
283
+ return { r: 0.3, g: 0.3, b: 0.8, a: 0.3 };
284
+ }
285
+ const argbRaw = row[colorIdx];
286
+ const argb = typeof argbRaw === 'bigint' ? Number(argbRaw) : argbRaw;
287
+ return {
288
+ a: ((argb >>> 24) & 0xff) / 255,
289
+ r: ((argb >>> 16) & 0xff) / 255,
290
+ g: ((argb >>> 8) & 0xff) / 255,
291
+ b: (argb & 0xff) / 255,
292
+ };
293
+ }
294
+ /**
295
+ * 行データからポイントのサイズを取得する
296
+ * @param row 行データ
297
+ * @param columns カラム名の配列
298
+ * @returns ポイントサイズ
299
+ */
300
+ getPointSize(row, columns) {
301
+ const sizeIdx = columns.indexOf('__size__');
302
+ if (sizeIdx === -1) {
303
+ return 3;
304
+ }
305
+ return row[sizeIdx];
306
+ }
307
+ /**
308
+ * 画面座標に最も近いポイントを検索する
309
+ * @param screenX マウスのスクリーンX座標
310
+ * @param screenY マウスのスクリーンY座標
311
+ * @param canvasWidth キャンバスの幅(ピクセル)
312
+ * @param canvasHeight キャンバスの高さ(ピクセル)
313
+ * @param zoom 現在のズームレベル
314
+ * @param panX 現在のパンX
315
+ * @param panY 現在のパンY
316
+ * @param aspectRatio キャンバスのアスペクト比
317
+ * @param thresholdPixels ヒットと見なす最大距離(ピクセル、デフォルト: 10)
318
+ * @returns 見つかった場合はポイントデータ、そうでない場合はnull
319
+ */
320
+ async findNearestPoint(screenX, screenY, canvasWidth, canvasHeight, zoom, panX, panY, aspectRatio, thresholdPixels = 10) {
321
+ if (this.allPointsCache.length == 0 || this.repository == null) {
322
+ return null;
323
+ }
324
+ const clipX = (screenX / canvasWidth) * 2 - 1;
325
+ const clipY = -((screenY / canvasHeight) * 2 - 1);
326
+ const worldX = ((clipX - panX) * aspectRatio) / zoom;
327
+ const worldY = (clipY - panY) / zoom;
328
+ const thresholdClip = (thresholdPixels / canvasWidth) * 2;
329
+ const thresholdWorld = (thresholdClip * aspectRatio) / zoom;
330
+ let nearestId = null;
331
+ let nearestDistance = Infinity;
332
+ for (let i = 0; i < this.allPointsCache.length; i++) {
333
+ const pointX = this.allPointsCache[i].x;
334
+ const pointY = this.allPointsCache[i].y;
335
+ const dx = pointX - worldX;
336
+ const dy = pointY - worldY;
337
+ const distance = Math.sqrt(dx * dx + dy * dy);
338
+ if (distance < nearestDistance && distance <= thresholdWorld) {
339
+ nearestDistance = distance;
340
+ nearestId = this.allPointsCache[i].id;
341
+ }
342
+ }
343
+ if (nearestId == null) {
344
+ return null;
345
+ }
346
+ const data = await this.repository.query({
347
+ toString: () => `SELECT *, CAST((${this.sizeSql}) AS DOUBLE) AS __size__, CAST((${this.colorSql}) AS INTEGER) AS __color__ FROM parquet_data WHERE ${this.idColumn} = ${nearestId}`,
348
+ });
349
+ if (!data) {
350
+ return null;
351
+ }
352
+ return { row: this.buildRowFromData(data, 0), columns: data.columns };
353
+ }
354
+ /**
355
+ * データレイヤーが初期化されているかチェックする
356
+ * @returns 初期化されていればtrue
357
+ */
358
+ isInitialized() {
359
+ return this.repository !== null;
360
+ }
361
+ /**
362
+ * IDでポイントを検索する
363
+ * @param pointId 検索するポイントのidColumn値
364
+ * @returns 見つかった場合はポイントデータ、そうでない場合はnull
365
+ */
366
+ async findPointById(pointId) {
367
+ if (!this.repository) {
368
+ return null;
369
+ }
370
+ const escapedId = typeof pointId === 'string' ? `'${pointId.replace(/'/g, "''")}'` : pointId;
371
+ const data = await this.repository.query({
372
+ toString: () => `SELECT *, CAST((${this.sizeSql}) AS DOUBLE) AS __size__, CAST((${this.colorSql}) AS INTEGER) AS __color__ FROM parquet_data WHERE ${this.idColumn} = ${escapedId}`,
373
+ });
374
+ if (!data || data.rowCount === 0) {
375
+ return null;
376
+ }
377
+ return { row: this.buildRowFromData(data, 0), columns: data.columns };
378
+ }
379
+ /**
380
+ * ParquetDataから指定行のデータを配列として構築する
381
+ * @param data ParquetData
382
+ * @param rowIndex 行インデックス
383
+ * @returns 行データの配列
384
+ */
385
+ buildRowFromData(data, rowIndex) {
386
+ const row = new Array(data.columns.length);
387
+ for (let j = 0; j < data.columns.length; j++) {
388
+ const column = data.columnData.get(data.columns[j]);
389
+ row[j] = column?.get(rowIndex);
390
+ }
391
+ return row;
392
+ }
393
+ /**
394
+ * リソースをクリーンアップする
395
+ */
396
+ async destroy() {
397
+ if (this.repository) {
398
+ await this.repository.close();
399
+ this.repository = null;
400
+ }
401
+ }
402
+ }
@@ -0,0 +1,2 @@
1
+ export { DataLayer } from './data-layer.js';
2
+ export { createParquetReader, type ParquetData, type ParquetReader } from './repository.js';
@@ -0,0 +1,2 @@
1
+ export { DataLayer } from './data-layer.js';
2
+ export { createParquetReader } from './repository.js';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Parquetデータの構造を表すインターフェース
3
+ */
4
+ export interface ParquetData {
5
+ /** カラム名の配列 */
6
+ columns: string[];
7
+ /** カラム名からArrowベクター(型付き配列または値)へのマップ */
8
+ columnData: Map<string, any>;
9
+ /** 行数 */
10
+ rowCount: number;
11
+ }
12
+ /**
13
+ * DuckDB-WASMを使用してParquetファイルを読み込み、クエリを実行するクラス
14
+ */
15
+ export declare class ParquetReader {
16
+ private db;
17
+ private conn;
18
+ /**
19
+ * DuckDB-WASMを初期化する
20
+ */
21
+ initialize(): Promise<void>;
22
+ /**
23
+ * URLからParquetファイルを読み込み、テーブルを作成する
24
+ * @param url Parquetファイルのurl
25
+ * @param idColumn 一意のインデックスを作成するカラム名
26
+ */
27
+ loadParquetFromUrl(url: string, idColumn: string): Promise<void>;
28
+ /**
29
+ * SQLクエリを実行し、結果を返す
30
+ * @param queryObj toStringメソッドを持つクエリオブジェクト
31
+ * @returns クエリ結果のParquetData
32
+ */
33
+ query(queryObj: any): Promise<ParquetData>;
34
+ /**
35
+ * GeoJSONデータをテーブルとして読み込む
36
+ * @param geojson GeoJSON FeatureCollectionオブジェクト
37
+ */
38
+ loadGeoJson(geojson: any): Promise<void>;
39
+ /**
40
+ * データベース接続を閉じてリソースを解放する
41
+ */
42
+ close(): Promise<void>;
43
+ }
44
+ /**
45
+ * ParquetReaderインスタンスを作成し、初期化して返す
46
+ * @returns 初期化済みのParquetReaderインスタンス
47
+ */
48
+ export declare function createParquetReader(): Promise<ParquetReader>;
@@ -0,0 +1,109 @@
1
+ import * as duckdb from '@duckdb/duckdb-wasm';
2
+ /** データベース未初期化エラーメッセージ */
3
+ const ERROR_DB_NOT_INITIALIZED = 'Database not initialized. Call initialize() first.';
4
+ /**
5
+ * DuckDB-WASMを使用してParquetファイルを読み込み、クエリを実行するクラス
6
+ */
7
+ export class ParquetReader {
8
+ constructor() {
9
+ this.db = null;
10
+ this.conn = null;
11
+ }
12
+ /**
13
+ * DuckDB-WASMを初期化する
14
+ */
15
+ async initialize() {
16
+ const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
17
+ const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
18
+ const worker_url = URL.createObjectURL(new Blob([`importScripts("${bundle.mainWorker}");`], { type: 'text/javascript' }));
19
+ const worker = new Worker(worker_url);
20
+ const logger = new duckdb.ConsoleLogger();
21
+ this.db = new duckdb.AsyncDuckDB(logger, worker);
22
+ await this.db.instantiate(bundle.mainModule, bundle.pthreadWorker);
23
+ URL.revokeObjectURL(worker_url);
24
+ this.conn = await this.db.connect();
25
+ }
26
+ /**
27
+ * URLからParquetファイルを読み込み、テーブルを作成する
28
+ * @param url Parquetファイルのurl
29
+ * @param idColumn 一意のインデックスを作成するカラム名
30
+ */
31
+ async loadParquetFromUrl(url, idColumn) {
32
+ if (!this.conn) {
33
+ throw new Error(ERROR_DB_NOT_INITIALIZED);
34
+ }
35
+ const response = await fetch(url);
36
+ const arrayBuffer = await response.arrayBuffer();
37
+ const uint8Array = new Uint8Array(arrayBuffer);
38
+ await this.db.registerFileBuffer('temp.parquet', uint8Array);
39
+ await this.conn.query(`CREATE TABLE IF NOT EXISTS parquet_data AS SELECT * FROM read_parquet('temp.parquet')`);
40
+ await this.db.dropFile('temp.parquet');
41
+ await this.conn.query(`CREATE UNIQUE INDEX idx_${idColumn} ON parquet_data (${idColumn});`);
42
+ }
43
+ /**
44
+ * SQLクエリを実行し、結果を返す
45
+ * @param queryObj toStringメソッドを持つクエリオブジェクト
46
+ * @returns クエリ結果のParquetData
47
+ */
48
+ async query(queryObj) {
49
+ if (!this.conn) {
50
+ throw new Error(ERROR_DB_NOT_INITIALIZED);
51
+ }
52
+ const rawSql = queryObj.toString();
53
+ const result = await this.conn.query(rawSql);
54
+ const columns = result.schema.fields.map((field) => field.name);
55
+ const columnData = new Map();
56
+ for (let j = 0; j < result.numCols; j++) {
57
+ const column = result.getChildAt(j);
58
+ const columnName = columns[j];
59
+ columnData.set(columnName, column);
60
+ }
61
+ return {
62
+ columns,
63
+ columnData,
64
+ rowCount: result.numRows,
65
+ };
66
+ }
67
+ /**
68
+ * GeoJSONデータをテーブルとして読み込む
69
+ * @param geojson GeoJSON FeatureCollectionオブジェクト
70
+ */
71
+ async loadGeoJson(geojson) {
72
+ if (!this.conn) {
73
+ throw new Error(ERROR_DB_NOT_INITIALIZED);
74
+ }
75
+ const features = geojson.features;
76
+ if (features.length === 0)
77
+ return;
78
+ const values = features.map((f) => {
79
+ const coords = f.geometry?.coordinates || [0, 0];
80
+ const props = f.properties || {};
81
+ return { x: coords[0], y: coords[1], ...props };
82
+ });
83
+ await this.db.registerFileText('label_data.json', JSON.stringify(values));
84
+ await this.conn.query(`CREATE TABLE IF NOT EXISTS label_data AS SELECT * FROM read_json_auto('label_data.json')`);
85
+ await this.db.dropFile('label_data.json');
86
+ }
87
+ /**
88
+ * データベース接続を閉じてリソースを解放する
89
+ */
90
+ async close() {
91
+ if (this.conn) {
92
+ await this.conn.close();
93
+ this.conn = null;
94
+ }
95
+ if (this.db) {
96
+ await this.db.terminate();
97
+ this.db = null;
98
+ }
99
+ }
100
+ }
101
+ /**
102
+ * ParquetReaderインスタンスを作成し、初期化して返す
103
+ * @returns 初期化済みのParquetReaderインスタンス
104
+ */
105
+ export async function createParquetReader() {
106
+ const reader = new ParquetReader();
107
+ await reader.initialize();
108
+ return reader;
109
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * WebGPU診断結果のインターフェース
3
+ */
4
+ export interface WebGPUDiagnostics {
5
+ /** WebGPU APIがサポートされているか */
6
+ supported: boolean;
7
+ /** WebGPUが利用可能か */
8
+ available: boolean;
9
+ /** GPUアダプター情報 */
10
+ adapter: {
11
+ /** アダプターが利用可能か */
12
+ available: boolean;
13
+ /** サポートされている機能の配列 */
14
+ features?: string[];
15
+ /** GPUの制限値 */
16
+ limits?: Record<string, number>;
17
+ };
18
+ /** ブラウザ情報 */
19
+ browser: string;
20
+ /** エラーメッセージ(ある場合) */
21
+ error?: string;
22
+ }
23
+ /**
24
+ * WebGPUの利用可能性を診断する
25
+ * @returns 診断結果のWebGPUDiagnosticsオブジェクト
26
+ */
27
+ export declare function diagnoseWebGPU(): Promise<WebGPUDiagnostics>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * WebGPUの利用可能性を診断する
3
+ * @returns 診断結果のWebGPUDiagnosticsオブジェクト
4
+ */
5
+ export async function diagnoseWebGPU() {
6
+ const result = {
7
+ supported: false,
8
+ available: false,
9
+ adapter: {
10
+ available: false,
11
+ },
12
+ browser: getBrowserInfo(),
13
+ };
14
+ if (!('gpu' in navigator)) {
15
+ result.error = 'WebGPU API not found in navigator';
16
+ return result;
17
+ }
18
+ result.supported = true;
19
+ try {
20
+ const adapter = await navigator.gpu.requestAdapter();
21
+ if (!adapter) {
22
+ result.error = 'GPU adapter is null - GPU may be blocklisted or WebGPU is disabled';
23
+ return result;
24
+ }
25
+ result.adapter.available = true;
26
+ result.adapter.features = Array.from(adapter.features);
27
+ result.adapter.limits = {};
28
+ const limitsToCheck = [
29
+ 'maxTextureDimension1D',
30
+ 'maxTextureDimension2D',
31
+ 'maxBufferSize',
32
+ 'maxVertexBuffers',
33
+ 'maxVertexAttributes',
34
+ ];
35
+ for (const limit of limitsToCheck) {
36
+ if (limit in adapter.limits) {
37
+ result.adapter.limits[limit] = adapter.limits[limit];
38
+ }
39
+ }
40
+ result.available = true;
41
+ }
42
+ catch (e) {
43
+ result.error = `Error requesting adapter: ${e}`;
44
+ return result;
45
+ }
46
+ return result;
47
+ }
48
+ /**
49
+ * ブラウザ情報を取得する
50
+ * @returns ブラウザ名とバージョンの文字列
51
+ */
52
+ function getBrowserInfo() {
53
+ const ua = navigator.userAgent;
54
+ if (ua.includes('Chrome') && !ua.includes('Edg')) {
55
+ const match = ua.match(/Chrome\/(\d+)/);
56
+ return match ? `Chrome ${match[1]}` : 'Chrome (unknown version)';
57
+ }
58
+ if (ua.includes('Edg')) {
59
+ const match = ua.match(/Edg\/(\d+)/);
60
+ return match ? `Edge ${match[1]}` : 'Edge (unknown version)';
61
+ }
62
+ if (ua.includes('Safari') && !ua.includes('Chrome')) {
63
+ const match = ua.match(/Version\/(\d+)/);
64
+ return match ? `Safari ${match[1]}` : 'Safari (unknown version)';
65
+ }
66
+ if (ua.includes('Firefox')) {
67
+ const match = ua.match(/Firefox\/(\d+)/);
68
+ return match ? `Firefox ${match[1]}` : 'Firefox (unknown version)';
69
+ }
70
+ return 'Unknown browser';
71
+ }
@@ -0,0 +1,22 @@
1
+ import type { ScatterPlotError, ErrorCode } from './types.js';
2
+ /**
3
+ * 指定されたコードとメッセージでScatterPlotErrorオブジェクトを作成する
4
+ *
5
+ * @param code エラーコード
6
+ * @param message 人間が読めるエラーメッセージ
7
+ * @param options 追加オプション
8
+ * @returns ScatterPlotErrorオブジェクト
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const error = createError(
13
+ * 'WEBGPU_NOT_SUPPORTED',
14
+ * 'WebGPU is not supported in this browser',
15
+ * { context: { userAgent: navigator.userAgent } }
16
+ * );
17
+ * ```
18
+ */
19
+ export declare function createError(code: ErrorCode, message: string, options?: {
20
+ cause?: Error;
21
+ context?: Record<string, unknown>;
22
+ }): ScatterPlotError;