@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,72 @@
1
+ const eslint = require('@eslint/js');
2
+ const tseslint = require('typescript-eslint');
3
+ const prettierPlugin = require('eslint-plugin-prettier');
4
+ const prettierConfig = require('eslint-config-prettier');
5
+
6
+ module.exports = tseslint.config(
7
+ // Base ESLint recommended config
8
+ eslint.configs.recommended,
9
+
10
+ // TypeScript ESLint recommended configs
11
+ ...tseslint.configs.recommended,
12
+
13
+ // Prettier integration
14
+ {
15
+ plugins: {
16
+ prettier: prettierPlugin,
17
+ },
18
+ rules: {
19
+ ...prettierConfig.rules,
20
+ 'prettier/prettier': 'error',
21
+ },
22
+ },
23
+
24
+ // Custom configuration
25
+ {
26
+ files: ['**/*.ts', '**/*.tsx'],
27
+ languageOptions: {
28
+ ecmaVersion: 'latest',
29
+ sourceType: 'module',
30
+ parser: tseslint.parser,
31
+ parserOptions: {
32
+ project: './tsconfig.json',
33
+ },
34
+ },
35
+ rules: {
36
+ '@typescript-eslint/explicit-function-return-type': 'off',
37
+ '@typescript-eslint/no-explicit-any': 'warn',
38
+ '@typescript-eslint/no-unused-vars': [
39
+ 'error',
40
+ {
41
+ argsIgnorePattern: '^_',
42
+ varsIgnorePattern: '^_',
43
+ },
44
+ ],
45
+ '@typescript-eslint/consistent-type-imports': 'error',
46
+ 'no-console': 'warn',
47
+ },
48
+ },
49
+
50
+ // Files to ignore
51
+ {
52
+ ignores: [
53
+ 'node_modules/**',
54
+ 'dist/**',
55
+ 'build/**',
56
+ '*.js',
57
+ '*.d.ts',
58
+ '*.config.js',
59
+ '*.config.ts',
60
+ 'package-lock.json',
61
+ 'pnpm-lock.yaml',
62
+ 'yarn.lock',
63
+ '.vscode/**',
64
+ '.idea/**',
65
+ '*.tmp',
66
+ '*.log',
67
+ 'temp/**',
68
+ 'tmp/**',
69
+ 'examples/next/**',
70
+ ],
71
+ }
72
+ );
@@ -0,0 +1,36 @@
1
+ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
+
3
+ ## Getting Started
4
+
5
+ First, run the development server:
6
+
7
+ ```bash
8
+ npm run dev
9
+ # or
10
+ yarn dev
11
+ # or
12
+ pnpm dev
13
+ # or
14
+ bun dev
15
+ ```
16
+
17
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
+
19
+ You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
+
21
+ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
+
23
+ ## Learn More
24
+
25
+ To learn more about Next.js, take a look at the following resources:
26
+
27
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
+
30
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
+
32
+ ## Deploy on Vercel
33
+
34
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
+
36
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useScatterPlot } from '../context/ScatterPlotContext';
5
+
6
+ export function ColorExpressionInput() {
7
+ const [color, setColor] = useState('#3b82f6');
8
+ const [error, setError] = useState<string | null>(null);
9
+ const { updateColor } = useScatterPlot();
10
+
11
+ useEffect(() => {
12
+ const timer = setTimeout(async () => {
13
+ try {
14
+ setError(null);
15
+ const r = parseInt(color.slice(1, 3), 16);
16
+ const g = parseInt(color.slice(3, 5), 16);
17
+ const b = parseInt(color.slice(5, 7), 16);
18
+ const a = 255;
19
+ const calc = `(CAST(${a} AS BIGINT) * 16777216 + ${r} * 65536 + ${g} * 256 + ${b})`;
20
+ const colorSql = `(${calc} - CASE WHEN ${calc} > 2147483647 THEN 4294967296 ELSE 0 END)::INTEGER`;
21
+ await updateColor(colorSql);
22
+ } catch (e) {
23
+ setError(e instanceof Error ? e.message : 'Invalid color');
24
+ }
25
+ }, 100);
26
+ return () => clearTimeout(timer);
27
+ }, [color, updateColor]);
28
+
29
+ return (
30
+ <div className="flex flex-col gap-2">
31
+ <label className="text-sm font-medium text-zinc-700">Point Color</label>
32
+ <input
33
+ type="color"
34
+ value={color}
35
+ onChange={(e) => setColor(e.target.value)}
36
+ className="w-full h-10 cursor-pointer rounded border border-zinc-300"
37
+ />
38
+ {error && <p className="text-red-500 text-xs">{error}</p>}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { PointAlphaSlider } from './PointAlphaSlider';
4
+ import { PointSizeScaleSlider } from './PointSizeScaleSlider';
5
+ import { ColorExpressionInput } from './ColorExpressionInput';
6
+ import { SearchBox } from './SearchBox';
7
+ import { LabelFilterInput } from './LabelFilterInput';
8
+ import { PointLimitSlider } from './PointLimitSlider';
9
+ import { StatsDisplay } from './StatsDisplay';
10
+ import { HoverControlPanel } from './HoverControlPanel';
11
+ import { TimeFilterSlider } from './TimeFilterSlider';
12
+
13
+ export function ControlPanel() {
14
+ return (
15
+ <div className="w-80 bg-zinc-100 border-r border-zinc-300 p-4 flex flex-col gap-4 overflow-y-auto">
16
+ <div className="flex items-center justify-between">
17
+ <h2 className="text-lg font-semibold text-zinc-800">Controls</h2>
18
+ <StatsDisplay />
19
+ </div>
20
+ <PointLimitSlider />
21
+ <PointAlphaSlider />
22
+ <PointSizeScaleSlider />
23
+ <TimeFilterSlider />
24
+ <ColorExpressionInput />
25
+ <SearchBox />
26
+ <LabelFilterInput />
27
+ <HoverControlPanel />
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useScatterPlot } from '../context/ScatterPlotContext';
5
+ import { PointList } from './PointList';
6
+ import { LabelList } from './LabelList';
7
+
8
+ type Tab = 'points' | 'labels';
9
+
10
+ export function HoverControlPanel() {
11
+ const { state, clearAllHover } = useScatterPlot();
12
+ const [activeTab, setActiveTab] = useState<Tab>('points');
13
+
14
+ return (
15
+ <div className="flex flex-col gap-3">
16
+ <label className="text-sm font-medium text-zinc-700">Hover Control</label>
17
+
18
+ {/* Tabs */}
19
+ <div className="flex border-b border-zinc-200">
20
+ <button
21
+ onClick={() => setActiveTab('points')}
22
+ className={`px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
23
+ activeTab === 'points'
24
+ ? 'border-blue-500 text-blue-600'
25
+ : 'border-transparent text-zinc-500 hover:text-zinc-700'
26
+ }`}
27
+ >
28
+ Points
29
+ </button>
30
+ <button
31
+ onClick={() => setActiveTab('labels')}
32
+ className={`px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
33
+ activeTab === 'labels'
34
+ ? 'border-blue-500 text-blue-600'
35
+ : 'border-transparent text-zinc-500 hover:text-zinc-700'
36
+ }`}
37
+ >
38
+ Labels
39
+ </button>
40
+ </div>
41
+
42
+ {/* Tab content */}
43
+ <div className="min-h-[200px]">
44
+ {activeTab === 'points' && <PointList />}
45
+ {activeTab === 'labels' && <LabelList />}
46
+ </div>
47
+
48
+ {/* Clear button */}
49
+ <button
50
+ onClick={() => clearAllHover()}
51
+ className="px-3 py-1.5 bg-zinc-200 text-zinc-700 text-sm rounded hover:bg-zinc-300"
52
+ >
53
+ Clear All Hover
54
+ </button>
55
+
56
+ {/* Current hover state */}
57
+ <div className="text-xs text-zinc-500 border-t border-zinc-200 pt-2">
58
+ <div>
59
+ <span className="font-medium">Point:</span>{' '}
60
+ {state.hoveredPoint ? 'Hovered' : 'None'}
61
+ </div>
62
+ <div>
63
+ <span className="font-medium">Label:</span>{' '}
64
+ {state.hoveredLabel ? state.hoveredLabel.text : 'None'}
65
+ </div>
66
+ </div>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+ import { useScatterPlot } from '../context/ScatterPlotContext';
5
+
6
+ interface HoverInfoDisplayProps {
7
+ mousePosition: { x: number; y: number };
8
+ }
9
+
10
+ export function HoverInfoDisplay({ mousePosition }: HoverInfoDisplayProps) {
11
+ const { state } = useScatterPlot();
12
+
13
+ const formattedData = useMemo(() => {
14
+ if (!state.hoveredPoint) return null;
15
+
16
+ const { row, columns } = state.hoveredPoint;
17
+ const obj: Record<string, unknown> = {};
18
+ columns.forEach((col, i) => {
19
+ const val = row[i];
20
+ obj[col] = typeof val === 'bigint' ? Number(val) : val;
21
+ });
22
+ return JSON.stringify(obj, null, 2);
23
+ }, [state.hoveredPoint]);
24
+
25
+ if (!formattedData) return null;
26
+
27
+ return (
28
+ <div
29
+ className="fixed z-50 pointer-events-none"
30
+ style={{
31
+ left: mousePosition.x + 15,
32
+ top: mousePosition.y - 10,
33
+ }}
34
+ >
35
+ <pre className="p-2 bg-white border border-zinc-300 rounded shadow-lg text-zinc-700 text-xs max-w-xs">
36
+ {formattedData}
37
+ </pre>
38
+ </div>
39
+ );
40
+ }
@@ -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 LabelFilterInput() {
7
+ const [value, setValue] = useState('');
8
+ const { updateLabelFilter } = 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
+ updateLabelFilter(value);
17
+ }, 300);
18
+
19
+ return () => {
20
+ if (debounceRef.current) {
21
+ clearTimeout(debounceRef.current);
22
+ }
23
+ };
24
+ }, [value, updateLabelFilter]);
25
+
26
+ return (
27
+ <div className="flex flex-col gap-2">
28
+ <label className="text-sm font-medium text-zinc-700">Filter Labels</label>
29
+ <input
30
+ type="text"
31
+ value={value}
32
+ onChange={(e) => setValue(e.target.value)}
33
+ placeholder="Filter by label name..."
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,106 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo } from 'react';
4
+ import type { Label } from '@uoa-css-lab/duckscatter';
5
+ import { useScatterPlot } from '../context/ScatterPlotContext';
6
+
7
+ const PAGE_SIZE = 20;
8
+
9
+ export function LabelList() {
10
+ const { state, getLabels, setLabelHover } = useScatterPlot();
11
+ const [page, setPage] = useState(0);
12
+
13
+ // Load and sort labels by count (descending)
14
+ const labels = useMemo(() => {
15
+ if (!state.isInitialized) return [];
16
+ const allLabels = getLabels();
17
+ return [...allLabels].sort((a, b) => (b.count ?? 0) - (a.count ?? 0));
18
+ }, [state.isInitialized, getLabels]);
19
+
20
+ // Paginate labels
21
+ const pagedLabels = useMemo(() => {
22
+ const start = page * PAGE_SIZE;
23
+ return labels.slice(start, start + PAGE_SIZE);
24
+ }, [labels, page]);
25
+
26
+ const totalPages = Math.ceil(labels.length / PAGE_SIZE);
27
+
28
+ const handlePrev = () => {
29
+ if (page > 0) setPage(page - 1);
30
+ };
31
+
32
+ const handleNext = () => {
33
+ if (page < totalPages - 1) setPage(page + 1);
34
+ };
35
+
36
+ const handleLabelClick = (label: Label) => {
37
+ setLabelHover({ text: label.text });
38
+ };
39
+
40
+ // Check if a label is currently hovered
41
+ const isHovered = (label: Label) => {
42
+ return state.hoveredLabel?.text === label.text;
43
+ };
44
+
45
+ if (!state.isInitialized) {
46
+ return <div className="text-xs text-zinc-400">Not initialized</div>;
47
+ }
48
+
49
+ if (labels.length === 0) {
50
+ return <div className="text-xs text-zinc-400">No labels</div>;
51
+ }
52
+
53
+ return (
54
+ <div className="flex flex-col gap-2">
55
+ {/* List */}
56
+ <div className="max-h-48 overflow-y-auto border border-zinc-200 rounded bg-white">
57
+ {pagedLabels.map((label, idx) => (
58
+ <div
59
+ key={`${label.text}-${idx}`}
60
+ onClick={() => handleLabelClick(label)}
61
+ className={`px-2 py-1 text-xs cursor-pointer border-b border-zinc-100 last:border-b-0 hover:bg-blue-50 ${
62
+ isHovered(label) ? 'bg-blue-100 font-medium' : ''
63
+ }`}
64
+ >
65
+ <div className="flex justify-between items-center">
66
+ <span className="text-zinc-700 truncate flex-1">{label.text}</span>
67
+ {label.count !== undefined && (
68
+ <span className="text-zinc-400 ml-2">({label.count})</span>
69
+ )}
70
+ </div>
71
+ {label.cluster !== undefined && (
72
+ <span className="text-zinc-400 text-[10px]">cluster: {label.cluster}</span>
73
+ )}
74
+ </div>
75
+ ))}
76
+ </div>
77
+
78
+ {/* Pagination */}
79
+ {totalPages > 1 && (
80
+ <div className="flex items-center justify-between text-xs">
81
+ <button
82
+ onClick={handlePrev}
83
+ disabled={page === 0}
84
+ className="px-2 py-1 bg-zinc-100 rounded hover:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed"
85
+ >
86
+ Prev
87
+ </button>
88
+ <span className="text-zinc-500">
89
+ {page + 1} / {totalPages}
90
+ </span>
91
+ <button
92
+ onClick={handleNext}
93
+ disabled={page >= totalPages - 1}
94
+ className="px-2 py-1 bg-zinc-100 rounded hover:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed"
95
+ >
96
+ Next
97
+ </button>
98
+ </div>
99
+ )}
100
+
101
+ <div className="text-[10px] text-zinc-400">
102
+ Total: {labels.length} labels
103
+ </div>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+
3
+ import { useScatterPlot } from '../context/ScatterPlotContext';
4
+ import { Slider } from './Slider';
5
+
6
+ export function PointAlphaSlider() {
7
+ const { updatePointAlpha } = useScatterPlot();
8
+
9
+ return (
10
+ <Slider
11
+ label="Point Alpha"
12
+ min={0}
13
+ max={1}
14
+ step={0.01}
15
+ defaultValue={1.0}
16
+ onChange={updatePointAlpha}
17
+ minLabel="0"
18
+ maxLabel="1"
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { useScatterPlot } from '../context/ScatterPlotContext';
4
+ import { Slider } from './Slider';
5
+
6
+ export function PointLimitSlider() {
7
+ const { updatePointLimit } = useScatterPlot();
8
+
9
+ return (
10
+ <Slider
11
+ label="Visible Points"
12
+ min={10000}
13
+ max={5000000}
14
+ step={1000}
15
+ defaultValue={100000}
16
+ onChange={updatePointLimit}
17
+ parseValue={(v) => parseInt(v, 10)}
18
+ formatValue={(v) => v.toLocaleString()}
19
+ minLabel="1K"
20
+ maxLabel="5M"
21
+ />
22
+ );
23
+ }
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { useScatterPlot, type PointListItem } from '../context/ScatterPlotContext';
5
+
6
+ const PAGE_SIZE = 20;
7
+
8
+ export function PointList() {
9
+ const { state, fetchPoints, setPointHover } = useScatterPlot();
10
+ const [points, setPoints] = useState<PointListItem[]>([]);
11
+ const [page, setPage] = useState(0);
12
+ const [isLoading, setIsLoading] = useState(false);
13
+
14
+ const totalPages = state.pointCount ? Math.ceil(state.pointCount / PAGE_SIZE) : 0;
15
+
16
+ const loadPage = useCallback(
17
+ async (pageNum: number) => {
18
+ setIsLoading(true);
19
+ const data = await fetchPoints(pageNum, PAGE_SIZE);
20
+ setPoints(data);
21
+ setIsLoading(false);
22
+ },
23
+ [fetchPoints]
24
+ );
25
+
26
+ useEffect(() => {
27
+ if (state.isInitialized) {
28
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetching pattern
29
+ loadPage(page);
30
+ }
31
+ }, [state.isInitialized, page, loadPage]);
32
+
33
+ const handlePrev = () => {
34
+ if (page > 0) setPage(page - 1);
35
+ };
36
+
37
+ const handleNext = () => {
38
+ if (page < totalPages - 1) setPage(page + 1);
39
+ };
40
+
41
+ const handlePointClick = async (id: string | number) => {
42
+ await setPointHover(id);
43
+ };
44
+
45
+ // Check if a point is currently hovered
46
+ const isHovered = (id: string | number) => {
47
+ if (!state.hoveredPoint) return false;
48
+ const idIdx = state.hoveredPoint.columns.indexOf('__index_level_0__');
49
+ if (idIdx === -1) return false;
50
+ return state.hoveredPoint.row[idIdx] === id;
51
+ };
52
+
53
+ if (!state.isInitialized) {
54
+ return <div className="text-xs text-zinc-400">Not initialized</div>;
55
+ }
56
+
57
+ return (
58
+ <div className="flex flex-col gap-2">
59
+ {/* List */}
60
+ <div className="max-h-48 overflow-y-auto border border-zinc-200 rounded bg-white">
61
+ {isLoading ? (
62
+ <div className="p-2 text-xs text-zinc-400">Loading...</div>
63
+ ) : points.length === 0 ? (
64
+ <div className="p-2 text-xs text-zinc-400">No points</div>
65
+ ) : (
66
+ points.map((point) => (
67
+ <div
68
+ key={String(point.id)}
69
+ onClick={() => handlePointClick(point.id)}
70
+ className={`px-2 py-1 text-xs cursor-pointer border-b border-zinc-100 last:border-b-0 hover:bg-blue-50 ${
71
+ isHovered(point.id) ? 'bg-blue-100 font-medium' : ''
72
+ }`}
73
+ >
74
+ <span className="text-zinc-500">#{point.id}</span>{' '}
75
+ <span className="text-zinc-700">
76
+ ({point.x.toFixed(2)}, {point.y.toFixed(2)})
77
+ </span>
78
+ </div>
79
+ ))
80
+ )}
81
+ </div>
82
+
83
+ {/* Pagination */}
84
+ <div className="flex items-center justify-between text-xs">
85
+ <button
86
+ onClick={handlePrev}
87
+ disabled={page === 0}
88
+ className="px-2 py-1 bg-zinc-100 rounded hover:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed"
89
+ >
90
+ Prev
91
+ </button>
92
+ <span className="text-zinc-500">
93
+ {page + 1} / {totalPages || 1}
94
+ </span>
95
+ <button
96
+ onClick={handleNext}
97
+ disabled={page >= totalPages - 1}
98
+ className="px-2 py-1 bg-zinc-100 rounded hover:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed"
99
+ >
100
+ Next
101
+ </button>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import { useScatterPlot } from '../context/ScatterPlotContext';
4
+ import { Slider } from './Slider';
5
+
6
+ export function PointSizeScaleSlider() {
7
+ const { updatePointSizeScale } = useScatterPlot();
8
+
9
+ return (
10
+ <Slider
11
+ label="Size Scale"
12
+ min={0.1}
13
+ max={3}
14
+ step={0.05}
15
+ defaultValue={1.0}
16
+ onChange={updatePointSizeScale}
17
+ formatValue={(v) => `${v.toFixed(2)}x`}
18
+ minLabel="0.1x"
19
+ maxLabel="3x"
20
+ />
21
+ );
22
+ }