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