@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
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useState,
7
+ useCallback,
8
+ useRef,
9
+ useEffect,
10
+ type ReactNode,
11
+ } from 'react';
12
+ import type { ScatterPlot, WhereCondition, Label, PointId, LabelIdentifier, GpuWhereCondition } from '@uoa-css-lab/duckscatter';
13
+
14
+ interface ScatterPlotState {
15
+ isInitialized: boolean;
16
+ isLoading: boolean;
17
+ error: string | null;
18
+ hoveredPoint: { row: unknown[]; columns: string[] } | null;
19
+ hoveredLabel: Label | null;
20
+ pointCount: number | null;
21
+ /** created_atカラムの範囲(min/max) */
22
+ timeRange: { min: number; max: number } | null;
23
+ }
24
+
25
+ export interface PointListItem {
26
+ id: string | number;
27
+ x: number;
28
+ y: number;
29
+ }
30
+
31
+ interface ScatterPlotContextValue {
32
+ plot: ScatterPlot | null;
33
+ state: ScatterPlotState;
34
+ initializePlot: (canvas: HTMLCanvasElement) => Promise<void>;
35
+ updateSize: (sizeSql: string) => Promise<void>;
36
+ updateColor: (colorSql: string) => Promise<void>;
37
+ updateSearch: (searchText: string) => Promise<void>;
38
+ updatePointLimit: (limit: number) => Promise<void>;
39
+ updateLabelFilter: (searchText: string) => void;
40
+ /** GPU側で時間範囲フィルターを適用 */
41
+ updateTimeFilter: (min: number | null, max: number | null) => Promise<void>;
42
+ /** グローバル透明度を設定 */
43
+ updatePointAlpha: (alpha: number) => void;
44
+ /** グローバルサイズスケールを設定 */
45
+ updatePointSizeScale: (scale: number) => void;
46
+ // Hover control
47
+ setPointHover: (pointId: PointId) => Promise<boolean>;
48
+ setLabelHover: (identifier: LabelIdentifier) => boolean;
49
+ clearAllHover: () => void;
50
+ // List data
51
+ fetchPoints: (page: number, pageSize: number) => Promise<PointListItem[]>;
52
+ getLabels: () => Label[];
53
+ }
54
+
55
+ const ScatterPlotContext = createContext<ScatterPlotContextValue | null>(null);
56
+
57
+ export function ScatterPlotProvider({ children }: { children: ReactNode }) {
58
+ const plotRef = useRef<ScatterPlot | null>(null);
59
+ const [state, setState] = useState<ScatterPlotState>({
60
+ isInitialized: false,
61
+ isLoading: false,
62
+ error: null,
63
+ hoveredPoint: null,
64
+ hoveredLabel: null,
65
+ pointCount: null,
66
+ timeRange: null,
67
+ });
68
+
69
+ const filtersRef = useRef<{
70
+ searchText: string;
71
+ sizeSql: string;
72
+ colorSql: string;
73
+ visiblePointLimit: number;
74
+ timeRangeFilter: { min: number | null; max: number | null };
75
+ }>({
76
+ searchText: '',
77
+ sizeSql: '5',
78
+ colorSql: '(CAST(255 AS BIGINT) * 16777216 + 59 * 65536 + 130 * 256 + 246 - CASE WHEN (CAST(255 AS BIGINT) * 16777216 + 59 * 65536 + 130 * 256 + 246) > 2147483647 THEN 4294967296 ELSE 0 END)::INTEGER',
79
+ visiblePointLimit: 100000,
80
+ timeRangeFilter: { min: null, max: null },
81
+ });
82
+
83
+ const buildWhereConditions = useCallback((): WhereCondition[] => {
84
+ const conditions: WhereCondition[] = [];
85
+ if (filtersRef.current.searchText) {
86
+ conditions.push({
87
+ type: 'string',
88
+ column: 'token',
89
+ operator: 'contains',
90
+ value: filtersRef.current.searchText,
91
+ });
92
+ }
93
+ return conditions;
94
+ }, []);
95
+
96
+ const initializePlot = useCallback(
97
+ async (canvas: HTMLCanvasElement) => {
98
+ const { ScatterPlot } = await import('@uoa-css-lab/duckscatter');
99
+
100
+ setState((s) => ({ ...s, isLoading: true, error: null }));
101
+
102
+ const plot = new ScatterPlot({
103
+ canvas,
104
+ dataUrl: '/output.parquet',
105
+ data: {
106
+ idColumn: '__index_level_0__',
107
+ sizeSql: filtersRef.current.sizeSql,
108
+ colorSql: filtersRef.current.colorSql,
109
+ visiblePointLimit: filtersRef.current.visiblePointLimit,
110
+ gpuFilterColumns: ['created_at'],
111
+ },
112
+ gpu: {
113
+ backgroundColor: { r: 0.85, g: 0.85, b: 0.85, a: 1.0 },
114
+ },
115
+ labels: {
116
+ url: 'label.geojson',
117
+ onClick: (label) => {
118
+ console.log('Label clicked:', label);
119
+ },
120
+ hoverOutlineOptions: {
121
+ enabled: true,
122
+ color: '#ffffff',
123
+ width: 2,
124
+ },
125
+ },
126
+ interaction: {
127
+ onPointHover: (data) => {
128
+ setState((s) => ({ ...s, hoveredPoint: data }));
129
+ },
130
+ onLabelHover: (label) => {
131
+ setState((s) => ({ ...s, hoveredLabel: label }));
132
+ },
133
+ },
134
+ });
135
+
136
+ plot.on('error', (error) => {
137
+ setState((s) => ({ ...s, error: error.message }));
138
+ });
139
+
140
+ try {
141
+ await plot.initialize();
142
+ plotRef.current = plot;
143
+ plot.render();
144
+
145
+ // Get point count
146
+ const result = await plot.runQuery('SELECT COUNT(*) as count FROM parquet_data');
147
+ let pointCount = 0;
148
+ if (result && result.rowCount > 0) {
149
+ const countCol = result.columnData.get('count');
150
+ if (countCol) {
151
+ pointCount = Number(countCol[0]);
152
+ }
153
+ }
154
+
155
+ // Get time range (created_at min/max)
156
+ let timeRange: { min: number; max: number } | null = null;
157
+ const timeResult = await plot.runQuery(
158
+ 'SELECT MIN(created_at) as min_time, MAX(created_at) as max_time FROM parquet_data'
159
+ );
160
+ if (timeResult && timeResult.rowCount > 0) {
161
+ const minCol = timeResult.columnData.get('min_time');
162
+ const maxCol = timeResult.columnData.get('max_time');
163
+ if (minCol && maxCol) {
164
+ const minVal = Number(minCol.get(0));
165
+ const maxVal = Number(maxCol.get(0));
166
+ if (!isNaN(minVal) && !isNaN(maxVal)) {
167
+ timeRange = { min: minVal, max: maxVal };
168
+ }
169
+ }
170
+ }
171
+
172
+ setState((s) => ({ ...s, isInitialized: true, isLoading: false, pointCount, timeRange }));
173
+ } catch (e) {
174
+ setState((s) => ({
175
+ ...s,
176
+ isLoading: false,
177
+ error: e instanceof Error ? e.message : 'Failed to initialize',
178
+ }));
179
+ }
180
+ },
181
+ []
182
+ );
183
+
184
+ const updateSize = useCallback(
185
+ async (sizeSql: string) => {
186
+ if (!plotRef.current) return;
187
+ filtersRef.current.sizeSql = sizeSql;
188
+ await plotRef.current.update({
189
+ data: {
190
+ idColumn: '__index_level_0__',
191
+ sizeSql,
192
+ colorSql: filtersRef.current.colorSql,
193
+ whereConditions: buildWhereConditions(),
194
+ },
195
+ });
196
+ plotRef.current.render();
197
+ },
198
+ [buildWhereConditions]
199
+ );
200
+
201
+ const updateColor = useCallback(
202
+ async (colorSql: string) => {
203
+ if (!plotRef.current) return;
204
+ filtersRef.current.colorSql = colorSql;
205
+ await plotRef.current.update({
206
+ data: {
207
+ idColumn: '__index_level_0__',
208
+ sizeSql: filtersRef.current.sizeSql,
209
+ colorSql,
210
+ whereConditions: buildWhereConditions(),
211
+ },
212
+ });
213
+ plotRef.current.render();
214
+ },
215
+ [buildWhereConditions]
216
+ );
217
+
218
+ const updateSearch = useCallback(
219
+ async (searchText: string) => {
220
+ if (!plotRef.current) return;
221
+ filtersRef.current.searchText = searchText;
222
+ await plotRef.current.update({
223
+ data: {
224
+ idColumn: '__index_level_0__',
225
+ sizeSql: filtersRef.current.sizeSql,
226
+ colorSql: filtersRef.current.colorSql,
227
+ whereConditions: buildWhereConditions(),
228
+ },
229
+ });
230
+ plotRef.current.render();
231
+ },
232
+ [buildWhereConditions]
233
+ );
234
+
235
+ const updatePointLimit = useCallback(
236
+ async (limit: number) => {
237
+ if (!plotRef.current) return;
238
+ filtersRef.current.visiblePointLimit = limit;
239
+ await plotRef.current.update({
240
+ data: {
241
+ idColumn: '__index_level_0__',
242
+ sizeSql: filtersRef.current.sizeSql,
243
+ colorSql: filtersRef.current.colorSql,
244
+ visiblePointLimit: limit,
245
+ whereConditions: buildWhereConditions(),
246
+ },
247
+ });
248
+ plotRef.current.render();
249
+ },
250
+ [buildWhereConditions]
251
+ );
252
+
253
+ // Hover control methods
254
+ const setPointHover = useCallback(async (pointId: PointId): Promise<boolean> => {
255
+ if (!plotRef.current) return false;
256
+ return await plotRef.current.setPointHover(pointId);
257
+ }, []);
258
+
259
+ const setLabelHover = useCallback((identifier: LabelIdentifier): boolean => {
260
+ if (!plotRef.current) return false;
261
+ return plotRef.current.setLabelHover(identifier);
262
+ }, []);
263
+
264
+ const clearAllHover = useCallback(() => {
265
+ if (!plotRef.current) return;
266
+ plotRef.current.clearAllHover();
267
+ }, []);
268
+
269
+ const updateLabelFilter = useCallback((searchText: string) => {
270
+ if (!plotRef.current) return;
271
+ if (searchText.trim() === '') {
272
+ plotRef.current.update({
273
+ labels: {
274
+ filterLambda: undefined,
275
+ },
276
+ });
277
+ } else {
278
+ const lowerSearch = searchText.toLowerCase();
279
+ plotRef.current.update({
280
+ labels: {
281
+ filterLambda: (properties) => {
282
+ const label = (properties.cluster_label as string) || '';
283
+ return label.toLowerCase().includes(lowerSearch);
284
+ },
285
+ },
286
+ });
287
+ }
288
+ plotRef.current.render();
289
+ }, []);
290
+
291
+ const updateTimeFilter = useCallback(
292
+ async (min: number | null, max: number | null) => {
293
+ if (!plotRef.current) return;
294
+ filtersRef.current.timeRangeFilter = { min, max };
295
+
296
+ // GPU側のフィルター条件を構築
297
+ const gpuConditions: GpuWhereCondition[] = [];
298
+ if (min !== null || max !== null) {
299
+ gpuConditions.push({
300
+ column: 'created_at',
301
+ min: min ?? undefined,
302
+ max: max ?? undefined,
303
+ });
304
+ }
305
+
306
+ await plotRef.current.update({
307
+ data: {
308
+ idColumn: '__index_level_0__',
309
+ sizeSql: filtersRef.current.sizeSql,
310
+ colorSql: filtersRef.current.colorSql,
311
+ gpuWhereConditions: gpuConditions,
312
+ },
313
+ });
314
+ plotRef.current.render();
315
+ },
316
+ []
317
+ );
318
+
319
+ const updatePointAlpha = useCallback((alpha: number) => {
320
+ if (!plotRef.current) return;
321
+ plotRef.current.setPointAlpha(alpha);
322
+ }, []);
323
+
324
+ const updatePointSizeScale = useCallback((scale: number) => {
325
+ if (!plotRef.current) return;
326
+ plotRef.current.setPointSizeScale(scale);
327
+ }, []);
328
+
329
+ // List data methods
330
+ const fetchPoints = useCallback(
331
+ async (page: number, pageSize: number): Promise<PointListItem[]> => {
332
+ if (!plotRef.current) return [];
333
+ const offset = page * pageSize;
334
+ const result = await plotRef.current.runQuery(
335
+ `SELECT __index_level_0__ as id, x, y FROM parquet_data ORDER BY __index_level_0__ LIMIT ${pageSize} OFFSET ${offset}`
336
+ );
337
+ if (!result || result.rowCount === 0) return [];
338
+
339
+ const idCol = result.columnData.get('id');
340
+ const xCol = result.columnData.get('x');
341
+ const yCol = result.columnData.get('y');
342
+
343
+ const points: PointListItem[] = [];
344
+ for (let i = 0; i < result.rowCount; i++) {
345
+ points.push({
346
+ id: idCol?.get(i),
347
+ x: xCol?.get(i),
348
+ y: yCol?.get(i),
349
+ });
350
+ }
351
+ return points;
352
+ },
353
+ []
354
+ );
355
+
356
+ const getLabels = useCallback((): Label[] => {
357
+ if (!plotRef.current) return [];
358
+ return plotRef.current.getLabels();
359
+ }, []);
360
+
361
+ useEffect(() => {
362
+ return () => {
363
+ if (plotRef.current) {
364
+ plotRef.current.destroy();
365
+ plotRef.current = null;
366
+ }
367
+ };
368
+ }, []);
369
+
370
+ return (
371
+ <ScatterPlotContext.Provider
372
+ value={{
373
+ plot: plotRef.current,
374
+ state,
375
+ initializePlot,
376
+ updateSize,
377
+ updateColor,
378
+ updateSearch,
379
+ updatePointLimit,
380
+ updateLabelFilter,
381
+ updateTimeFilter,
382
+ updatePointAlpha,
383
+ updatePointSizeScale,
384
+ setPointHover,
385
+ setLabelHover,
386
+ clearAllHover,
387
+ fetchPoints,
388
+ getLabels,
389
+ }}
390
+ >
391
+ {children}
392
+ </ScatterPlotContext.Provider>
393
+ );
394
+ }
395
+
396
+ export function useScatterPlot() {
397
+ const context = useContext(ScatterPlotContext);
398
+ if (!context) {
399
+ throw new Error('useScatterPlot must be used within ScatterPlotProvider');
400
+ }
401
+ return context;
402
+ }
Binary file
@@ -0,0 +1,23 @@
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --background: #ffffff;
5
+ --foreground: #171717;
6
+ }
7
+
8
+ @theme inline {
9
+ --color-background: var(--background);
10
+ --color-foreground: var(--foreground);
11
+ --font-sans: var(--font-geist-sans);
12
+ --font-mono: var(--font-geist-mono);
13
+ }
14
+
15
+
16
+ html,
17
+ body {
18
+ height: 100%;
19
+ overflow: hidden;
20
+ background: var(--background);
21
+ color: var(--foreground);
22
+ font-family: Arial, Helvetica, sans-serif;
23
+ }
@@ -0,0 +1,35 @@
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+ import { ScatterPlotProvider } from "./context/ScatterPlotContext";
5
+
6
+ const geistSans = Geist({
7
+ variable: "--font-geist-sans",
8
+ subsets: ["latin"],
9
+ });
10
+
11
+ const geistMono = Geist_Mono({
12
+ variable: "--font-geist-mono",
13
+ subsets: ["latin"],
14
+ });
15
+
16
+ export const metadata: Metadata = {
17
+ title: "DuckScatter Example",
18
+ description: "Next.js example for duckscatter library",
19
+ };
20
+
21
+ export default function RootLayout({
22
+ children,
23
+ }: Readonly<{
24
+ children: React.ReactNode;
25
+ }>) {
26
+ return (
27
+ <html lang="en">
28
+ <body
29
+ className={`${geistSans.variable} ${geistMono.variable} antialiased`}
30
+ >
31
+ <ScatterPlotProvider>{children}</ScatterPlotProvider>
32
+ </body>
33
+ </html>
34
+ );
35
+ }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ import { ScatterPlotCanvas } from './components/ScatterPlotCanvas';
4
+ import { ControlPanel } from './components/ControlPanel';
5
+
6
+ export default function Home() {
7
+ return (
8
+ <div className="h-screen w-screen flex overflow-hidden">
9
+ <ControlPanel />
10
+ <main className="flex-1 relative">
11
+ <ScatterPlotCanvas />
12
+ </main>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;