@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.
- package/.github/dependabot.yml +42 -0
- package/.github/workflows/ci.yaml +111 -0
- package/.github/workflows/release.yml +55 -0
- package/.prettierrc +11 -0
- package/LICENSE +22 -0
- package/README.md +250 -0
- package/dist/data/data-layer.d.ts +169 -0
- package/dist/data/data-layer.js +402 -0
- package/dist/data/index.d.ts +2 -0
- package/dist/data/index.js +2 -0
- package/dist/data/repository.d.ts +48 -0
- package/dist/data/repository.js +109 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +71 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +58 -0
- package/dist/event-emitter.d.ts +62 -0
- package/dist/event-emitter.js +82 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/dist/renderer/gpu-layer.d.ts +204 -0
- package/dist/renderer/gpu-layer.js +611 -0
- package/dist/renderer/index.d.ts +3 -0
- package/dist/renderer/index.js +3 -0
- package/dist/renderer/shaders.d.ts +13 -0
- package/dist/renderer/shaders.js +216 -0
- package/dist/renderer/webgpu-context.d.ts +20 -0
- package/dist/renderer/webgpu-context.js +88 -0
- package/dist/scatter-plot.d.ts +210 -0
- package/dist/scatter-plot.js +450 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.js +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/label-layer.d.ts +176 -0
- package/dist/ui/label-layer.js +488 -0
- package/docs/image.png +0 -0
- package/eslint.config.js +72 -0
- package/examples/next/README.md +36 -0
- package/examples/next/app/components/ColorExpressionInput.tsx +41 -0
- package/examples/next/app/components/ControlPanel.tsx +30 -0
- package/examples/next/app/components/HoverControlPanel.tsx +69 -0
- package/examples/next/app/components/HoverInfoDisplay.tsx +40 -0
- package/examples/next/app/components/LabelFilterInput.tsx +46 -0
- package/examples/next/app/components/LabelList.tsx +106 -0
- package/examples/next/app/components/PointAlphaSlider.tsx +21 -0
- package/examples/next/app/components/PointLimitSlider.tsx +23 -0
- package/examples/next/app/components/PointList.tsx +105 -0
- package/examples/next/app/components/PointSizeScaleSlider.tsx +22 -0
- package/examples/next/app/components/ScatterPlotCanvas.tsx +150 -0
- package/examples/next/app/components/SearchBox.tsx +46 -0
- package/examples/next/app/components/Slider.tsx +76 -0
- package/examples/next/app/components/StatsDisplay.tsx +15 -0
- package/examples/next/app/components/TimeFilterSlider.tsx +169 -0
- package/examples/next/app/context/ScatterPlotContext.tsx +402 -0
- package/examples/next/app/favicon.ico +0 -0
- package/examples/next/app/globals.css +23 -0
- package/examples/next/app/layout.tsx +35 -0
- package/examples/next/app/page.tsx +15 -0
- package/examples/next/eslint.config.mjs +18 -0
- package/examples/next/next.config.ts +7 -0
- package/examples/next/package-lock.json +6572 -0
- package/examples/next/package.json +27 -0
- package/examples/next/postcss.config.mjs +7 -0
- package/examples/next/scripts/generate_labels.py +167 -0
- package/examples/next/tsconfig.json +34 -0
- package/package.json +43 -0
- package/src/data/data-layer.ts +515 -0
- package/src/data/index.ts +2 -0
- package/src/data/repository.ts +146 -0
- package/src/diagnostics.ts +108 -0
- package/src/errors.ts +69 -0
- package/src/event-emitter.ts +88 -0
- package/src/index.ts +40 -0
- package/src/renderer/gpu-layer.ts +757 -0
- package/src/renderer/index.ts +3 -0
- package/src/renderer/shaders.ts +219 -0
- package/src/renderer/webgpu-context.ts +98 -0
- package/src/scatter-plot.ts +533 -0
- package/src/types.ts +218 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/label-layer.ts +648 -0
- 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,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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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;
|