@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
package/eslint.config.js
ADDED
|
@@ -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
|
+
}
|