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