@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,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,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
|
+
}
|