@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,150 @@
1
+ 'use client';
2
+
3
+ import { useRef, useEffect, useCallback, useState } from 'react';
4
+ import { useScatterPlot } from '../context/ScatterPlotContext';
5
+ import { HoverInfoDisplay } from './HoverInfoDisplay';
6
+
7
+ export function ScatterPlotCanvas() {
8
+ const canvasRef = useRef<HTMLCanvasElement>(null);
9
+ const containerRef = useRef<HTMLDivElement>(null);
10
+ const { initializePlot, plot, state } = useScatterPlot();
11
+ const initializedRef = useRef(false);
12
+ const isDraggingRef = useRef(false);
13
+ const lastMouseRef = useRef({ x: 0, y: 0 });
14
+ const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
15
+
16
+ useEffect(() => {
17
+ if (canvasRef.current && !initializedRef.current) {
18
+ initializedRef.current = true;
19
+ initializePlot(canvasRef.current);
20
+ }
21
+ }, [initializePlot]);
22
+
23
+ const handleResize = useCallback(() => {
24
+ if (!containerRef.current || !canvasRef.current || !plot) return;
25
+
26
+ const { width, height } = containerRef.current.getBoundingClientRect();
27
+ const dpr = window.devicePixelRatio || 1;
28
+
29
+ canvasRef.current.width = width * dpr;
30
+ canvasRef.current.height = height * dpr;
31
+ canvasRef.current.style.width = `${width}px`;
32
+ canvasRef.current.style.height = `${height}px`;
33
+
34
+ plot.resize(width * dpr, height * dpr);
35
+ }, [plot]);
36
+
37
+ useEffect(() => {
38
+ window.addEventListener('resize', handleResize);
39
+ handleResize();
40
+ return () => window.removeEventListener('resize', handleResize);
41
+ }, [handleResize]);
42
+
43
+ // Zoom with wheel
44
+ useEffect(() => {
45
+ const canvas = canvasRef.current;
46
+ if (!canvas || !plot) return;
47
+
48
+ const handleWheel = (e: WheelEvent) => {
49
+ e.preventDefault();
50
+ const rect = canvas.getBoundingClientRect();
51
+ const x = e.clientX - rect.left;
52
+ const y = e.clientY - rect.top;
53
+ const dpr = window.devicePixelRatio || 1;
54
+
55
+ const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
56
+ const newZoom = plot.getZoom() * zoomFactor;
57
+ plot.zoomToPoint(newZoom, x * dpr, y * dpr);
58
+ plot.render();
59
+ };
60
+
61
+ canvas.addEventListener('wheel', handleWheel, { passive: false });
62
+ return () => canvas.removeEventListener('wheel', handleWheel);
63
+ }, [plot]);
64
+
65
+ // Pan with mouse drag
66
+ useEffect(() => {
67
+ const canvas = canvasRef.current;
68
+ if (!canvas || !plot) return;
69
+
70
+ const handleMouseDown = (e: MouseEvent) => {
71
+ isDraggingRef.current = true;
72
+ lastMouseRef.current = { x: e.clientX, y: e.clientY };
73
+ canvas.style.cursor = 'grabbing';
74
+ };
75
+
76
+ const handleMouseMove = (e: MouseEvent) => {
77
+ if (!isDraggingRef.current) return;
78
+ const dx = e.clientX - lastMouseRef.current.x;
79
+ const dy = e.clientY - lastMouseRef.current.y;
80
+ lastMouseRef.current = { x: e.clientX, y: e.clientY };
81
+
82
+ const rect = canvas.getBoundingClientRect();
83
+ // Convert pixel delta to clip space delta (-1 to 1)
84
+ const clipDx = (dx / rect.width) * 2;
85
+ const clipDy = -(dy / rect.height) * 2; // Y is inverted
86
+ plot.pan(clipDx, clipDy);
87
+ plot.render();
88
+ };
89
+
90
+ const handleMouseUp = () => {
91
+ isDraggingRef.current = false;
92
+ canvas.style.cursor = 'grab';
93
+ };
94
+
95
+ canvas.addEventListener('mousedown', handleMouseDown);
96
+ window.addEventListener('mousemove', handleMouseMove);
97
+ window.addEventListener('mouseup', handleMouseUp);
98
+ canvas.style.cursor = 'grab';
99
+
100
+ return () => {
101
+ canvas.removeEventListener('mousedown', handleMouseDown);
102
+ window.removeEventListener('mousemove', handleMouseMove);
103
+ window.removeEventListener('mouseup', handleMouseUp);
104
+ };
105
+ }, [plot]);
106
+
107
+ // Track mouse position for hover tooltip
108
+ useEffect(() => {
109
+ const container = containerRef.current;
110
+ if (!container) return;
111
+
112
+ const handleMouseMove = (e: MouseEvent) => {
113
+ setMousePosition({ x: e.clientX, y: e.clientY });
114
+ };
115
+
116
+ const handleMouseLeave = () => {
117
+ setMousePosition(null);
118
+ };
119
+
120
+ container.addEventListener('mousemove', handleMouseMove);
121
+ container.addEventListener('mouseleave', handleMouseLeave);
122
+
123
+ return () => {
124
+ container.removeEventListener('mousemove', handleMouseMove);
125
+ container.removeEventListener('mouseleave', handleMouseLeave);
126
+ };
127
+ }, []);
128
+
129
+ return (
130
+ <div ref={containerRef} className="absolute inset-0">
131
+ <canvas ref={canvasRef} className="block w-full h-full" />
132
+ {state.hoveredPoint && mousePosition && (
133
+ <HoverInfoDisplay mousePosition={mousePosition} />
134
+ )}
135
+ {state.isLoading && (
136
+ <div className="absolute inset-0 flex items-center justify-center bg-black/50">
137
+ <span className="text-white">Loading...</span>
138
+ </div>
139
+ )}
140
+ {state.error && (
141
+ <div className="absolute inset-0 flex items-center justify-center bg-black/80">
142
+ <div className="text-red-500 p-4 max-w-md text-center">
143
+ <p className="font-bold">Error</p>
144
+ <p>{state.error}</p>
145
+ </div>
146
+ </div>
147
+ )}
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import { useScatterPlot } from '../context/ScatterPlotContext';
5
+
6
+ export function SearchBox() {
7
+ const [value, setValue] = useState('');
8
+ const { updateSearch } = useScatterPlot();
9
+ const debounceRef = useRef<NodeJS.Timeout | null>(null);
10
+
11
+ useEffect(() => {
12
+ if (debounceRef.current) {
13
+ clearTimeout(debounceRef.current);
14
+ }
15
+ debounceRef.current = setTimeout(() => {
16
+ updateSearch(value);
17
+ }, 300);
18
+
19
+ return () => {
20
+ if (debounceRef.current) {
21
+ clearTimeout(debounceRef.current);
22
+ }
23
+ };
24
+ }, [value, updateSearch]);
25
+
26
+ return (
27
+ <div className="flex flex-col gap-2">
28
+ <label className="text-sm font-medium text-zinc-700">Search Words</label>
29
+ <input
30
+ type="text"
31
+ value={value}
32
+ onChange={(e) => setValue(e.target.value)}
33
+ placeholder="Filter by word..."
34
+ className="px-3 py-2 bg-white border border-zinc-300 rounded text-zinc-800 text-sm"
35
+ />
36
+ {value && (
37
+ <button
38
+ onClick={() => setValue('')}
39
+ className="text-xs text-zinc-500 hover:text-zinc-800 self-start"
40
+ >
41
+ Clear filter
42
+ </button>
43
+ )}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+
5
+ interface SliderProps {
6
+ /** ラベルテキスト */
7
+ label: string;
8
+ /** 最小値 */
9
+ min: number;
10
+ /** 最大値 */
11
+ max: number;
12
+ /** ステップ値 */
13
+ step: number;
14
+ /** 初期値 */
15
+ defaultValue: number;
16
+ /** 値変更時のコールバック */
17
+ onChange: (value: number) => void;
18
+ /** 値のパース関数(デフォルト: parseFloat) */
19
+ parseValue?: (v: string) => number;
20
+ /** 表示用のフォーマット関数(デフォルト: 小数点2桁) */
21
+ formatValue?: (v: number) => string;
22
+ /** 左端のラベル */
23
+ minLabel?: string;
24
+ /** 右端のラベル */
25
+ maxLabel?: string;
26
+ }
27
+
28
+ /**
29
+ * 汎用スライダーコンポーネント
30
+ */
31
+ export function Slider({
32
+ label,
33
+ min,
34
+ max,
35
+ step,
36
+ defaultValue,
37
+ onChange,
38
+ parseValue = parseFloat,
39
+ formatValue = (v) => v.toFixed(2),
40
+ minLabel,
41
+ maxLabel,
42
+ }: SliderProps) {
43
+ const [value, setValue] = useState(defaultValue);
44
+
45
+ const handleChange = useCallback(
46
+ (e: React.ChangeEvent<HTMLInputElement>) => {
47
+ const newValue = parseValue(e.target.value);
48
+ setValue(newValue);
49
+ onChange(newValue);
50
+ },
51
+ [onChange, parseValue]
52
+ );
53
+
54
+ return (
55
+ <div className="flex flex-col gap-2">
56
+ <label className="text-sm font-medium text-zinc-700">
57
+ {label}: {formatValue(value)}
58
+ </label>
59
+ <input
60
+ type="range"
61
+ min={min}
62
+ max={max}
63
+ step={step}
64
+ value={value}
65
+ onChange={handleChange}
66
+ className="w-full h-2 bg-zinc-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
67
+ />
68
+ {(minLabel || maxLabel) && (
69
+ <div className="flex justify-between text-xs text-zinc-500">
70
+ <span>{minLabel ?? min}</span>
71
+ <span>{maxLabel ?? max}</span>
72
+ </div>
73
+ )}
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ import { useScatterPlot } from '../context/ScatterPlotContext';
4
+
5
+ export function StatsDisplay() {
6
+ const { state } = useScatterPlot();
7
+
8
+ if (state.pointCount === null) return null;
9
+
10
+ return (
11
+ <span className="text-sm text-zinc-500">
12
+ {state.pointCount.toLocaleString()} points
13
+ </span>
14
+ );
15
+ }
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+ import { useScatterPlot } from '../context/ScatterPlotContext';
5
+
6
+ export function TimeFilterSlider() {
7
+ const { state, updateTimeFilter } = useScatterPlot();
8
+ const { timeRange } = state;
9
+
10
+ const [minValue, setMinValue] = useState<number>(0);
11
+ const [maxValue, setMaxValue] = useState<number>(0);
12
+ const [prevTimeRange, setPrevTimeRange] = useState(timeRange);
13
+
14
+ // timeRangeが変更されたら値を更新(レンダー中のステート更新パターン)
15
+ if (timeRange !== prevTimeRange) {
16
+ setPrevTimeRange(timeRange);
17
+ if (timeRange) {
18
+ setMinValue(timeRange.min);
19
+ setMaxValue(timeRange.max);
20
+ }
21
+ }
22
+
23
+ const handleMinChange = useCallback(
24
+ (e: React.ChangeEvent<HTMLInputElement>) => {
25
+ const newMin = parseFloat(e.target.value);
26
+ // maxValueを超えないように制限
27
+ const clampedMin = Math.min(newMin, maxValue);
28
+ setMinValue(clampedMin);
29
+ updateTimeFilter(clampedMin, maxValue);
30
+ },
31
+ [maxValue, updateTimeFilter]
32
+ );
33
+
34
+ const handleMaxChange = useCallback(
35
+ (e: React.ChangeEvent<HTMLInputElement>) => {
36
+ const newMax = parseFloat(e.target.value);
37
+ // minValueを下回らないように制限
38
+ const clampedMax = Math.max(newMax, minValue);
39
+ setMaxValue(clampedMax);
40
+ updateTimeFilter(minValue, clampedMax);
41
+ },
42
+ [minValue, updateTimeFilter]
43
+ );
44
+
45
+ const handleReset = useCallback(() => {
46
+ if (timeRange) {
47
+ setMinValue(timeRange.min);
48
+ setMaxValue(timeRange.max);
49
+ updateTimeFilter(null, null);
50
+ }
51
+ }, [timeRange, updateTimeFilter]);
52
+
53
+ // 日時フォーマット関数(UNIXタイムスタンプ秒 → 日付文字列)
54
+ const formatTime = (timestamp: number) => {
55
+ // タイムスタンプが大きい場合はミリ秒として扱う
56
+ const ts = timestamp > 1e12 ? timestamp : timestamp * 1000;
57
+ return new Date(ts).toLocaleDateString('ja-JP', {
58
+ year: 'numeric',
59
+ month: 'short',
60
+ day: 'numeric',
61
+ });
62
+ };
63
+
64
+ // 範囲が未取得の場合は非表示
65
+ if (!timeRange) {
66
+ return null;
67
+ }
68
+
69
+ // 選択範囲の割合を計算(トラックのハイライト用)
70
+ const range = timeRange.max - timeRange.min;
71
+ const minPercent = range > 0 ? ((minValue - timeRange.min) / range) * 100 : 0;
72
+ const maxPercent = range > 0 ? ((maxValue - timeRange.min) / range) * 100 : 100;
73
+
74
+ return (
75
+ <div className="flex flex-col gap-2">
76
+ <div className="flex justify-between items-center">
77
+ <label className="text-sm font-medium text-zinc-700">Time Filter</label>
78
+ <button
79
+ onClick={handleReset}
80
+ className="text-xs text-zinc-500 hover:text-zinc-800 underline"
81
+ >
82
+ Reset
83
+ </button>
84
+ </div>
85
+
86
+ <div className="relative h-6">
87
+ {/* トラック背景 */}
88
+ <div className="absolute top-1/2 -translate-y-1/2 w-full h-2 bg-zinc-200 rounded-lg" />
89
+
90
+ {/* 選択範囲ハイライト */}
91
+ <div
92
+ className="absolute top-1/2 -translate-y-1/2 h-2 bg-blue-500 rounded-lg"
93
+ style={{
94
+ left: `${minPercent}%`,
95
+ width: `${maxPercent - minPercent}%`,
96
+ }}
97
+ />
98
+
99
+ {/* 最小値スライダー */}
100
+ <input
101
+ type="range"
102
+ min={timeRange.min}
103
+ max={timeRange.max}
104
+ step={(timeRange.max - timeRange.min) / 1000}
105
+ value={minValue}
106
+ onChange={handleMinChange}
107
+ className="absolute w-full h-2 top-1/2 -translate-y-1/2 appearance-none bg-transparent pointer-events-none
108
+ [&::-webkit-slider-thumb]:pointer-events-auto
109
+ [&::-webkit-slider-thumb]:w-4
110
+ [&::-webkit-slider-thumb]:h-4
111
+ [&::-webkit-slider-thumb]:appearance-none
112
+ [&::-webkit-slider-thumb]:bg-blue-500
113
+ [&::-webkit-slider-thumb]:rounded-full
114
+ [&::-webkit-slider-thumb]:cursor-pointer
115
+ [&::-webkit-slider-thumb]:shadow-md
116
+ [&::-webkit-slider-thumb]:border-2
117
+ [&::-webkit-slider-thumb]:border-white
118
+ [&::-moz-range-thumb]:pointer-events-auto
119
+ [&::-moz-range-thumb]:w-4
120
+ [&::-moz-range-thumb]:h-4
121
+ [&::-moz-range-thumb]:appearance-none
122
+ [&::-moz-range-thumb]:bg-blue-500
123
+ [&::-moz-range-thumb]:rounded-full
124
+ [&::-moz-range-thumb]:cursor-pointer
125
+ [&::-moz-range-thumb]:shadow-md
126
+ [&::-moz-range-thumb]:border-2
127
+ [&::-moz-range-thumb]:border-white"
128
+ />
129
+
130
+ {/* 最大値スライダー */}
131
+ <input
132
+ type="range"
133
+ min={timeRange.min}
134
+ max={timeRange.max}
135
+ step={(timeRange.max - timeRange.min) / 1000}
136
+ value={maxValue}
137
+ onChange={handleMaxChange}
138
+ className="absolute w-full h-2 top-1/2 -translate-y-1/2 appearance-none bg-transparent pointer-events-none
139
+ [&::-webkit-slider-thumb]:pointer-events-auto
140
+ [&::-webkit-slider-thumb]:w-4
141
+ [&::-webkit-slider-thumb]:h-4
142
+ [&::-webkit-slider-thumb]:appearance-none
143
+ [&::-webkit-slider-thumb]:bg-blue-500
144
+ [&::-webkit-slider-thumb]:rounded-full
145
+ [&::-webkit-slider-thumb]:cursor-pointer
146
+ [&::-webkit-slider-thumb]:shadow-md
147
+ [&::-webkit-slider-thumb]:border-2
148
+ [&::-webkit-slider-thumb]:border-white
149
+ [&::-moz-range-thumb]:pointer-events-auto
150
+ [&::-moz-range-thumb]:w-4
151
+ [&::-moz-range-thumb]:h-4
152
+ [&::-moz-range-thumb]:appearance-none
153
+ [&::-moz-range-thumb]:bg-blue-500
154
+ [&::-moz-range-thumb]:rounded-full
155
+ [&::-moz-range-thumb]:cursor-pointer
156
+ [&::-moz-range-thumb]:shadow-md
157
+ [&::-moz-range-thumb]:border-2
158
+ [&::-moz-range-thumb]:border-white"
159
+ />
160
+ </div>
161
+
162
+ {/* 日時表示 */}
163
+ <div className="flex justify-between text-xs text-zinc-500">
164
+ <span>{formatTime(minValue)}</span>
165
+ <span>{formatTime(maxValue)}</span>
166
+ </div>
167
+ </div>
168
+ );
169
+ }