Initial commit
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* BrainView3D - 3D brain visualization with source activity overlay
|
||||
*
|
||||
* Features:
|
||||
* - 3D brain mesh rendering
|
||||
* - Source activity colormap overlay
|
||||
* - Interactive rotation and zoom
|
||||
* - Electrode/sensor visualization
|
||||
*/
|
||||
|
||||
import { useRef, useMemo, useState } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls, Sphere, Line } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
|
||||
export interface SourcePoint {
|
||||
position: [number, number, number];
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SensorPoint {
|
||||
name: string;
|
||||
position: [number, number, number];
|
||||
type: 'eeg' | 'meg';
|
||||
}
|
||||
|
||||
/** FreeSurfer mesh data for rendering */
|
||||
export interface FreeSurferMeshData {
|
||||
/** Vertex positions [[x, y, z], ...] */
|
||||
vertices: [number, number, number][];
|
||||
/** Triangle faces [[v0, v1, v2], ...] */
|
||||
faces: [number, number, number][];
|
||||
/** Vertex normals [[nx, ny, nz], ...] */
|
||||
normals?: [number, number, number][];
|
||||
/** Per-vertex colors [[r, g, b], ...] (0-1 range) for parcellation */
|
||||
colors?: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface BrainView3DProps {
|
||||
/** Source points with activity values */
|
||||
sources?: SourcePoint[];
|
||||
/** Sensor/electrode positions */
|
||||
sensors?: SensorPoint[];
|
||||
/** Title */
|
||||
title?: string;
|
||||
/** Height in pixels */
|
||||
height?: number;
|
||||
/** Show brain mesh */
|
||||
showBrain?: boolean;
|
||||
/** Show sensors */
|
||||
showSensors?: boolean;
|
||||
/** Colormap threshold (0-1, hide sources below) */
|
||||
threshold?: number;
|
||||
/** Min value for color scaling */
|
||||
vmin?: number;
|
||||
/** Max value for color scaling */
|
||||
vmax?: number;
|
||||
/** FreeSurfer mesh data (optional - replaces spherical approximation) */
|
||||
meshData?: FreeSurferMeshData;
|
||||
}
|
||||
|
||||
// Colormap function (viridis-like)
|
||||
function getColor(t: number): THREE.Color {
|
||||
const colors = [
|
||||
new THREE.Color(0.267, 0.005, 0.329),
|
||||
new THREE.Color(0.283, 0.141, 0.458),
|
||||
new THREE.Color(0.254, 0.265, 0.530),
|
||||
new THREE.Color(0.207, 0.372, 0.553),
|
||||
new THREE.Color(0.164, 0.471, 0.558),
|
||||
new THREE.Color(0.128, 0.567, 0.551),
|
||||
new THREE.Color(0.267, 0.749, 0.441),
|
||||
new THREE.Color(0.478, 0.821, 0.318),
|
||||
new THREE.Color(0.993, 0.906, 0.144),
|
||||
];
|
||||
|
||||
const i = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2);
|
||||
const f = t * (colors.length - 1) - i;
|
||||
|
||||
const color = new THREE.Color();
|
||||
color.lerpColors(colors[i], colors[i + 1], f);
|
||||
return color;
|
||||
}
|
||||
|
||||
// Simple brain mesh (spherical approximation)
|
||||
function BrainMesh({ opacity = 0.3 }: { opacity?: number }) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
// Create a simple brain-like mesh from two ellipsoids
|
||||
const geometry = useMemo(() => {
|
||||
const geo = new THREE.SphereGeometry(0.08, 32, 32);
|
||||
// Scale to brain-like shape
|
||||
geo.scale(1.0, 0.85, 1.1);
|
||||
return geo;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} geometry={geometry} position={[0, 0, 0.04]}>
|
||||
<meshPhongMaterial
|
||||
color="#f0d0c0"
|
||||
transparent
|
||||
opacity={opacity}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// FreeSurfer mesh rendering component
|
||||
function FreeSurferMesh({
|
||||
meshData,
|
||||
opacity = 0.8,
|
||||
}: {
|
||||
meshData: FreeSurferMeshData;
|
||||
opacity?: number;
|
||||
}) {
|
||||
const geometry = useMemo(() => {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
|
||||
// Flatten vertices for Three.js buffer
|
||||
const positions = new Float32Array(meshData.vertices.length * 3);
|
||||
for (let i = 0; i < meshData.vertices.length; i++) {
|
||||
// Scale from mm to scene units and center
|
||||
positions[i * 3] = meshData.vertices[i][0] / 1000;
|
||||
positions[i * 3 + 1] = meshData.vertices[i][1] / 1000;
|
||||
positions[i * 3 + 2] = meshData.vertices[i][2] / 1000;
|
||||
}
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
|
||||
// Set face indices
|
||||
const indices = new Uint32Array(meshData.faces.length * 3);
|
||||
for (let i = 0; i < meshData.faces.length; i++) {
|
||||
indices[i * 3] = meshData.faces[i][0];
|
||||
indices[i * 3 + 1] = meshData.faces[i][1];
|
||||
indices[i * 3 + 2] = meshData.faces[i][2];
|
||||
}
|
||||
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
// Set normals if provided, otherwise compute them
|
||||
if (meshData.normals && meshData.normals.length > 0) {
|
||||
const normals = new Float32Array(meshData.normals.length * 3);
|
||||
for (let i = 0; i < meshData.normals.length; i++) {
|
||||
normals[i * 3] = meshData.normals[i][0];
|
||||
normals[i * 3 + 1] = meshData.normals[i][1];
|
||||
normals[i * 3 + 2] = meshData.normals[i][2];
|
||||
}
|
||||
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||
} else {
|
||||
geo.computeVertexNormals();
|
||||
}
|
||||
|
||||
// Set per-vertex colors if provided (for parcellation visualization)
|
||||
if (meshData.colors && meshData.colors.length > 0) {
|
||||
const colors = new Float32Array(meshData.colors.length * 3);
|
||||
for (let i = 0; i < meshData.colors.length; i++) {
|
||||
colors[i * 3] = meshData.colors[i][0];
|
||||
colors[i * 3 + 1] = meshData.colors[i][1];
|
||||
colors[i * 3 + 2] = meshData.colors[i][2];
|
||||
}
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
}
|
||||
|
||||
return geo;
|
||||
}, [meshData]);
|
||||
|
||||
const hasColors = meshData.colors && meshData.colors.length > 0;
|
||||
|
||||
return (
|
||||
<mesh geometry={geometry}>
|
||||
<meshPhongMaterial
|
||||
color={hasColors ? '#ffffff' : '#f0d0c0'}
|
||||
vertexColors={hasColors}
|
||||
transparent
|
||||
opacity={opacity}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// Source activity points
|
||||
function SourcePoints({
|
||||
sources,
|
||||
vmin,
|
||||
vmax,
|
||||
threshold,
|
||||
}: {
|
||||
sources: SourcePoint[];
|
||||
vmin: number;
|
||||
vmax: number;
|
||||
threshold: number;
|
||||
}) {
|
||||
const points = useMemo(() => {
|
||||
const range = vmax - vmin || 1;
|
||||
|
||||
return sources
|
||||
.filter(s => {
|
||||
const t = (s.value - vmin) / range;
|
||||
return t >= threshold;
|
||||
})
|
||||
.map((source, i) => {
|
||||
const t = Math.max(0, Math.min(1, (source.value - vmin) / range));
|
||||
const color = getColor(t);
|
||||
const size = 0.002 + t * 0.004;
|
||||
|
||||
return (
|
||||
<Sphere
|
||||
key={i}
|
||||
args={[size, 8, 8]}
|
||||
position={source.position}
|
||||
>
|
||||
<meshBasicMaterial color={color} />
|
||||
</Sphere>
|
||||
);
|
||||
});
|
||||
}, [sources, vmin, vmax, threshold]);
|
||||
|
||||
return <>{points}</>;
|
||||
}
|
||||
|
||||
// Sensor markers
|
||||
function SensorMarkers({ sensors }: { sensors: SensorPoint[] }) {
|
||||
const markers = useMemo(() => {
|
||||
return sensors.map((sensor, i) => {
|
||||
const color = sensor.type === 'eeg' ? '#4ade80' : '#60a5fa';
|
||||
|
||||
return (
|
||||
<Sphere
|
||||
key={i}
|
||||
args={[0.003, 8, 8]}
|
||||
position={sensor.position}
|
||||
>
|
||||
<meshBasicMaterial color={color} />
|
||||
</Sphere>
|
||||
);
|
||||
});
|
||||
}, [sensors]);
|
||||
|
||||
return <>{markers}</>;
|
||||
}
|
||||
|
||||
// Axis helper
|
||||
function AxisHelper() {
|
||||
return (
|
||||
<group>
|
||||
{/* X axis - red */}
|
||||
<Line
|
||||
points={[[0, 0, 0], [0.05, 0, 0]]}
|
||||
color="#ef4444"
|
||||
lineWidth={2}
|
||||
/>
|
||||
{/* Y axis - green */}
|
||||
<Line
|
||||
points={[[0, 0, 0], [0, 0.05, 0]]}
|
||||
color="#22c55e"
|
||||
lineWidth={2}
|
||||
/>
|
||||
{/* Z axis - blue */}
|
||||
<Line
|
||||
points={[[0, 0, 0], [0, 0, 0.05]]}
|
||||
color="#3b82f6"
|
||||
lineWidth={2}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Camera controls with animation
|
||||
function CameraController() {
|
||||
useFrame(() => {
|
||||
// Optional: Add subtle rotation
|
||||
});
|
||||
|
||||
return (
|
||||
<OrbitControls
|
||||
enableDamping
|
||||
dampingFactor={0.1}
|
||||
minDistance={0.1}
|
||||
maxDistance={0.5}
|
||||
target={[0, 0, 0.04]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// View preset buttons
|
||||
function ViewPresets({ onViewChange }: { onViewChange: (view: string) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-zinc-700 text-xs px-2 py-1 h-6"
|
||||
onClick={() => onViewChange('front')}
|
||||
>
|
||||
Front
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-zinc-700 text-xs px-2 py-1 h-6"
|
||||
onClick={() => onViewChange('top')}
|
||||
>
|
||||
Top
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-zinc-700 text-xs px-2 py-1 h-6"
|
||||
onClick={() => onViewChange('left')}
|
||||
>
|
||||
Left
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-zinc-700 text-xs px-2 py-1 h-6"
|
||||
onClick={() => onViewChange('right')}
|
||||
>
|
||||
Right
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrainView3D({
|
||||
sources = [],
|
||||
sensors = [],
|
||||
title = '3D Brain View',
|
||||
height = 400,
|
||||
showBrain = true,
|
||||
showSensors = true,
|
||||
threshold = 0,
|
||||
vmin: userVmin,
|
||||
vmax: userVmax,
|
||||
meshData,
|
||||
}: BrainView3DProps) {
|
||||
const [brainOpacity, setBrainOpacity] = useState(0.8);
|
||||
const [internalThreshold, setInternalThreshold] = useState(threshold);
|
||||
|
||||
// Compute value range
|
||||
const { vmin, vmax } = useMemo(() => {
|
||||
if (sources.length === 0) return { vmin: 0, vmax: 1 };
|
||||
const values = sources.map(s => s.value);
|
||||
const min = userVmin ?? Math.min(...values);
|
||||
const max = userVmax ?? Math.max(...values);
|
||||
return { vmin: min, vmax: max };
|
||||
}, [sources, userVmin, userVmax]);
|
||||
|
||||
// Handle view preset changes
|
||||
const handleViewChange = (view: string) => {
|
||||
// This would need to be implemented with refs to OrbitControls
|
||||
console.log('View:', view);
|
||||
};
|
||||
|
||||
if (sources.length === 0 && sensors.length === 0 && !meshData) {
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="flex items-center justify-center" style={{ height }}>
|
||||
<p className="text-zinc-500">No source, sensor, or mesh data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-zinc-400">{title}</CardTitle>
|
||||
<ViewPresets onViewChange={handleViewChange} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<div style={{ height }} className="bg-zinc-950 rounded overflow-hidden">
|
||||
<Canvas
|
||||
camera={{
|
||||
position: [0.15, 0.1, 0.2],
|
||||
fov: 50,
|
||||
near: 0.01,
|
||||
far: 1,
|
||||
}}
|
||||
>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[1, 1, 1]} intensity={0.8} />
|
||||
<directionalLight position={[-1, -1, -1]} intensity={0.4} />
|
||||
|
||||
{showBrain && meshData && (
|
||||
<FreeSurferMesh meshData={meshData} opacity={brainOpacity} />
|
||||
)}
|
||||
{showBrain && !meshData && <BrainMesh opacity={brainOpacity} />}
|
||||
|
||||
{sources.length > 0 && (
|
||||
<SourcePoints
|
||||
sources={sources}
|
||||
vmin={vmin}
|
||||
vmax={vmax}
|
||||
threshold={internalThreshold}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSensors && sensors.length > 0 && (
|
||||
<SensorMarkers sensors={sensors} />
|
||||
)}
|
||||
|
||||
<AxisHelper />
|
||||
<CameraController />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-xs text-zinc-500">Brain:</span>
|
||||
<Slider
|
||||
value={[brainOpacity * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
onValueChange={([v]) => setBrainOpacity(v / 100)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-xs text-zinc-500">Threshold:</span>
|
||||
<Slider
|
||||
value={[internalThreshold * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={([v]) => setInternalThreshold(v / 100)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{sources.length} sources | {sensors.length} sensors
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate demo source points on a spherical shell (for testing)
|
||||
*/
|
||||
export function generateDemoSources(n: number = 100): SourcePoint[] {
|
||||
const sources: SourcePoint[] = [];
|
||||
const phi = (1 + Math.sqrt(5)) / 2; // Golden ratio
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const theta = (2 * Math.PI * i) / phi;
|
||||
const cosPhi = 1 - (2 * (i + 0.5)) / n;
|
||||
const sinPhi = Math.sqrt(1 - cosPhi * cosPhi);
|
||||
|
||||
const radius = 0.06; // Brain radius
|
||||
const x = radius * sinPhi * Math.cos(theta);
|
||||
const y = radius * sinPhi * Math.sin(theta);
|
||||
const z = 0.04 + radius * cosPhi; // Centered at typical head position
|
||||
|
||||
// Generate activity value (simulate some localized activity)
|
||||
const activityCenter = [0.03, 0.02, 0.08]; // Localized source
|
||||
const dist = Math.sqrt(
|
||||
(x - activityCenter[0]) ** 2 +
|
||||
(y - activityCenter[1]) ** 2 +
|
||||
(z - activityCenter[2]) ** 2
|
||||
);
|
||||
const value = Math.exp(-dist * 50) + Math.random() * 0.1;
|
||||
|
||||
sources.push({
|
||||
position: [x, y, z],
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate standard 10-20 sensor positions (for EEG)
|
||||
*/
|
||||
export function generateEEGSensors(): SensorPoint[] {
|
||||
const headRadius = 0.092;
|
||||
const sensors: SensorPoint[] = [];
|
||||
|
||||
const electrodes: [string, number, number][] = [
|
||||
['Fp1', -18, 72],
|
||||
['Fp2', 18, 72],
|
||||
['F7', -54, 54],
|
||||
['F3', -39, 54],
|
||||
['Fz', 0, 54],
|
||||
['F4', 39, 54],
|
||||
['F8', 54, 54],
|
||||
['T3', -90, 45],
|
||||
['C3', -45, 45],
|
||||
['Cz', 0, 0],
|
||||
['C4', 45, 45],
|
||||
['T4', 90, 45],
|
||||
['T5', -126, 54],
|
||||
['P3', -39, -54],
|
||||
['Pz', 0, -54],
|
||||
['P4', 39, -54],
|
||||
['T6', 126, 54],
|
||||
['O1', -18, -72],
|
||||
['O2', 18, -72],
|
||||
];
|
||||
|
||||
for (const [name, thetaDeg, phiDeg] of electrodes) {
|
||||
const theta = (thetaDeg * Math.PI) / 180;
|
||||
const phi = ((90 - phiDeg) * Math.PI) / 180;
|
||||
|
||||
const x = headRadius * Math.sin(phi) * Math.cos(theta);
|
||||
const y = headRadius * Math.sin(phi) * Math.sin(theta);
|
||||
const z = headRadius * Math.cos(phi);
|
||||
|
||||
sensors.push({
|
||||
name,
|
||||
position: [x, y, z + 0.04], // Offset to head center
|
||||
type: 'eeg',
|
||||
});
|
||||
}
|
||||
|
||||
return sensors;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* ChannelSelector - Interactive channel selection component
|
||||
*
|
||||
* Features:
|
||||
* - List of channels with selection checkboxes
|
||||
* - Bad channel marking
|
||||
* - Channel type filtering
|
||||
* - Search/filter by name
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { ChannelInfo } from '@/lib/neuro';
|
||||
|
||||
export interface ChannelSelectorProps {
|
||||
/** Channel information */
|
||||
channels: ChannelInfo[];
|
||||
/** Currently selected channel indices */
|
||||
selectedChannels: number[];
|
||||
/** Callback when selection changes */
|
||||
onSelectionChange: (indices: number[]) => void;
|
||||
/** Bad channels (can be toggled) */
|
||||
badChannels?: number[];
|
||||
/** Callback when bad channels change */
|
||||
onBadChannelsChange?: (indices: number[]) => void;
|
||||
/** Max height in pixels */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
const CHANNEL_TYPE_COLORS: Record<string, string> = {
|
||||
EEG: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
|
||||
MEG: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
EOG: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
|
||||
ECG: 'bg-red-500/20 text-red-400 border-red-500/50',
|
||||
EMG: 'bg-purple-500/20 text-purple-400 border-purple-500/50',
|
||||
STIM: 'bg-orange-500/20 text-orange-400 border-orange-500/50',
|
||||
MISC: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/50',
|
||||
};
|
||||
|
||||
export function ChannelSelector({
|
||||
channels,
|
||||
selectedChannels,
|
||||
onSelectionChange,
|
||||
badChannels = [],
|
||||
onBadChannelsChange,
|
||||
maxHeight = 400,
|
||||
}: ChannelSelectorProps) {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
|
||||
// Get unique channel types
|
||||
const channelTypes = useMemo(() => {
|
||||
const types = new Set(channels.map(c => c.channel_type));
|
||||
return Array.from(types).sort();
|
||||
}, [channels]);
|
||||
|
||||
// Filter channels
|
||||
const filteredChannels = useMemo(() => {
|
||||
return channels.filter((ch) => {
|
||||
if (filter && !ch.name.toLowerCase().includes(filter.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (typeFilter && ch.channel_type !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((ch) => ({
|
||||
...ch,
|
||||
originalIndex: channels.findIndex(c => c.name === ch.name),
|
||||
}));
|
||||
}, [channels, filter, typeFilter]);
|
||||
|
||||
// Toggle channel selection
|
||||
const toggleChannel = useCallback((index: number) => {
|
||||
if (selectedChannels.includes(index)) {
|
||||
onSelectionChange(selectedChannels.filter(i => i !== index));
|
||||
} else {
|
||||
onSelectionChange([...selectedChannels, index].sort((a, b) => a - b));
|
||||
}
|
||||
}, [selectedChannels, onSelectionChange]);
|
||||
|
||||
// Toggle bad channel
|
||||
const toggleBadChannel = useCallback((index: number) => {
|
||||
if (!onBadChannelsChange) return;
|
||||
|
||||
if (badChannels.includes(index)) {
|
||||
onBadChannelsChange(badChannels.filter(i => i !== index));
|
||||
} else {
|
||||
onBadChannelsChange([...badChannels, index]);
|
||||
}
|
||||
}, [badChannels, onBadChannelsChange]);
|
||||
|
||||
// Select all visible
|
||||
const selectAll = useCallback(() => {
|
||||
const indices = filteredChannels.map(c => c.originalIndex);
|
||||
const newSelection = [...new Set([...selectedChannels, ...indices])].sort((a, b) => a - b);
|
||||
onSelectionChange(newSelection);
|
||||
}, [filteredChannels, selectedChannels, onSelectionChange]);
|
||||
|
||||
// Deselect all visible
|
||||
const deselectAll = useCallback(() => {
|
||||
const indicesToRemove = new Set(filteredChannels.map(c => c.originalIndex));
|
||||
onSelectionChange(selectedChannels.filter(i => !indicesToRemove.has(i)));
|
||||
}, [filteredChannels, selectedChannels, onSelectionChange]);
|
||||
|
||||
// Invert selection
|
||||
const invertSelection = useCallback(() => {
|
||||
const allIndices = new Set(channels.map((_, i) => i));
|
||||
const currentSet = new Set(selectedChannels);
|
||||
const inverted = Array.from(allIndices).filter(i => !currentSet.has(i));
|
||||
onSelectionChange(inverted.sort((a, b) => a - b));
|
||||
}, [channels, selectedChannels, onSelectionChange]);
|
||||
|
||||
// Select first N channels
|
||||
const selectFirstN = useCallback((n: number) => {
|
||||
const indices = channels.slice(0, n).map((_, i) => i);
|
||||
onSelectionChange(indices);
|
||||
}, [channels, onSelectionChange]);
|
||||
|
||||
// Select every Nth channel
|
||||
const selectEveryN = useCallback((n: number) => {
|
||||
const indices = channels.map((_, i) => i).filter(i => i % n === 0);
|
||||
onSelectionChange(indices);
|
||||
}, [channels, onSelectionChange]);
|
||||
|
||||
// Select by type
|
||||
const selectByType = useCallback((type: string) => {
|
||||
const indices = channels
|
||||
.map((ch, i) => ({ ch, i }))
|
||||
.filter(({ ch }) => ch.channel_type === type)
|
||||
.map(({ i }) => i);
|
||||
onSelectionChange(indices);
|
||||
}, [channels, onSelectionChange]);
|
||||
|
||||
// Mark all selected as bad
|
||||
const markSelectedAsBad = useCallback(() => {
|
||||
if (!onBadChannelsChange) return;
|
||||
const newBad = [...new Set([...badChannels, ...selectedChannels])];
|
||||
onBadChannelsChange(newBad);
|
||||
}, [selectedChannels, badChannels, onBadChannelsChange]);
|
||||
|
||||
// Clear all bad channel markings
|
||||
const clearAllBad = useCallback(() => {
|
||||
if (!onBadChannelsChange) return;
|
||||
onBadChannelsChange([]);
|
||||
}, [onBadChannelsChange]);
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-zinc-400">
|
||||
Channels ({selectedChannels.length}/{channels.length} selected)
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* Search filter */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search channels..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm bg-zinc-800 border border-zinc-700 rounded text-zinc-200 placeholder:text-zinc-500"
|
||||
/>
|
||||
|
||||
{/* Type filter */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={typeFilter === null ? 'default' : 'outline'}
|
||||
className={`h-6 text-xs px-2 ${typeFilter === null ? 'bg-zinc-600' : 'border-zinc-700'}`}
|
||||
onClick={() => setTypeFilter(null)}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
{channelTypes.map(type => (
|
||||
<Button
|
||||
key={type}
|
||||
size="sm"
|
||||
variant={typeFilter === type ? 'default' : 'outline'}
|
||||
className={`h-6 text-xs px-2 ${typeFilter === type ? 'bg-zinc-600' : 'border-zinc-700'}`}
|
||||
onClick={() => setTypeFilter(type === typeFilter ? null : type)}
|
||||
>
|
||||
{type}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selection controls */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs px-2 border-zinc-700 flex-1"
|
||||
onClick={selectAll}
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs px-2 border-zinc-700 flex-1"
|
||||
onClick={deselectAll}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs px-2 border-zinc-700 flex-1"
|
||||
onClick={invertSelection}
|
||||
>
|
||||
Invert
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick presets */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Quick:</span>
|
||||
<div className="flex gap-1 flex-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 text-[10px] px-1.5 border-zinc-700"
|
||||
onClick={() => selectFirstN(8)}
|
||||
title="Select first 8 channels"
|
||||
>
|
||||
First 8
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 text-[10px] px-1.5 border-zinc-700"
|
||||
onClick={() => selectFirstN(16)}
|
||||
title="Select first 16 channels"
|
||||
>
|
||||
First 16
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 text-[10px] px-1.5 border-zinc-700"
|
||||
onClick={() => selectEveryN(2)}
|
||||
title="Select every 2nd channel"
|
||||
>
|
||||
Every 2nd
|
||||
</Button>
|
||||
{channelTypes.length > 1 && channelTypes.slice(0, 2).map(type => (
|
||||
<Button
|
||||
key={type}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 text-[10px] px-1.5 border-zinc-700"
|
||||
onClick={() => selectByType(type)}
|
||||
title={`Select all ${type} channels`}
|
||||
>
|
||||
{type}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bad channel controls */}
|
||||
{onBadChannelsChange && (
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-zinc-800">
|
||||
<span className="text-xs text-zinc-500">Bad:</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 text-[10px] px-1.5 border-zinc-700 text-red-400"
|
||||
onClick={markSelectedAsBad}
|
||||
disabled={selectedChannels.length === 0}
|
||||
title="Mark selected channels as bad"
|
||||
>
|
||||
Mark Selected
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 text-[10px] px-1.5 border-zinc-700"
|
||||
onClick={clearAllBad}
|
||||
disabled={badChannels.length === 0}
|
||||
title="Clear all bad channel markings"
|
||||
>
|
||||
Clear All Bad
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel list */}
|
||||
<ScrollArea className="rounded border border-zinc-800" style={{ height: maxHeight }}>
|
||||
<div className="space-y-0.5 p-1">
|
||||
{filteredChannels.map((channel) => {
|
||||
const index = channel.originalIndex;
|
||||
const isSelected = selectedChannels.includes(index);
|
||||
const isBad = badChannels.includes(index);
|
||||
const typeColor = CHANNEL_TYPE_COLORS[channel.channel_type] || CHANNEL_TYPE_COLORS.MISC;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-zinc-800 ${
|
||||
isSelected ? 'bg-zinc-800' : ''
|
||||
} ${isBad ? 'opacity-50' : ''}`}
|
||||
onClick={() => toggleChannel(index)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleChannel(index)}
|
||||
className="w-3 h-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Channel name */}
|
||||
<span className={`font-mono text-xs flex-1 ${isBad ? 'line-through' : ''}`}>
|
||||
{channel.name}
|
||||
</span>
|
||||
|
||||
{/* Channel type badge */}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${typeColor}`}>
|
||||
{channel.channel_type}
|
||||
</span>
|
||||
|
||||
{/* Bad channel toggle */}
|
||||
{onBadChannelsChange && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`h-5 w-5 p-0 ${isBad ? 'text-red-400' : 'text-zinc-500 hover:text-red-400'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleBadChannel(index);
|
||||
}}
|
||||
title={isBad ? 'Unmark as bad' : 'Mark as bad'}
|
||||
>
|
||||
{isBad ? '×' : '○'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Bad channels summary */}
|
||||
{badChannels.length > 0 && (
|
||||
<div className="text-xs text-zinc-500">
|
||||
{badChannels.length} bad channel{badChannels.length !== 1 ? 's' : ''} marked
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* ConnectivityMatrix - Connectivity visualization as a heatmap
|
||||
*
|
||||
* Features:
|
||||
* - Square matrix heatmap for channel-to-channel connectivity
|
||||
* - Colormap customization
|
||||
* - Hover tooltips
|
||||
* - Threshold filtering
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import type { ConnectivityResult } from '@/lib/neuro';
|
||||
|
||||
export interface ConnectivityMatrixProps {
|
||||
/** Connectivity result from the backend */
|
||||
result: ConnectivityResult | null;
|
||||
/** Channel names for labels */
|
||||
channelNames?: string[];
|
||||
/** Title */
|
||||
title?: string;
|
||||
/** Size in pixels */
|
||||
size?: number;
|
||||
/** Colormap */
|
||||
colormap?: 'viridis' | 'hot' | 'coolwarm';
|
||||
/** Threshold (0-1, hide values below) */
|
||||
threshold?: number;
|
||||
/** Callback when cell is clicked */
|
||||
onCellClick?: (source: number, target: number, value: number) => void;
|
||||
}
|
||||
|
||||
// Colormap functions
|
||||
const colormaps = {
|
||||
viridis: (t: number): [number, number, number] => {
|
||||
const colors = [
|
||||
[0.267, 0.005, 0.329],
|
||||
[0.283, 0.141, 0.458],
|
||||
[0.207, 0.372, 0.553],
|
||||
[0.128, 0.567, 0.551],
|
||||
[0.267, 0.749, 0.441],
|
||||
[0.993, 0.906, 0.144],
|
||||
];
|
||||
const i = Math.min(Math.floor(t * (colors.length - 1)), colors.length - 2);
|
||||
const f = t * (colors.length - 1) - i;
|
||||
return [
|
||||
colors[i][0] + f * (colors[i + 1][0] - colors[i][0]),
|
||||
colors[i][1] + f * (colors[i + 1][1] - colors[i][1]),
|
||||
colors[i][2] + f * (colors[i + 1][2] - colors[i][2]),
|
||||
];
|
||||
},
|
||||
hot: (t: number): [number, number, number] => {
|
||||
if (t < 0.33) return [t * 3, 0, 0];
|
||||
if (t < 0.67) return [1, (t - 0.33) * 3, 0];
|
||||
return [1, 1, (t - 0.67) * 3];
|
||||
},
|
||||
coolwarm: (t: number): [number, number, number] => {
|
||||
if (t < 0.5) {
|
||||
const s = t * 2;
|
||||
return [0.23 + 0.77 * s, 0.30 + 0.40 * s, 0.75 - 0.10 * s];
|
||||
}
|
||||
const s = (t - 0.5) * 2;
|
||||
return [0.75 + 0.25 * s, 0.70 - 0.60 * s, 0.65 - 0.55 * s];
|
||||
},
|
||||
};
|
||||
|
||||
export function ConnectivityMatrix({
|
||||
result,
|
||||
channelNames,
|
||||
title = 'Connectivity Matrix',
|
||||
size = 400,
|
||||
colormap = 'viridis',
|
||||
threshold = 0,
|
||||
onCellClick,
|
||||
}: ConnectivityMatrixProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [internalThreshold, setInternalThreshold] = useState(threshold);
|
||||
const [hoveredCell, setHoveredCell] = useState<{ i: number; j: number; value: number } | null>(null);
|
||||
|
||||
// Convert connectivity data to matrix form
|
||||
const { matrix, labels, vmin, vmax } = useMemo(() => {
|
||||
if (!result) {
|
||||
return { matrix: null, labels: [], vmin: 0, vmax: 1 };
|
||||
}
|
||||
|
||||
// Get unique channel indices
|
||||
const sources = result.sources;
|
||||
const targets = result.targets;
|
||||
const uniqueChannels = [...new Set([...sources, ...targets])].sort((a, b) => a - b);
|
||||
const n = uniqueChannels.length;
|
||||
|
||||
// Create channel index map
|
||||
const indexMap = new Map<number, number>();
|
||||
uniqueChannels.forEach((ch, i) => indexMap.set(ch, i));
|
||||
|
||||
// Create matrix
|
||||
const mat = Array(n).fill(null).map(() => Array(n).fill(0));
|
||||
|
||||
// Average across frequencies if multiple
|
||||
const nPairs = result.data.length;
|
||||
for (let p = 0; p < nPairs; p++) {
|
||||
const srcIdx = indexMap.get(sources[p]);
|
||||
const tgtIdx = indexMap.get(targets[p]);
|
||||
if (srcIdx !== undefined && tgtIdx !== undefined) {
|
||||
// Average across frequencies
|
||||
const avgValue = result.data[p].reduce((a, b) => a + b, 0) / result.data[p].length;
|
||||
mat[srcIdx][tgtIdx] = avgValue;
|
||||
mat[tgtIdx][srcIdx] = avgValue; // Symmetric
|
||||
}
|
||||
}
|
||||
|
||||
// Get labels
|
||||
const lbls = uniqueChannels.map(i =>
|
||||
channelNames && i < channelNames.length ? channelNames[i] : `Ch${i}`
|
||||
);
|
||||
|
||||
// Get value range
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (const row of mat) {
|
||||
for (const v of row) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matrix: mat,
|
||||
labels: lbls,
|
||||
vmin: min,
|
||||
vmax: max,
|
||||
};
|
||||
}, [result, channelNames]);
|
||||
|
||||
// Draw the matrix
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx || !matrix) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const n = matrix.length;
|
||||
|
||||
if (n === 0) return;
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const margin = 60;
|
||||
const cellSize = Math.min(
|
||||
(width - margin - 40) / n,
|
||||
(height - margin - 20) / n
|
||||
);
|
||||
const matrixSize = cellSize * n;
|
||||
|
||||
// Draw matrix cells
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = 0; j < n; j++) {
|
||||
const value = matrix[i][j];
|
||||
const x = margin + j * cellSize;
|
||||
const y = margin + i * cellSize;
|
||||
|
||||
// Normalize value
|
||||
const range = vmax - vmin || 1;
|
||||
const t = (value - vmin) / range;
|
||||
|
||||
// Apply threshold
|
||||
if (t < internalThreshold && i !== j) {
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
} else {
|
||||
const [r, g, b] = colormaps[colormap](t);
|
||||
ctx.fillStyle = `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`;
|
||||
}
|
||||
|
||||
ctx.fillRect(x, y, cellSize - 1, cellSize - 1);
|
||||
|
||||
// Highlight hovered cell
|
||||
if (hoveredCell && hoveredCell.i === i && hoveredCell.j === j) {
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x, y, cellSize - 1, cellSize - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw labels (only if space permits)
|
||||
if (cellSize >= 15) {
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = `${Math.min(11, cellSize * 0.8)}px monospace`;
|
||||
|
||||
// Row labels (left)
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const y = margin + i * cellSize + cellSize / 2;
|
||||
ctx.fillText(labels[i].slice(0, 5), margin - 5, y);
|
||||
}
|
||||
|
||||
// Column labels (top, rotated)
|
||||
ctx.save();
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let j = 0; j < n; j++) {
|
||||
const x = margin + j * cellSize + cellSize / 2;
|
||||
ctx.save();
|
||||
ctx.translate(x, margin - 5);
|
||||
ctx.rotate(-Math.PI / 4);
|
||||
ctx.fillText(labels[j].slice(0, 5), 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw colorbar
|
||||
const barWidth = 15;
|
||||
const barHeight = matrixSize;
|
||||
const barX = margin + matrixSize + 15;
|
||||
const barY = margin;
|
||||
|
||||
for (let i = 0; i < barHeight; i++) {
|
||||
const t = 1 - i / barHeight;
|
||||
const [r, g, b] = colormaps[colormap](t);
|
||||
ctx.fillStyle = `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`;
|
||||
ctx.fillRect(barX, barY + i, barWidth, 1);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(barX, barY, barWidth, barHeight);
|
||||
|
||||
// Colorbar labels
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(vmax.toFixed(2), barX + barWidth + 4, barY + 10);
|
||||
ctx.fillText(((vmax + vmin) / 2).toFixed(2), barX + barWidth + 4, barY + barHeight / 2);
|
||||
ctx.fillText(vmin.toFixed(2), barX + barWidth + 4, barY + barHeight);
|
||||
|
||||
// Method label
|
||||
if (result) {
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(`Method: ${result.method}`, margin, height - 10);
|
||||
ctx.fillText(`n_epochs: ${result.n_epochs}`, margin + 150, height - 10);
|
||||
}
|
||||
}, [matrix, labels, vmin, vmax, colormap, internalThreshold, hoveredCell, result]);
|
||||
|
||||
// Handle mouse move for hover
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!matrix || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const margin = 60;
|
||||
const n = matrix.length;
|
||||
const cellSize = Math.min(
|
||||
(canvas.width - margin - 40) / n,
|
||||
(canvas.height - margin - 20) / n
|
||||
);
|
||||
|
||||
const j = Math.floor((x - margin) / cellSize);
|
||||
const i = Math.floor((y - margin) / cellSize);
|
||||
|
||||
if (i >= 0 && i < n && j >= 0 && j < n) {
|
||||
setHoveredCell({ i, j, value: matrix[i][j] });
|
||||
} else {
|
||||
setHoveredCell(null);
|
||||
}
|
||||
}, [matrix]);
|
||||
|
||||
// Handle click
|
||||
const handleClick = useCallback((_e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!onCellClick || !hoveredCell) return;
|
||||
onCellClick(hoveredCell.i, hoveredCell.j, hoveredCell.value);
|
||||
}, [onCellClick, hoveredCell]);
|
||||
|
||||
// Draw on mount and data change
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
draw();
|
||||
}
|
||||
}, [size, draw]);
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="flex items-center justify-center" style={{ height: size }}>
|
||||
<p className="text-zinc-500">No connectivity data. Compute connectivity first.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-zinc-400">{title}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Threshold:</span>
|
||||
<Slider
|
||||
value={[internalThreshold * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={([v]) => setInternalThreshold(v / 100)}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-xs text-zinc-400 w-8">{internalThreshold.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<div className="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={size}
|
||||
height={size}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHoveredCell(null)}
|
||||
onClick={handleClick}
|
||||
className="cursor-crosshair"
|
||||
/>
|
||||
{/* Tooltip */}
|
||||
{hoveredCell && labels.length > 0 && (
|
||||
<div
|
||||
className="absolute bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs pointer-events-none"
|
||||
style={{
|
||||
left: 70,
|
||||
top: 10,
|
||||
}}
|
||||
>
|
||||
<span className="text-zinc-400">
|
||||
{labels[hoveredCell.i]} ↔ {labels[hoveredCell.j]}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-zinc-200 font-mono">
|
||||
{hoveredCell.value.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate demo connectivity result (for testing)
|
||||
*/
|
||||
export function generateDemoConnectivity(nChannels: number = 10): ConnectivityResult {
|
||||
const sources: number[] = [];
|
||||
const targets: number[] = [];
|
||||
const data: number[][] = [];
|
||||
const freqs = [8, 10, 12, 14, 16]; // Alpha band
|
||||
|
||||
for (let i = 0; i < nChannels; i++) {
|
||||
for (let j = i + 1; j < nChannels; j++) {
|
||||
sources.push(i);
|
||||
targets.push(j);
|
||||
|
||||
// Generate random connectivity values for each frequency
|
||||
const values = freqs.map(() => Math.random() * 0.5 + (i === j ? 1 : 0));
|
||||
data.push(values);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
freqs,
|
||||
sources,
|
||||
targets,
|
||||
method: 'coherence',
|
||||
n_epochs: 50,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* PsdPlot - Power Spectral Density visualization
|
||||
*
|
||||
* Features:
|
||||
* - Line plot of power vs frequency
|
||||
* - Log scale options for both axes
|
||||
* - Multiple channel overlay
|
||||
* - Frequency band highlighting (delta, theta, alpha, beta, gamma)
|
||||
* - Interactive hover with frequency/power readout
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface PsdData {
|
||||
/** PSD values [n_channels][n_freqs] */
|
||||
psd: number[][];
|
||||
/** Frequency vector */
|
||||
freqs: number[];
|
||||
/** Channel names */
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
export interface PsdPlotProps {
|
||||
/** PSD data to display */
|
||||
psd: PsdData | null;
|
||||
/** Title */
|
||||
title?: string;
|
||||
/** Width in pixels */
|
||||
width?: number;
|
||||
/** Height in pixels */
|
||||
height?: number;
|
||||
/** Selected channels to display (indices) */
|
||||
selectedChannels?: number[];
|
||||
/** Log scale for frequency axis */
|
||||
logFreq?: boolean;
|
||||
/** Log scale for power axis */
|
||||
logPower?: boolean;
|
||||
/** Show frequency band markers */
|
||||
showBands?: boolean;
|
||||
/** Frequency range to display [fmin, fmax] */
|
||||
freqRange?: [number, number];
|
||||
}
|
||||
|
||||
// Standard EEG frequency bands
|
||||
const FREQUENCY_BANDS = [
|
||||
{ name: 'Delta', range: [0.5, 4], color: 'rgba(147, 51, 234, 0.15)' }, // Purple
|
||||
{ name: 'Theta', range: [4, 8], color: 'rgba(59, 130, 246, 0.15)' }, // Blue
|
||||
{ name: 'Alpha', range: [8, 13], color: 'rgba(34, 197, 94, 0.15)' }, // Green
|
||||
{ name: 'Beta', range: [13, 30], color: 'rgba(234, 179, 8, 0.15)' }, // Yellow
|
||||
{ name: 'Gamma', range: [30, 100], color: 'rgba(239, 68, 68, 0.15)' }, // Red
|
||||
];
|
||||
|
||||
// Color palette for multiple channels
|
||||
const CHANNEL_COLORS = [
|
||||
'#3b82f6', // Blue
|
||||
'#ef4444', // Red
|
||||
'#22c55e', // Green
|
||||
'#f59e0b', // Amber
|
||||
'#8b5cf6', // Purple
|
||||
'#ec4899', // Pink
|
||||
'#06b6d4', // Cyan
|
||||
'#f97316', // Orange
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate demo PSD data
|
||||
*/
|
||||
export function generateDemoPsd(nChannels: number = 4, nFreqs: number = 100): PsdData {
|
||||
const freqs: number[] = [];
|
||||
const psd: number[][] = [];
|
||||
const channels: string[] = [];
|
||||
|
||||
// Generate frequency vector (0.5 to 50 Hz)
|
||||
for (let i = 0; i < nFreqs; i++) {
|
||||
freqs.push(0.5 + (i / (nFreqs - 1)) * 49.5);
|
||||
}
|
||||
|
||||
// Generate channel names and PSD data
|
||||
for (let ch = 0; ch < nChannels; ch++) {
|
||||
channels.push(`Ch${ch + 1}`);
|
||||
const channelPsd: number[] = [];
|
||||
|
||||
for (let i = 0; i < nFreqs; i++) {
|
||||
const f = freqs[i];
|
||||
// Simulate 1/f spectrum with peaks
|
||||
let power = 100 / (f + 1);
|
||||
|
||||
// Add alpha peak (8-13 Hz)
|
||||
power += 50 * Math.exp(-Math.pow(f - 10, 2) / 4) * (1 + 0.3 * Math.random());
|
||||
|
||||
// Add beta peak (15-25 Hz) - smaller
|
||||
power += 20 * Math.exp(-Math.pow(f - 20, 2) / 10) * (1 + 0.2 * Math.random());
|
||||
|
||||
// Add channel-specific variation
|
||||
power *= (1 + 0.2 * (ch - nChannels / 2));
|
||||
|
||||
// Add noise
|
||||
power *= (0.9 + 0.2 * Math.random());
|
||||
|
||||
channelPsd.push(Math.max(0.01, power));
|
||||
}
|
||||
psd.push(channelPsd);
|
||||
}
|
||||
|
||||
return { psd, freqs, channels };
|
||||
}
|
||||
|
||||
export function PsdPlot({
|
||||
psd,
|
||||
title = 'Power Spectral Density',
|
||||
width = 600,
|
||||
height = 350,
|
||||
selectedChannels,
|
||||
logFreq = false,
|
||||
logPower = true,
|
||||
showBands = true,
|
||||
freqRange,
|
||||
}: PsdPlotProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [hoveredPoint, setHoveredPoint] = useState<{
|
||||
freq: number;
|
||||
power: number;
|
||||
channel: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [internalLogPower, setInternalLogPower] = useState(logPower);
|
||||
const [internalLogFreq, setInternalLogFreq] = useState(logFreq);
|
||||
const [internalShowBands, setInternalShowBands] = useState(showBands);
|
||||
|
||||
// Margins for axes
|
||||
const margin = { top: 40, right: 120, bottom: 50, left: 70 };
|
||||
const plotWidth = width - margin.left - margin.right;
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
|
||||
// Determine which channels to display
|
||||
const channelsToShow = useMemo(() => {
|
||||
if (!psd) return [];
|
||||
if (selectedChannels && selectedChannels.length > 0) {
|
||||
return selectedChannels.filter(i => i >= 0 && i < psd.channels.length);
|
||||
}
|
||||
// Default: show first 4 channels
|
||||
return Array.from({ length: Math.min(4, psd.channels.length) }, (_, i) => i);
|
||||
}, [psd, selectedChannels]);
|
||||
|
||||
// Process data for plotting
|
||||
const plotData = useMemo(() => {
|
||||
if (!psd || psd.freqs.length === 0) return null;
|
||||
|
||||
let freqs = [...psd.freqs];
|
||||
let psdData = psd.psd.map(ch => [...ch]);
|
||||
|
||||
// Apply frequency range filter
|
||||
if (freqRange) {
|
||||
const [fmin, fmax] = freqRange;
|
||||
const indices = freqs.map((f, i) => ({ f, i }))
|
||||
.filter(({ f }) => f >= fmin && f <= fmax)
|
||||
.map(({ i }) => i);
|
||||
|
||||
freqs = indices.map(i => freqs[i]);
|
||||
psdData = psdData.map(ch => indices.map(i => ch[i]));
|
||||
}
|
||||
|
||||
// Find min/max for scaling
|
||||
let minPower = Infinity;
|
||||
let maxPower = -Infinity;
|
||||
for (const chIdx of channelsToShow) {
|
||||
if (chIdx >= psdData.length) continue;
|
||||
for (const val of psdData[chIdx]) {
|
||||
const v = internalLogPower ? Math.log10(Math.max(val, 1e-10)) : val;
|
||||
minPower = Math.min(minPower, v);
|
||||
maxPower = Math.max(maxPower, v);
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding to range
|
||||
const range = maxPower - minPower || 1;
|
||||
minPower -= range * 0.05;
|
||||
maxPower += range * 0.05;
|
||||
|
||||
const minFreq = internalLogFreq ? Math.log10(Math.max(freqs[0], 0.1)) : freqs[0];
|
||||
const maxFreq = internalLogFreq ? Math.log10(freqs[freqs.length - 1]) : freqs[freqs.length - 1];
|
||||
|
||||
return { freqs, psdData, minPower, maxPower, minFreq, maxFreq };
|
||||
}, [psd, freqRange, channelsToShow, internalLogPower, internalLogFreq]);
|
||||
|
||||
// Draw the plot
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !plotData) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#09090b';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const { freqs, psdData, minPower, maxPower, minFreq, maxFreq } = plotData;
|
||||
|
||||
// Helper functions
|
||||
const freqToX = (f: number) => {
|
||||
const fVal = internalLogFreq ? Math.log10(Math.max(f, 0.1)) : f;
|
||||
return margin.left + ((fVal - minFreq) / (maxFreq - minFreq)) * plotWidth;
|
||||
};
|
||||
|
||||
const powerToY = (p: number) => {
|
||||
const pVal = internalLogPower ? Math.log10(Math.max(p, 1e-10)) : p;
|
||||
return margin.top + plotHeight - ((pVal - minPower) / (maxPower - minPower)) * plotHeight;
|
||||
};
|
||||
|
||||
// Draw frequency bands
|
||||
if (internalShowBands) {
|
||||
for (const band of FREQUENCY_BANDS) {
|
||||
const [f1, f2] = band.range;
|
||||
if (f2 < freqs[0] || f1 > freqs[freqs.length - 1]) continue;
|
||||
|
||||
const x1 = freqToX(Math.max(f1, freqs[0]));
|
||||
const x2 = freqToX(Math.min(f2, freqs[freqs.length - 1]));
|
||||
|
||||
ctx.fillStyle = band.color;
|
||||
ctx.fillRect(x1, margin.top, x2 - x1, plotHeight);
|
||||
|
||||
// Band label
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '9px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(band.name, (x1 + x2) / 2, margin.top + 12);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 0.5;
|
||||
|
||||
// Vertical grid lines (frequency)
|
||||
const freqTicks = internalLogFreq
|
||||
? [1, 2, 5, 10, 20, 50, 100].filter(f => f >= freqs[0] && f <= freqs[freqs.length - 1])
|
||||
: Array.from({ length: 6 }, (_, i) => freqs[0] + (i / 5) * (freqs[freqs.length - 1] - freqs[0]));
|
||||
|
||||
for (const f of freqTicks) {
|
||||
const x = freqToX(f);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, margin.top);
|
||||
ctx.lineTo(x, margin.top + plotHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Horizontal grid lines (power)
|
||||
const nPowerTicks = 5;
|
||||
for (let i = 0; i <= nPowerTicks; i++) {
|
||||
const y = margin.top + plotHeight - (i / nPowerTicks) * plotHeight;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin.left, y);
|
||||
ctx.lineTo(margin.left + plotWidth, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw plot border
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(margin.left, margin.top, plotWidth, plotHeight);
|
||||
|
||||
// Draw PSD lines for each channel
|
||||
for (let i = 0; i < channelsToShow.length; i++) {
|
||||
const chIdx = channelsToShow[i];
|
||||
if (chIdx >= psdData.length) continue;
|
||||
|
||||
const channelData = psdData[chIdx];
|
||||
const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let j = 0; j < freqs.length; j++) {
|
||||
const x = freqToX(freqs[j]);
|
||||
const y = powerToY(channelData[j]);
|
||||
|
||||
if (j === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw axes labels
|
||||
ctx.fillStyle = '#999';
|
||||
ctx.font = '11px system-ui';
|
||||
|
||||
// X-axis labels
|
||||
ctx.textAlign = 'center';
|
||||
for (const f of freqTicks) {
|
||||
const x = freqToX(f);
|
||||
ctx.fillText(f.toFixed(f < 10 ? 1 : 0), x, margin.top + plotHeight + 20);
|
||||
}
|
||||
|
||||
// X-axis title
|
||||
ctx.fillText('Frequency (Hz)', margin.left + plotWidth / 2, height - 10);
|
||||
|
||||
// Y-axis labels
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= nPowerTicks; i++) {
|
||||
const p = minPower + (i / nPowerTicks) * (maxPower - minPower);
|
||||
const y = margin.top + plotHeight - (i / nPowerTicks) * plotHeight;
|
||||
const label = internalLogPower
|
||||
? `10^${p.toFixed(1)}`
|
||||
: p.toExponential(1);
|
||||
ctx.fillText(label, margin.left - 5, y + 4);
|
||||
}
|
||||
|
||||
// Y-axis title
|
||||
ctx.save();
|
||||
ctx.translate(15, margin.top + plotHeight / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(internalLogPower ? 'Power (log₁₀)' : 'Power', 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// Draw legend
|
||||
const legendX = margin.left + plotWidth + 10;
|
||||
let legendY = margin.top + 20;
|
||||
|
||||
ctx.font = '10px system-ui';
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
for (let i = 0; i < channelsToShow.length; i++) {
|
||||
const chIdx = channelsToShow[i];
|
||||
if (chIdx >= psd!.channels.length) continue;
|
||||
|
||||
const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||
|
||||
// Color line
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(legendX, legendY);
|
||||
ctx.lineTo(legendX + 20, legendY);
|
||||
ctx.stroke();
|
||||
|
||||
// Channel name
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.fillText(psd!.channels[chIdx], legendX + 25, legendY + 4);
|
||||
|
||||
legendY += 18;
|
||||
}
|
||||
}, [plotData, width, height, channelsToShow, internalLogPower, internalLogFreq, internalShowBands, psd]);
|
||||
|
||||
// Handle mouse move for tooltip
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!plotData || !psd) {
|
||||
setHoveredPoint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Check if within plot area
|
||||
if (x < margin.left || x > margin.left + plotWidth ||
|
||||
y < margin.top || y > margin.top + plotHeight) {
|
||||
setHoveredPoint(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { freqs, psdData, minPower, maxPower, minFreq, maxFreq } = plotData;
|
||||
|
||||
// Convert x to frequency
|
||||
const xRatio = (x - margin.left) / plotWidth;
|
||||
const fVal = minFreq + xRatio * (maxFreq - minFreq);
|
||||
const freq = internalLogFreq ? Math.pow(10, fVal) : fVal;
|
||||
|
||||
// Find closest frequency index
|
||||
let closestIdx = 0;
|
||||
let closestDist = Infinity;
|
||||
for (let i = 0; i < freqs.length; i++) {
|
||||
const dist = Math.abs(freqs[i] - freq);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Find closest channel at this frequency
|
||||
let closestChannel = 0;
|
||||
let closestYDist = Infinity;
|
||||
for (let i = 0; i < channelsToShow.length; i++) {
|
||||
const chIdx = channelsToShow[i];
|
||||
if (chIdx >= psdData.length) continue;
|
||||
|
||||
const power = psdData[chIdx][closestIdx];
|
||||
const pVal = internalLogPower ? Math.log10(Math.max(power, 1e-10)) : power;
|
||||
const chY = margin.top + plotHeight - ((pVal - minPower) / (maxPower - minPower)) * plotHeight;
|
||||
const dist = Math.abs(chY - y);
|
||||
|
||||
if (dist < closestYDist) {
|
||||
closestYDist = dist;
|
||||
closestChannel = chIdx;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestYDist < 30) {
|
||||
setHoveredPoint({
|
||||
freq: freqs[closestIdx],
|
||||
power: psdData[closestChannel][closestIdx],
|
||||
channel: psd.channels[closestChannel],
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
} else {
|
||||
setHoveredPoint(null);
|
||||
}
|
||||
},
|
||||
[plotData, psd, channelsToShow, internalLogPower, internalLogFreq, plotWidth, plotHeight]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredPoint(null);
|
||||
}, []);
|
||||
|
||||
if (!psd) {
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-zinc-400">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="flex items-center justify-center bg-zinc-950 rounded"
|
||||
style={{ width, height: height - 60 }}
|
||||
>
|
||||
<p className="text-zinc-500">No PSD data available</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-zinc-400">{title}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={internalLogPower ? 'default' : 'outline'}
|
||||
className={`h-6 text-xs px-2 ${internalLogPower ? 'bg-zinc-600' : 'border-zinc-700'}`}
|
||||
onClick={() => setInternalLogPower(!internalLogPower)}
|
||||
>
|
||||
Log Power
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={internalLogFreq ? 'default' : 'outline'}
|
||||
className={`h-6 text-xs px-2 ${internalLogFreq ? 'bg-zinc-600' : 'border-zinc-700'}`}
|
||||
onClick={() => setInternalLogFreq(!internalLogFreq)}
|
||||
>
|
||||
Log Freq
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={internalShowBands ? 'default' : 'outline'}
|
||||
className={`h-6 text-xs px-2 ${internalShowBands ? 'bg-zinc-600' : 'border-zinc-700'}`}
|
||||
onClick={() => setInternalShowBands(!internalShowBands)}
|
||||
>
|
||||
Bands
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<div className="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width, height }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="rounded"
|
||||
/>
|
||||
{/* Tooltip */}
|
||||
{hoveredPoint && (
|
||||
<div
|
||||
className="absolute pointer-events-none bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs shadow-lg"
|
||||
style={{
|
||||
left: Math.min(hoveredPoint.x + 10, width - 120),
|
||||
top: Math.max(hoveredPoint.y - 40, 10),
|
||||
}}
|
||||
>
|
||||
<div className="text-zinc-300">{hoveredPoint.channel}</div>
|
||||
<div className="text-zinc-400">
|
||||
{hoveredPoint.freq.toFixed(1)} Hz: {hoveredPoint.power.toExponential(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
/**
|
||||
* SignalViewer - Multi-channel time series visualization component
|
||||
*
|
||||
* Features:
|
||||
* - Canvas-based rendering for performance
|
||||
* - Vertical/horizontal zoom and scroll
|
||||
* - Channel selection and bad channel marking
|
||||
* - Event markers overlay
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import type { DataChunk, EventDto } from '@/lib/neuro';
|
||||
|
||||
/** Cursor position for measurements */
|
||||
export interface CursorPosition {
|
||||
time: number; // Time in seconds
|
||||
sampleIdx: number; // Sample index in current chunk
|
||||
}
|
||||
|
||||
/** Annotation for marking regions or points */
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
startTime: number;
|
||||
endTime?: number; // If undefined, it's a point annotation
|
||||
label: string;
|
||||
color?: string;
|
||||
channel?: number; // If undefined, spans all channels
|
||||
}
|
||||
|
||||
export interface SignalViewerProps {
|
||||
/** Data chunk to display */
|
||||
data: DataChunk | null;
|
||||
/** Events to overlay */
|
||||
events?: EventDto[];
|
||||
/** Currently visible time range */
|
||||
timeRange?: [number, number];
|
||||
/** Callback when time range changes */
|
||||
onTimeRangeChange?: (range: [number, number]) => void;
|
||||
/** Selected channels (indices) */
|
||||
selectedChannels?: number[];
|
||||
/** Bad channels (indices) */
|
||||
badChannels?: number[];
|
||||
/** Callback when channel is clicked */
|
||||
onChannelClick?: (index: number) => void;
|
||||
/** Height in pixels */
|
||||
height?: number;
|
||||
/** Vertical scale factor */
|
||||
verticalScale?: number;
|
||||
/** Enable event editing mode */
|
||||
eventEditMode?: boolean;
|
||||
/** Callback when user clicks to add an event */
|
||||
onEventAdd?: (time: number) => void;
|
||||
/** Callback when user clicks on an event */
|
||||
onEventClick?: (event: EventDto) => void;
|
||||
/** Cursor mode for measurements */
|
||||
cursorMode?: 'off' | 'single' | 'dual';
|
||||
/** First cursor position */
|
||||
cursor1?: CursorPosition | null;
|
||||
/** Second cursor position */
|
||||
cursor2?: CursorPosition | null;
|
||||
/** Callback when cursor is placed */
|
||||
onCursorClick?: (time: number, sampleIdx: number) => void;
|
||||
/** Annotations to display */
|
||||
annotations?: Annotation[];
|
||||
/** Show amplitude values at cursor positions */
|
||||
showCursorAmplitudes?: boolean;
|
||||
/** Callback when annotation is clicked */
|
||||
onAnnotationClick?: (annotation: Annotation) => void;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#2563eb', '#dc2626', '#16a34a', '#ca8a04', '#9333ea',
|
||||
'#0891b2', '#be185d', '#4f46e5', '#059669', '#d97706',
|
||||
];
|
||||
|
||||
export function SignalViewer({
|
||||
data,
|
||||
events = [],
|
||||
timeRange: _timeRange,
|
||||
onTimeRangeChange: _onTimeRangeChange,
|
||||
selectedChannels,
|
||||
badChannels = [],
|
||||
onChannelClick: _onChannelClick,
|
||||
height = 600,
|
||||
verticalScale = 1.0,
|
||||
eventEditMode = false,
|
||||
onEventAdd,
|
||||
onEventClick,
|
||||
cursorMode = 'off',
|
||||
cursor1 = null,
|
||||
cursor2 = null,
|
||||
onCursorClick,
|
||||
annotations = [],
|
||||
showCursorAmplitudes = true,
|
||||
onAnnotationClick: _onAnnotationClick,
|
||||
}: SignalViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [_scale, _setScale] = useState(1.0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, offset: 0 });
|
||||
const [hoverTime, setHoverTime] = useState<number | null>(null);
|
||||
|
||||
// Compute visible channels
|
||||
const visibleChannels = selectedChannels ??
|
||||
(data?.channels.map((_, i) => i) ?? []);
|
||||
|
||||
const nVisibleChannels = visibleChannels.length;
|
||||
const channelHeight = nVisibleChannels > 0 ? (height - 60) / nVisibleChannels : 100;
|
||||
|
||||
// Draw the signals
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx || !data || data.data.length === 0) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const canvasHeight = canvas.height;
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, width, canvasHeight);
|
||||
|
||||
const times = data.times;
|
||||
const nSamples = times.length;
|
||||
if (nSamples === 0) return;
|
||||
|
||||
const tMin = times[0];
|
||||
const tMax = times[nSamples - 1];
|
||||
const duration = tMax - tMin;
|
||||
|
||||
// Time axis
|
||||
const leftMargin = 80;
|
||||
const rightMargin = 20;
|
||||
const topMargin = 30;
|
||||
const bottomMargin = 30;
|
||||
const plotWidth = width - leftMargin - rightMargin;
|
||||
// plotHeight available if needed: canvasHeight - topMargin - bottomMargin
|
||||
|
||||
// Draw channel labels and signals
|
||||
visibleChannels.forEach((chIdx, displayIdx) => {
|
||||
if (chIdx >= data.data.length) return;
|
||||
|
||||
const y0 = topMargin + displayIdx * channelHeight + channelHeight / 2;
|
||||
const isBad = badChannels.includes(chIdx);
|
||||
const color = isBad ? '#666' : COLORS[displayIdx % COLORS.length];
|
||||
|
||||
// Channel label
|
||||
ctx.fillStyle = isBad ? '#666' : '#999';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(data.channels[chIdx] || `Ch${chIdx}`, leftMargin - 10, y0);
|
||||
|
||||
// Draw signal
|
||||
const signal = data.data[chIdx];
|
||||
if (!signal || signal.length === 0) return;
|
||||
|
||||
// Compute signal stats for scaling
|
||||
const signalSlice = signal;
|
||||
const max = Math.max(...signalSlice.map(Math.abs));
|
||||
const scaleFactor = max > 0 ? (channelHeight * 0.4 * verticalScale) / max : 1;
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let i = 0; i < nSamples; i++) {
|
||||
const t = times[i];
|
||||
const x = leftMargin + ((t - tMin) / duration) * plotWidth;
|
||||
const val = signal[i];
|
||||
const y = y0 - val * scaleFactor;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw baseline
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.setLineDash([2, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leftMargin, y0);
|
||||
ctx.lineTo(width - rightMargin, y0);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
});
|
||||
|
||||
// Draw events
|
||||
events.forEach((event) => {
|
||||
const t = event.time;
|
||||
if (t < tMin || t > tMax) return;
|
||||
|
||||
const x = leftMargin + ((t - tMin) / duration) * plotWidth;
|
||||
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 2]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, topMargin);
|
||||
ctx.lineTo(x, canvasHeight - bottomMargin);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Event label
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(event.description || `E${event.value}`, x, topMargin - 5);
|
||||
});
|
||||
|
||||
// Draw annotations (regions and points)
|
||||
annotations.forEach((annotation) => {
|
||||
const annotColor = annotation.color || '#fbbf24';
|
||||
const startX = leftMargin + ((annotation.startTime - tMin) / duration) * plotWidth;
|
||||
|
||||
if (annotation.endTime !== undefined) {
|
||||
// Region annotation
|
||||
const endX = leftMargin + ((annotation.endTime - tMin) / duration) * plotWidth;
|
||||
const regionWidth = Math.abs(endX - startX);
|
||||
|
||||
// Determine Y range based on channel
|
||||
let yTop = topMargin;
|
||||
let yBottom = canvasHeight - bottomMargin;
|
||||
if (annotation.channel !== undefined) {
|
||||
const displayIdx = visibleChannels.indexOf(annotation.channel);
|
||||
if (displayIdx >= 0) {
|
||||
yTop = topMargin + displayIdx * channelHeight;
|
||||
yBottom = yTop + channelHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw semi-transparent region
|
||||
ctx.fillStyle = annotColor + '20'; // 20% opacity
|
||||
ctx.fillRect(Math.min(startX, endX), yTop, regionWidth, yBottom - yTop);
|
||||
|
||||
// Draw borders
|
||||
ctx.strokeStyle = annotColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 2]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, yTop);
|
||||
ctx.lineTo(startX, yBottom);
|
||||
ctx.moveTo(endX, yTop);
|
||||
ctx.lineTo(endX, yBottom);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw label
|
||||
ctx.fillStyle = annotColor;
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const labelX = (startX + endX) / 2;
|
||||
ctx.fillText(annotation.label, labelX, yTop + 12);
|
||||
} else {
|
||||
// Point annotation (vertical line with marker)
|
||||
if (annotation.startTime < tMin || annotation.startTime > tMax) return;
|
||||
|
||||
let yTop = topMargin;
|
||||
let yBottom = canvasHeight - bottomMargin;
|
||||
if (annotation.channel !== undefined) {
|
||||
const displayIdx = visibleChannels.indexOf(annotation.channel);
|
||||
if (displayIdx >= 0) {
|
||||
yTop = topMargin + displayIdx * channelHeight;
|
||||
yBottom = yTop + channelHeight;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.strokeStyle = annotColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, yTop);
|
||||
ctx.lineTo(startX, yBottom);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw diamond marker
|
||||
ctx.fillStyle = annotColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startX, yTop - 4);
|
||||
ctx.lineTo(startX - 4, yTop);
|
||||
ctx.lineTo(startX, yTop + 4);
|
||||
ctx.lineTo(startX + 4, yTop);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Label
|
||||
ctx.font = 'bold 10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(annotation.label, startX, yTop - 8);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw time axis
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leftMargin, canvasHeight - bottomMargin);
|
||||
ctx.lineTo(width - rightMargin, canvasHeight - bottomMargin);
|
||||
ctx.stroke();
|
||||
|
||||
// Time labels
|
||||
const nTicks = 5;
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
for (let i = 0; i <= nTicks; i++) {
|
||||
const t = tMin + (i / nTicks) * duration;
|
||||
const x = leftMargin + (i / nTicks) * plotWidth;
|
||||
ctx.fillText(t.toFixed(2) + 's', x, canvasHeight - 10);
|
||||
|
||||
// Tick marks
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, canvasHeight - bottomMargin);
|
||||
ctx.lineTo(x, canvasHeight - bottomMargin + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw hover line in edit mode
|
||||
if (eventEditMode && hoverTime !== null && hoverTime >= tMin && hoverTime <= tMax) {
|
||||
const hoverX = leftMargin + ((hoverTime - tMin) / duration) * plotWidth;
|
||||
ctx.strokeStyle = '#22c55e';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hoverX, topMargin);
|
||||
ctx.lineTo(hoverX, canvasHeight - bottomMargin);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Time label for hover
|
||||
ctx.fillStyle = '#22c55e';
|
||||
ctx.font = 'bold 11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`Click to add: ${hoverTime.toFixed(3)}s`, hoverX, topMargin - 8);
|
||||
}
|
||||
|
||||
// Draw measurement cursors
|
||||
if (cursorMode !== 'off') {
|
||||
// Helper to get amplitude at cursor for a channel
|
||||
const getAmplitudeAtSample = (sampleIdx: number, chIdx: number): number | null => {
|
||||
if (chIdx >= data.data.length) return null;
|
||||
const signal = data.data[chIdx];
|
||||
if (!signal || sampleIdx < 0 || sampleIdx >= signal.length) return null;
|
||||
return signal[sampleIdx];
|
||||
};
|
||||
|
||||
// Format amplitude value with appropriate units
|
||||
const formatAmplitude = (val: number): string => {
|
||||
const absVal = Math.abs(val);
|
||||
if (absVal >= 1e-3) return `${(val * 1e3).toFixed(1)}mV`;
|
||||
if (absVal >= 1e-6) return `${(val * 1e6).toFixed(1)}µV`;
|
||||
return val.toExponential(2);
|
||||
};
|
||||
|
||||
// Cursor 1 (cyan)
|
||||
if (cursor1 && cursor1.time >= tMin && cursor1.time <= tMax) {
|
||||
const c1x = leftMargin + ((cursor1.time - tMin) / duration) * plotWidth;
|
||||
ctx.strokeStyle = '#06b6d4';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c1x, topMargin);
|
||||
ctx.lineTo(c1x, canvasHeight - bottomMargin);
|
||||
ctx.stroke();
|
||||
|
||||
// Cursor 1 label
|
||||
ctx.fillStyle = '#06b6d4';
|
||||
ctx.font = 'bold 10px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`C1: ${cursor1.time.toFixed(3)}s`, c1x, canvasHeight - 3);
|
||||
|
||||
// Draw triangle marker at top
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c1x, topMargin - 2);
|
||||
ctx.lineTo(c1x - 5, topMargin - 10);
|
||||
ctx.lineTo(c1x + 5, topMargin - 10);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = '#06b6d4';
|
||||
ctx.fill();
|
||||
|
||||
// Draw amplitude values at cursor for each visible channel
|
||||
if (showCursorAmplitudes) {
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
visibleChannels.forEach((chIdx, displayIdx) => {
|
||||
const y0 = topMargin + displayIdx * channelHeight + channelHeight / 2;
|
||||
const amp = getAmplitudeAtSample(cursor1.sampleIdx, chIdx);
|
||||
if (amp !== null) {
|
||||
// Draw dot at intersection
|
||||
ctx.fillStyle = '#06b6d4';
|
||||
ctx.beginPath();
|
||||
ctx.arc(c1x, y0 - amp * (channelHeight * 0.4 * verticalScale) / Math.max(...data.data[chIdx].map(Math.abs)), 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Draw value
|
||||
ctx.fillText(formatAmplitude(amp), c1x + 5, y0 - 5);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor 2 (magenta) - only in dual mode
|
||||
if (cursorMode === 'dual' && cursor2 && cursor2.time >= tMin && cursor2.time <= tMax) {
|
||||
const c2x = leftMargin + ((cursor2.time - tMin) / duration) * plotWidth;
|
||||
ctx.strokeStyle = '#d946ef';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c2x, topMargin);
|
||||
ctx.lineTo(c2x, canvasHeight - bottomMargin);
|
||||
ctx.stroke();
|
||||
|
||||
// Cursor 2 label
|
||||
ctx.fillStyle = '#d946ef';
|
||||
ctx.font = 'bold 10px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`C2: ${cursor2.time.toFixed(3)}s`, c2x, canvasHeight - 3);
|
||||
|
||||
// Draw triangle marker at top
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c2x, topMargin - 2);
|
||||
ctx.lineTo(c2x - 5, topMargin - 10);
|
||||
ctx.lineTo(c2x + 5, topMargin - 10);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = '#d946ef';
|
||||
ctx.fill();
|
||||
|
||||
// Draw amplitude values and delta for cursor 2
|
||||
if (showCursorAmplitudes && cursor1) {
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
visibleChannels.forEach((chIdx, displayIdx) => {
|
||||
const y0 = topMargin + displayIdx * channelHeight + channelHeight / 2;
|
||||
const amp2 = getAmplitudeAtSample(cursor2.sampleIdx, chIdx);
|
||||
const amp1 = getAmplitudeAtSample(cursor1.sampleIdx, chIdx);
|
||||
if (amp2 !== null) {
|
||||
// Draw dot at intersection
|
||||
ctx.fillStyle = '#d946ef';
|
||||
ctx.beginPath();
|
||||
ctx.arc(c2x, y0 - amp2 * (channelHeight * 0.4 * verticalScale) / Math.max(...data.data[chIdx].map(Math.abs)), 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Draw value and delta
|
||||
if (amp1 !== null) {
|
||||
const delta = amp2 - amp1;
|
||||
ctx.fillText(`${formatAmplitude(amp2)} (Δ${formatAmplitude(delta)})`, c2x - 5, y0 - 5);
|
||||
} else {
|
||||
ctx.fillText(formatAmplitude(amp2), c2x - 5, y0 - 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Draw delta T in between cursors
|
||||
if (cursor1) {
|
||||
const c1x = leftMargin + ((cursor1.time - tMin) / duration) * plotWidth;
|
||||
const midX = (c1x + c2x) / 2;
|
||||
const deltaT = Math.abs(cursor2.time - cursor1.time);
|
||||
ctx.fillStyle = '#fbbf24';
|
||||
ctx.font = 'bold 11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`ΔT: ${(deltaT * 1000).toFixed(1)}ms`, midX, topMargin + 15);
|
||||
}
|
||||
}
|
||||
|
||||
// Show cursor mode indicator in corner
|
||||
ctx.fillStyle = cursorMode === 'dual' ? '#d946ef' : '#06b6d4';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`Cursor: ${cursorMode}`, width - 10, 15);
|
||||
}
|
||||
}, [data, events, visibleChannels, badChannels, channelHeight, verticalScale, eventEditMode, hoverTime, cursorMode, cursor1, cursor2, annotations, showCursorAmplitudes]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width } = entry.contentRect;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
draw();
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [height, draw]);
|
||||
|
||||
// Redraw on data change
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
// Helper to convert canvas X to time
|
||||
const canvasXToTime = useCallback((clientX: number): number | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !data) return null;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasX = clientX - rect.left;
|
||||
|
||||
const leftMargin = 80;
|
||||
const rightMargin = 20;
|
||||
const plotWidth = canvas.width - leftMargin - rightMargin;
|
||||
|
||||
// Check if within plot area
|
||||
if (canvasX < leftMargin || canvasX > canvas.width - rightMargin) return null;
|
||||
|
||||
const times = data.times;
|
||||
const tMin = times[0];
|
||||
const tMax = times[times.length - 1];
|
||||
const duration = tMax - tMin;
|
||||
|
||||
const time = tMin + ((canvasX - leftMargin) / plotWidth) * duration;
|
||||
return time;
|
||||
}, [data]);
|
||||
|
||||
// Check if click is near an event (within 10 pixels)
|
||||
const findNearbyEvent = useCallback((clientX: number): EventDto | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !data) return null;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasX = clientX - rect.left;
|
||||
|
||||
const leftMargin = 80;
|
||||
const rightMargin = 20;
|
||||
const plotWidth = canvas.width - leftMargin - rightMargin;
|
||||
const times = data.times;
|
||||
const tMin = times[0];
|
||||
const tMax = times[times.length - 1];
|
||||
const duration = tMax - tMin;
|
||||
|
||||
for (const event of events) {
|
||||
if (event.time < tMin || event.time > tMax) continue;
|
||||
const eventX = leftMargin + ((event.time - tMin) / duration) * plotWidth;
|
||||
if (Math.abs(canvasX - eventX) < 10) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [data, events]);
|
||||
|
||||
// Helper to convert time to sample index
|
||||
const timeToSampleIdx = useCallback((time: number): number => {
|
||||
if (!data) return 0;
|
||||
const times = data.times;
|
||||
const tMin = times[0];
|
||||
const tMax = times[times.length - 1];
|
||||
const duration = tMax - tMin;
|
||||
const idx = Math.round(((time - tMin) / duration) * (times.length - 1));
|
||||
return Math.max(0, Math.min(times.length - 1, idx));
|
||||
}, [data]);
|
||||
|
||||
// Mouse handlers for panning, event editing, and cursor placement
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (eventEditMode) {
|
||||
// In edit mode, check for event click first
|
||||
const nearbyEvent = findNearbyEvent(e.clientX);
|
||||
if (nearbyEvent && onEventClick) {
|
||||
onEventClick(nearbyEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, add new event
|
||||
const time = canvasXToTime(e.clientX);
|
||||
if (time !== null && onEventAdd) {
|
||||
onEventAdd(time);
|
||||
}
|
||||
} else if (cursorMode !== 'off' && onCursorClick) {
|
||||
// Cursor placement mode
|
||||
const time = canvasXToTime(e.clientX);
|
||||
if (time !== null) {
|
||||
const sampleIdx = timeToSampleIdx(time);
|
||||
onCursorClick(time, sampleIdx);
|
||||
}
|
||||
} else {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX, offset });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (eventEditMode) {
|
||||
// Update hover time in edit mode
|
||||
const time = canvasXToTime(e.clientX);
|
||||
setHoverTime(time);
|
||||
} else if (isDragging) {
|
||||
const dx = e.clientX - dragStart.x;
|
||||
setOffset(dragStart.offset + dx);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsDragging(false);
|
||||
if (eventEditMode) {
|
||||
setHoverTime(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="flex items-center justify-center h-96">
|
||||
<p className="text-zinc-500">No data loaded. Load an EDF/BDF file to view signals.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg text-zinc-100">
|
||||
Signal Viewer
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-zinc-400">Vertical Scale:</span>
|
||||
<Slider
|
||||
className="w-24"
|
||||
value={[verticalScale * 50]}
|
||||
min={10}
|
||||
max={200}
|
||||
step={10}
|
||||
onValueChange={([_v]) => {
|
||||
// This would need to be lifted to parent
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-300"
|
||||
onClick={() => setOffset(0)}
|
||||
>
|
||||
Reset View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
{data.channels.length} channels | {data.times.length} samples |
|
||||
{data.times[0]?.toFixed(2)}s - {data.times[data.times.length - 1]?.toFixed(2)}s
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative w-full ${eventEditMode ? 'cursor-crosshair' : 'cursor-grab active:cursor-grabbing'}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={800}
|
||||
height={height}
|
||||
className="w-full rounded"
|
||||
/>
|
||||
{/* Edit mode indicator */}
|
||||
{eventEditMode && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-green-600 text-white text-xs rounded">
|
||||
Event Edit Mode - Click to add
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Statistics Panel for MEG/EEG Analysis
|
||||
*
|
||||
* Provides UI for running statistical tests on epochs:
|
||||
* - Permutation tests (non-parametric)
|
||||
* - T-tests (parametric)
|
||||
* - Multiple comparison correction
|
||||
* - Effect size calculation
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
permutationTest,
|
||||
ttest,
|
||||
correctPvalues,
|
||||
effectSize,
|
||||
type PermutationTestConfig,
|
||||
type PermutationTestResult,
|
||||
type TTestConfig,
|
||||
type TTestResult,
|
||||
type CorrectionConfig,
|
||||
type CorrectionResult,
|
||||
type EpochsHandle,
|
||||
} from '@/lib/neuro';
|
||||
|
||||
interface StatisticsPanelProps {
|
||||
/** Available epochs sets for analysis */
|
||||
epochsSets: EpochsHandle[];
|
||||
/** Callback when analysis is complete */
|
||||
onAnalysisComplete?: (result: StatisticsResult) => void;
|
||||
}
|
||||
|
||||
interface StatisticsResult {
|
||||
type: 'permutation' | 'ttest';
|
||||
result: PermutationTestResult | TTestResult;
|
||||
effectSize?: number;
|
||||
corrected?: CorrectionResult;
|
||||
}
|
||||
|
||||
export function StatisticsPanel({ epochsSets, onAnalysisComplete }: StatisticsPanelProps) {
|
||||
// State for test configuration
|
||||
const [testType, setTestType] = useState<'permutation' | 'ttest'>('ttest');
|
||||
const [comparisonType, setComparisonType] = useState<'one_sample' | 'paired' | 'independent'>('one_sample');
|
||||
const [epochsIdA, setEpochsIdA] = useState<string>('');
|
||||
const [epochsIdB, setEpochsIdB] = useState<string>('');
|
||||
const [popmean, setPopmean] = useState<number>(0);
|
||||
const [tail, setTail] = useState<'two_sided' | 'less' | 'greater'>('two_sided');
|
||||
const [nPermutations, setNPermutations] = useState<number>(10000);
|
||||
|
||||
// Correction options
|
||||
const [applyCorrection, setApplyCorrection] = useState<boolean>(false);
|
||||
const [correctionMethod, setCorrectionMethod] = useState<'fdr' | 'bonferroni' | 'holm'>('fdr');
|
||||
const [alpha, setAlpha] = useState<number>(0.05);
|
||||
|
||||
// Calculate effect size
|
||||
const [calculateEffectSize, setCalculateEffectSize] = useState<boolean>(true);
|
||||
|
||||
// Results
|
||||
const [isRunning, setIsRunning] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [result, setResult] = useState<StatisticsResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runAnalysis = useCallback(async () => {
|
||||
if (!epochsIdA) {
|
||||
setError('Please select epochs set A');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparisonType !== 'one_sample' && !epochsIdB) {
|
||||
setError('Please select epochs set B for paired/independent comparison');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setProgress(10);
|
||||
|
||||
try {
|
||||
let testResult: PermutationTestResult | TTestResult;
|
||||
let effectSizeValue: number | undefined;
|
||||
|
||||
if (testType === 'permutation') {
|
||||
const config: PermutationTestConfig = {
|
||||
test_type: comparisonType,
|
||||
n_permutations: nPermutations,
|
||||
tail: tail,
|
||||
popmean: comparisonType === 'one_sample' ? popmean : undefined,
|
||||
};
|
||||
|
||||
setProgress(30);
|
||||
testResult = await permutationTest(
|
||||
epochsIdA,
|
||||
config,
|
||||
comparisonType !== 'one_sample' ? epochsIdB : undefined
|
||||
);
|
||||
} else {
|
||||
const config: TTestConfig = {
|
||||
test_type: comparisonType,
|
||||
popmean: comparisonType === 'one_sample' ? popmean : undefined,
|
||||
};
|
||||
|
||||
setProgress(30);
|
||||
testResult = await ttest(
|
||||
epochsIdA,
|
||||
config,
|
||||
comparisonType !== 'one_sample' ? epochsIdB : undefined
|
||||
);
|
||||
}
|
||||
|
||||
setProgress(60);
|
||||
|
||||
// Calculate effect size if requested
|
||||
if (calculateEffectSize) {
|
||||
effectSizeValue = await effectSize(
|
||||
epochsIdA,
|
||||
comparisonType !== 'one_sample' ? epochsIdB : undefined
|
||||
);
|
||||
}
|
||||
|
||||
setProgress(80);
|
||||
|
||||
// Apply correction if requested (for multiple tests)
|
||||
let corrected: CorrectionResult | undefined;
|
||||
if (applyCorrection) {
|
||||
const corrConfig: CorrectionConfig = {
|
||||
method: correctionMethod,
|
||||
alpha: alpha,
|
||||
};
|
||||
corrected = await correctPvalues([testResult.pvalue], corrConfig);
|
||||
}
|
||||
|
||||
setProgress(100);
|
||||
|
||||
const finalResult: StatisticsResult = {
|
||||
type: testType,
|
||||
result: testResult,
|
||||
effectSize: effectSizeValue,
|
||||
corrected: corrected,
|
||||
};
|
||||
|
||||
setResult(finalResult);
|
||||
onAnalysisComplete?.(finalResult);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Analysis failed');
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
setProgress(0);
|
||||
}
|
||||
}, [
|
||||
epochsIdA, epochsIdB, testType, comparisonType, popmean, tail,
|
||||
nPermutations, applyCorrection, correctionMethod, alpha,
|
||||
calculateEffectSize, onAnalysisComplete
|
||||
]);
|
||||
|
||||
const formatPValue = (p: number): string => {
|
||||
if (p < 0.001) return '< 0.001';
|
||||
if (p < 0.01) return p.toFixed(3);
|
||||
return p.toFixed(2);
|
||||
};
|
||||
|
||||
const getSignificanceBadge = (p: number, alpha: number = 0.05) => {
|
||||
if (p < 0.001) return <Badge variant="success">*** p {'<'} 0.001</Badge>;
|
||||
if (p < 0.01) return <Badge variant="success">** p {'<'} 0.01</Badge>;
|
||||
if (p < alpha) return <Badge variant="success">* p {'<'} {alpha}</Badge>;
|
||||
return <Badge variant="secondary">n.s.</Badge>;
|
||||
};
|
||||
|
||||
const getEffectSizeMagnitude = (d: number): { label: string; variant: 'default' | 'secondary' | 'warning' | 'success' } => {
|
||||
const absD = Math.abs(d);
|
||||
if (absD < 0.2) return { label: 'Negligible', variant: 'secondary' };
|
||||
if (absD < 0.5) return { label: 'Small', variant: 'default' };
|
||||
if (absD < 0.8) return { label: 'Medium', variant: 'warning' };
|
||||
return { label: 'Large', variant: 'success' };
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>Statistical Analysis</span>
|
||||
{result && getSignificanceBadge(result.result.pvalue, alpha)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Tabs value={testType} onValueChange={(v) => setTestType(v as 'permutation' | 'ttest')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="ttest">T-Test</TabsTrigger>
|
||||
<TabsTrigger value="permutation">Permutation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ttest" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Parametric t-test assuming normal distribution
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="permutation" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Non-parametric permutation test (distribution-free)
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="n-permutations">Number of Permutations</Label>
|
||||
<Input
|
||||
id="n-permutations"
|
||||
type="number"
|
||||
value={nPermutations}
|
||||
onChange={(e) => setNPermutations(parseInt(e.target.value) || 10000)}
|
||||
min={100}
|
||||
max={100000}
|
||||
step={1000}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Comparison Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Comparison Type</Label>
|
||||
<Select value={comparisonType} onValueChange={(v) => setComparisonType(v as typeof comparisonType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one_sample">One-Sample (vs. population mean)</SelectItem>
|
||||
<SelectItem value="paired">Paired Samples</SelectItem>
|
||||
<SelectItem value="independent">Independent Samples</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Epochs Selection */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Epochs Set A</Label>
|
||||
<Select value={epochsIdA} onValueChange={setEpochsIdA}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select epochs..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{epochsSets.map((epochs) => (
|
||||
<SelectItem key={epochs.id} value={epochs.id}>
|
||||
{epochs.id} ({epochs.n_epochs} epochs)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{comparisonType !== 'one_sample' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Epochs Set B</Label>
|
||||
<Select value={epochsIdB} onValueChange={setEpochsIdB}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select epochs..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{epochsSets.map((epochs) => (
|
||||
<SelectItem key={epochs.id} value={epochs.id}>
|
||||
{epochs.id} ({epochs.n_epochs} epochs)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comparisonType === 'one_sample' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Population Mean</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={popmean}
|
||||
onChange={(e) => setPopmean(parseFloat(e.target.value) || 0)}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tail Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Test Tail</Label>
|
||||
<Select value={tail} onValueChange={(v) => setTail(v as typeof tail)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="two_sided">Two-sided (≠)</SelectItem>
|
||||
<SelectItem value="greater">Greater ({'>'})</SelectItem>
|
||||
<SelectItem value="less">Less ({'<'})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="effect-size"
|
||||
checked={calculateEffectSize}
|
||||
onCheckedChange={(checked) => setCalculateEffectSize(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="effect-size" className="cursor-pointer">
|
||||
Calculate effect size (Cohen's d)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="correction"
|
||||
checked={applyCorrection}
|
||||
onCheckedChange={(checked) => setApplyCorrection(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="correction" className="cursor-pointer">
|
||||
Apply multiple comparison correction
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{applyCorrection && (
|
||||
<div className="ml-6 grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Method</Label>
|
||||
<Select value={correctionMethod} onValueChange={(v) => setCorrectionMethod(v as typeof correctionMethod)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fdr">FDR (Benjamini-Hochberg)</SelectItem>
|
||||
<SelectItem value="bonferroni">Bonferroni</SelectItem>
|
||||
<SelectItem value="holm">Holm-Bonferroni</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Alpha</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={alpha}
|
||||
onChange={(e) => setAlpha(parseFloat(e.target.value) || 0.05)}
|
||||
min={0.001}
|
||||
max={0.1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Run Button */}
|
||||
<Button
|
||||
onClick={runAnalysis}
|
||||
disabled={isRunning || !epochsIdA}
|
||||
className="w-full"
|
||||
>
|
||||
{isRunning ? 'Running Analysis...' : 'Run Statistical Test'}
|
||||
</Button>
|
||||
|
||||
{/* Progress */}
|
||||
{isRunning && (
|
||||
<Progress value={progress} className="w-full" />
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && !error && (
|
||||
<Alert variant={result.result.pvalue < alpha ? 'success' : 'default'}>
|
||||
<AlertTitle>Results</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Test:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{result.type === 'permutation' ? 'Permutation' : 'T-Test'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Statistic:</span>{' '}
|
||||
<span className="font-mono font-medium">
|
||||
{result.result.statistic.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">p-value:</span>{' '}
|
||||
<span className="font-mono font-medium">
|
||||
{formatPValue(result.result.pvalue)}
|
||||
</span>
|
||||
</div>
|
||||
{result.type === 'ttest' && 'df' in result.result && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">df:</span>{' '}
|
||||
<span className="font-mono font-medium">
|
||||
{(result.result as TTestResult).df.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.effectSize !== undefined && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
<span className="text-muted-foreground">Effect Size (d):</span>
|
||||
<span className="font-mono font-medium">
|
||||
{result.effectSize.toFixed(3)}
|
||||
</span>
|
||||
<Badge variant={getEffectSizeMagnitude(result.effectSize).variant}>
|
||||
{getEffectSizeMagnitude(result.effectSize).label}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.type === 'ttest' && 'ci_95' in result.result && (
|
||||
<div className="pt-2 border-t text-sm">
|
||||
<span className="text-muted-foreground">95% CI:</span>{' '}
|
||||
<span className="font-mono">
|
||||
[{(result.result as TTestResult).ci_95[0].toFixed(3)}, {(result.result as TTestResult).ci_95[1].toFixed(3)}]
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatisticsPanel;
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* TimeFrequencyPlot - Time-frequency representation visualization
|
||||
*
|
||||
* Features:
|
||||
* - 2D spectrogram/TFR heatmap
|
||||
* - Multiple colormaps (viridis, hot, jet, coolwarm)
|
||||
* - Interactive hover with time/frequency/power readout
|
||||
* - Customizable axes and colorbar
|
||||
* - Baseline correction display
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface TfrData {
|
||||
/** TFR values [n_freqs x n_times] for single channel */
|
||||
data: number[][];
|
||||
/** Frequency vector */
|
||||
freqs: number[];
|
||||
/** Time vector */
|
||||
times: number[];
|
||||
/** Output type (power, phase, itc) */
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface TimeFrequencyPlotProps {
|
||||
/** TFR data to display */
|
||||
tfr: TfrData | null;
|
||||
/** Title */
|
||||
title?: string;
|
||||
/** Width in pixels */
|
||||
width?: number;
|
||||
/** Height in pixels */
|
||||
height?: number;
|
||||
/** Colormap */
|
||||
colormap?: 'viridis' | 'hot' | 'jet' | 'coolwarm' | 'plasma';
|
||||
/** Log scale for power */
|
||||
logScale?: boolean;
|
||||
/** Min value for color scaling (auto if undefined) */
|
||||
vmin?: number;
|
||||
/** Max value for color scaling (auto if undefined) */
|
||||
vmax?: number;
|
||||
/** Show colorbar */
|
||||
showColorbar?: boolean;
|
||||
/** Baseline interval for correction [start, end] in seconds */
|
||||
baseline?: [number, number];
|
||||
/** Channel name for display */
|
||||
channelName?: string;
|
||||
/** Callback when region is selected */
|
||||
onRegionSelect?: (tmin: number, tmax: number, fmin: number, fmax: number) => void;
|
||||
}
|
||||
|
||||
// Colormap functions
|
||||
const colormaps = {
|
||||
viridis: (t: number): [number, number, number] => {
|
||||
const c = [
|
||||
[0.267, 0.005, 0.329],
|
||||
[0.283, 0.141, 0.458],
|
||||
[0.254, 0.265, 0.530],
|
||||
[0.207, 0.372, 0.553],
|
||||
[0.164, 0.471, 0.558],
|
||||
[0.128, 0.567, 0.551],
|
||||
[0.135, 0.659, 0.518],
|
||||
[0.267, 0.749, 0.441],
|
||||
[0.478, 0.821, 0.318],
|
||||
[0.741, 0.873, 0.150],
|
||||
[0.993, 0.906, 0.144],
|
||||
];
|
||||
const i = Math.min(Math.floor(t * (c.length - 1)), c.length - 2);
|
||||
const f = t * (c.length - 1) - i;
|
||||
return [
|
||||
c[i][0] + f * (c[i + 1][0] - c[i][0]),
|
||||
c[i][1] + f * (c[i + 1][1] - c[i][1]),
|
||||
c[i][2] + f * (c[i + 1][2] - c[i][2]),
|
||||
];
|
||||
},
|
||||
hot: (t: number): [number, number, number] => {
|
||||
if (t < 0.33) return [t * 3, 0, 0];
|
||||
if (t < 0.67) return [1, (t - 0.33) * 3, 0];
|
||||
return [1, 1, (t - 0.67) * 3];
|
||||
},
|
||||
jet: (t: number): [number, number, number] => {
|
||||
if (t < 0.125) return [0, 0, 0.5 + t * 4];
|
||||
if (t < 0.375) return [0, (t - 0.125) * 4, 1];
|
||||
if (t < 0.625) return [(t - 0.375) * 4, 1, 1 - (t - 0.375) * 4];
|
||||
if (t < 0.875) return [1, 1 - (t - 0.625) * 4, 0];
|
||||
return [1 - (t - 0.875) * 4, 0, 0];
|
||||
},
|
||||
coolwarm: (t: number): [number, number, number] => {
|
||||
if (t < 0.5) {
|
||||
const s = t * 2;
|
||||
return [0.23 + 0.77 * s, 0.30 + 0.40 * s, 0.75 - 0.10 * s];
|
||||
}
|
||||
const s = (t - 0.5) * 2;
|
||||
return [0.75 + 0.25 * s, 0.70 - 0.60 * s, 0.65 - 0.55 * s];
|
||||
},
|
||||
plasma: (t: number): [number, number, number] => {
|
||||
const c = [
|
||||
[0.050, 0.030, 0.528],
|
||||
[0.294, 0.012, 0.615],
|
||||
[0.492, 0.012, 0.658],
|
||||
[0.658, 0.138, 0.618],
|
||||
[0.798, 0.280, 0.470],
|
||||
[0.899, 0.434, 0.296],
|
||||
[0.963, 0.613, 0.124],
|
||||
[0.988, 0.811, 0.145],
|
||||
[0.940, 0.975, 0.131],
|
||||
];
|
||||
const i = Math.min(Math.floor(t * (c.length - 1)), c.length - 2);
|
||||
const f = t * (c.length - 1) - i;
|
||||
return [
|
||||
c[i][0] + f * (c[i + 1][0] - c[i][0]),
|
||||
c[i][1] + f * (c[i + 1][1] - c[i][1]),
|
||||
c[i][2] + f * (c[i + 1][2] - c[i][2]),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export function TimeFrequencyPlot({
|
||||
tfr,
|
||||
title = 'Time-Frequency',
|
||||
width = 600,
|
||||
height = 400,
|
||||
colormap = 'viridis',
|
||||
logScale = true,
|
||||
vmin: userVmin,
|
||||
vmax: userVmax,
|
||||
showColorbar = true,
|
||||
baseline,
|
||||
channelName,
|
||||
onRegionSelect: _onRegionSelect,
|
||||
}: TimeFrequencyPlotProps) {
|
||||
// Silence unused variable warning - onRegionSelect is for future use
|
||||
void _onRegionSelect;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [hoveredPoint, setHoveredPoint] = useState<{
|
||||
time: number;
|
||||
freq: number;
|
||||
value: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [internalLogScale, setInternalLogScale] = useState(logScale);
|
||||
|
||||
// Margins for axes
|
||||
const margin = { top: 40, right: showColorbar ? 80 : 20, bottom: 50, left: 60 };
|
||||
const plotWidth = width - margin.left - margin.right;
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
|
||||
// Process data with optional baseline correction and log scaling
|
||||
const { processedData, vmin, vmax } = useMemo(() => {
|
||||
if (!tfr || !tfr.data || tfr.data.length === 0) {
|
||||
return { processedData: null, vmin: 0, vmax: 1 };
|
||||
}
|
||||
|
||||
let data = tfr.data.map(row => [...row]);
|
||||
|
||||
// Apply baseline correction if specified
|
||||
if (baseline && tfr.times.length > 0) {
|
||||
const [bstart, bend] = baseline;
|
||||
const startIdx = tfr.times.findIndex(t => t >= bstart);
|
||||
const endIdx = tfr.times.findIndex(t => t > bend);
|
||||
const baselineEnd = endIdx === -1 ? tfr.times.length : endIdx;
|
||||
|
||||
if (startIdx >= 0 && startIdx < baselineEnd) {
|
||||
data = data.map(row => {
|
||||
const baselineMean = row
|
||||
.slice(startIdx, baselineEnd)
|
||||
.reduce((a, b) => a + b, 0) / (baselineEnd - startIdx);
|
||||
return row.map(v => v / (baselineMean || 1));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply log scaling if enabled
|
||||
if (internalLogScale) {
|
||||
data = data.map(row => row.map(v => Math.log10(Math.max(v, 1e-10))));
|
||||
}
|
||||
|
||||
// Compute value range
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const row of data) {
|
||||
for (const v of row) {
|
||||
if (isFinite(v)) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processedData: data,
|
||||
vmin: userVmin ?? min,
|
||||
vmax: userVmax ?? max,
|
||||
};
|
||||
}, [tfr, baseline, internalLogScale, userVmin, userVmax]);
|
||||
|
||||
// Draw the spectrogram
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx || !processedData || !tfr) return;
|
||||
|
||||
const nFreqs = processedData.length;
|
||||
const nTimes = processedData[0]?.length || 0;
|
||||
if (nFreqs === 0 || nTimes === 0) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Create image data for spectrogram
|
||||
const imageData = ctx.createImageData(plotWidth, plotHeight);
|
||||
const pixels = imageData.data;
|
||||
const range = vmax - vmin || 1;
|
||||
|
||||
for (let py = 0; py < plotHeight; py++) {
|
||||
// Map pixel y to frequency index (flip y so low freq at bottom)
|
||||
const freqIdx = Math.floor((1 - py / plotHeight) * nFreqs);
|
||||
const freqIdxClamped = Math.max(0, Math.min(nFreqs - 1, freqIdx));
|
||||
|
||||
for (let px = 0; px < plotWidth; px++) {
|
||||
// Map pixel x to time index
|
||||
const timeIdx = Math.floor((px / plotWidth) * nTimes);
|
||||
const timeIdxClamped = Math.max(0, Math.min(nTimes - 1, timeIdx));
|
||||
|
||||
const value = processedData[freqIdxClamped][timeIdxClamped];
|
||||
const t = Math.max(0, Math.min(1, (value - vmin) / range));
|
||||
const [r, g, b] = colormaps[colormap](t);
|
||||
|
||||
const idx = (py * plotWidth + px) * 4;
|
||||
pixels[idx] = Math.round(r * 255);
|
||||
pixels[idx + 1] = Math.round(g * 255);
|
||||
pixels[idx + 2] = Math.round(b * 255);
|
||||
pixels[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, margin.left, margin.top);
|
||||
|
||||
// Draw border
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(margin.left, margin.top, plotWidth, plotHeight);
|
||||
|
||||
// Draw axes
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '11px monospace';
|
||||
|
||||
// X-axis (time)
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
const timeRange = tfr.times[tfr.times.length - 1] - tfr.times[0];
|
||||
const nTimeLabels = Math.min(6, Math.floor(plotWidth / 80));
|
||||
for (let i = 0; i <= nTimeLabels; i++) {
|
||||
const t = tfr.times[0] + (i / nTimeLabels) * timeRange;
|
||||
const x = margin.left + (i / nTimeLabels) * plotWidth;
|
||||
ctx.fillText(t.toFixed(2), x, height - margin.bottom + 5);
|
||||
|
||||
// Tick mark
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, margin.top + plotHeight);
|
||||
ctx.lineTo(x, margin.top + plotHeight + 4);
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillText('Time (s)', margin.left + plotWidth / 2, height - 15);
|
||||
|
||||
// Y-axis (frequency)
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
const freqMin = tfr.freqs[0];
|
||||
const freqMax = tfr.freqs[tfr.freqs.length - 1];
|
||||
const nFreqLabels = Math.min(6, Math.floor(plotHeight / 40));
|
||||
for (let i = 0; i <= nFreqLabels; i++) {
|
||||
const f = freqMin + (i / nFreqLabels) * (freqMax - freqMin);
|
||||
const y = margin.top + plotHeight - (i / nFreqLabels) * plotHeight;
|
||||
ctx.fillText(f.toFixed(1), margin.left - 5, y);
|
||||
|
||||
// Tick mark
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin.left - 4, y);
|
||||
ctx.lineTo(margin.left, y);
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Y-axis label (rotated)
|
||||
ctx.save();
|
||||
ctx.translate(15, margin.top + plotHeight / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Frequency (Hz)', 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// Draw colorbar
|
||||
if (showColorbar) {
|
||||
const barWidth = 15;
|
||||
const barHeight = plotHeight;
|
||||
const barX = width - margin.right + 15;
|
||||
const barY = margin.top;
|
||||
|
||||
for (let i = 0; i < barHeight; i++) {
|
||||
const t = 1 - i / barHeight;
|
||||
const [r, g, b] = colormaps[colormap](t);
|
||||
ctx.fillStyle = `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`;
|
||||
ctx.fillRect(barX, barY + i, barWidth, 1);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(barX, barY, barWidth, barHeight);
|
||||
|
||||
// Colorbar labels
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const unit = internalLogScale ? 'dB' : '';
|
||||
ctx.fillText(`${vmax.toFixed(1)}${unit}`, barX + barWidth + 4, barY + 5);
|
||||
ctx.fillText(`${((vmax + vmin) / 2).toFixed(1)}${unit}`, barX + barWidth + 4, barY + barHeight / 2);
|
||||
ctx.fillText(`${vmin.toFixed(1)}${unit}`, barX + barWidth + 4, barY + barHeight - 5);
|
||||
}
|
||||
|
||||
// Draw title
|
||||
ctx.fillStyle = '#aaa';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
const displayTitle = channelName ? `${title} - ${channelName}` : title;
|
||||
ctx.fillText(displayTitle, width / 2, 10);
|
||||
|
||||
// Draw baseline region if specified
|
||||
if (baseline && tfr.times.length > 0) {
|
||||
const [bstart, bend] = baseline;
|
||||
const tMin = tfr.times[0];
|
||||
const tMax = tfr.times[tfr.times.length - 1];
|
||||
const tRange = tMax - tMin;
|
||||
|
||||
if (bstart >= tMin && bend <= tMax) {
|
||||
const x1 = margin.left + ((bstart - tMin) / tRange) * plotWidth;
|
||||
const x2 = margin.left + ((bend - tMin) / tRange) * plotWidth;
|
||||
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.fillRect(x1, margin.top, x2 - x1, plotHeight);
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, margin.top);
|
||||
ctx.lineTo(x1, margin.top + plotHeight);
|
||||
ctx.moveTo(x2, margin.top);
|
||||
ctx.lineTo(x2, margin.top + plotHeight);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw hover crosshairs
|
||||
if (hoveredPoint) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.setLineDash([2, 2]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hoveredPoint.x, margin.top);
|
||||
ctx.lineTo(hoveredPoint.x, margin.top + plotHeight);
|
||||
ctx.moveTo(margin.left, hoveredPoint.y);
|
||||
ctx.lineTo(margin.left + plotWidth, hoveredPoint.y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}, [processedData, tfr, width, height, colormap, vmin, vmax, showColorbar,
|
||||
margin, plotWidth, plotHeight, internalLogScale, baseline, channelName,
|
||||
title, hoveredPoint]);
|
||||
|
||||
// Handle mouse move
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!tfr || !processedData) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Check if within plot area
|
||||
if (x >= margin.left && x <= margin.left + plotWidth &&
|
||||
y >= margin.top && y <= margin.top + plotHeight) {
|
||||
|
||||
const timeIdx = Math.floor(((x - margin.left) / plotWidth) * tfr.times.length);
|
||||
const freqIdx = Math.floor((1 - (y - margin.top) / plotHeight) * tfr.freqs.length);
|
||||
|
||||
if (timeIdx >= 0 && timeIdx < tfr.times.length &&
|
||||
freqIdx >= 0 && freqIdx < tfr.freqs.length) {
|
||||
setHoveredPoint({
|
||||
time: tfr.times[timeIdx],
|
||||
freq: tfr.freqs[freqIdx],
|
||||
value: processedData[freqIdx][timeIdx],
|
||||
x,
|
||||
y,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setHoveredPoint(null);
|
||||
}, [tfr, processedData, margin, plotWidth, plotHeight]);
|
||||
|
||||
// Redraw on data/settings change
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
draw();
|
||||
}
|
||||
}, [width, height, draw]);
|
||||
|
||||
if (!tfr || !tfr.data || tfr.data.length === 0) {
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="flex items-center justify-center" style={{ height }}>
|
||||
<p className="text-zinc-500">No time-frequency data. Compute TFR first.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-zinc-400">{title}</CardTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={internalLogScale ? 'default' : 'outline'}
|
||||
className={`h-6 text-xs px-2 ${internalLogScale ? 'bg-zinc-600' : 'border-zinc-700'}`}
|
||||
onClick={() => setInternalLogScale(!internalLogScale)}
|
||||
>
|
||||
Log Scale
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<div className="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHoveredPoint(null)}
|
||||
className="cursor-crosshair"
|
||||
/>
|
||||
{/* Hover tooltip */}
|
||||
{hoveredPoint && (
|
||||
<div
|
||||
className="absolute bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs pointer-events-none z-10"
|
||||
style={{
|
||||
left: Math.min(hoveredPoint.x + 10, width - 120),
|
||||
top: Math.max(hoveredPoint.y - 50, 10),
|
||||
}}
|
||||
>
|
||||
<div className="text-zinc-400">
|
||||
Time: <span className="text-zinc-200">{hoveredPoint.time.toFixed(3)}s</span>
|
||||
</div>
|
||||
<div className="text-zinc-400">
|
||||
Freq: <span className="text-zinc-200">{hoveredPoint.freq.toFixed(1)} Hz</span>
|
||||
</div>
|
||||
<div className="text-zinc-400">
|
||||
Power: <span className="text-zinc-200 font-mono">
|
||||
{internalLogScale
|
||||
? `${hoveredPoint.value.toFixed(2)} dB`
|
||||
: hoveredPoint.value.toExponential(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Info bar */}
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-zinc-500">
|
||||
<span>
|
||||
{tfr.freqs.length} freqs ({tfr.freqs[0].toFixed(1)}-{tfr.freqs[tfr.freqs.length - 1].toFixed(1)} Hz)
|
||||
</span>
|
||||
<span>
|
||||
{tfr.times.length} time points ({tfr.times[0].toFixed(2)}-{tfr.times[tfr.times.length - 1].toFixed(2)}s)
|
||||
</span>
|
||||
<span>{tfr.output || 'power'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate demo TFR data for testing
|
||||
*/
|
||||
export function generateDemoTfr(
|
||||
duration: number = 2,
|
||||
sfreq: number = 256,
|
||||
fmin: number = 1,
|
||||
fmax: number = 50
|
||||
): TfrData {
|
||||
const nTimes = Math.floor(duration * sfreq / 4); // Decimated
|
||||
const nFreqs = Math.floor(fmax - fmin);
|
||||
|
||||
const times = Array.from({ length: nTimes }, (_, i) => i * 4 / sfreq);
|
||||
const freqs = Array.from({ length: nFreqs }, (_, i) => fmin + i);
|
||||
|
||||
// Generate synthetic TFR with some oscillatory activity
|
||||
const data: number[][] = [];
|
||||
for (let fi = 0; fi < nFreqs; fi++) {
|
||||
const row: number[] = [];
|
||||
const freq = freqs[fi];
|
||||
|
||||
for (let ti = 0; ti < nTimes; ti++) {
|
||||
const t = times[ti];
|
||||
// Base power (decreasing with frequency)
|
||||
let power = 1 / (1 + freq * 0.1);
|
||||
|
||||
// Add alpha band (8-12 Hz) burst around t=1s
|
||||
if (freq >= 8 && freq <= 12) {
|
||||
const alphaBurst = Math.exp(-((t - 1.0) ** 2) / 0.05) * 2;
|
||||
power += alphaBurst;
|
||||
}
|
||||
|
||||
// Add beta band (15-25 Hz) activity around t=0.5s
|
||||
if (freq >= 15 && freq <= 25) {
|
||||
const betaBurst = Math.exp(-((t - 0.5) ** 2) / 0.03) * 1.5;
|
||||
power += betaBurst;
|
||||
}
|
||||
|
||||
// Add some noise
|
||||
power += Math.random() * 0.1;
|
||||
|
||||
row.push(Math.max(power, 0.01));
|
||||
}
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return { data, freqs, times, output: 'power' };
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Topomap - 2D scalp topography visualization
|
||||
*
|
||||
* Features:
|
||||
* - Interpolated scalp map from electrode values
|
||||
* - Standard 10-20 electrode positions
|
||||
* - Customizable colormap
|
||||
* - Interactive channel selection
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export interface TopoChannel {
|
||||
name: string;
|
||||
x: number; // Normalized position [-1, 1]
|
||||
y: number; // Normalized position [-1, 1]
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TopomapProps {
|
||||
/** Channel data with positions and values */
|
||||
channels: TopoChannel[];
|
||||
/** Title for the topomap */
|
||||
title?: string;
|
||||
/** Size in pixels */
|
||||
size?: number;
|
||||
/** Colormap: 'viridis', 'hot', 'coolwarm', 'jet' */
|
||||
colormap?: 'viridis' | 'hot' | 'coolwarm' | 'jet';
|
||||
/** Show electrode markers */
|
||||
showElectrodes?: boolean;
|
||||
/** Show electrode labels */
|
||||
showLabels?: boolean;
|
||||
/** Callback when electrode is clicked */
|
||||
onChannelClick?: (name: string) => void;
|
||||
/** Min value for color scaling (auto if undefined) */
|
||||
vmin?: number;
|
||||
/** Max value for color scaling (auto if undefined) */
|
||||
vmax?: number;
|
||||
}
|
||||
|
||||
// Standard 10-20 electrode positions (normalized to [-1, 1])
|
||||
export const STANDARD_10_20: Record<string, [number, number]> = {
|
||||
'Fp1': [-0.31, 0.95],
|
||||
'Fp2': [0.31, 0.95],
|
||||
'F7': [-0.81, 0.59],
|
||||
'F3': [-0.55, 0.67],
|
||||
'Fz': [0.0, 0.71],
|
||||
'F4': [0.55, 0.67],
|
||||
'F8': [0.81, 0.59],
|
||||
'T3': [-1.0, 0.0],
|
||||
'C3': [-0.55, 0.0],
|
||||
'Cz': [0.0, 0.0],
|
||||
'C4': [0.55, 0.0],
|
||||
'T4': [1.0, 0.0],
|
||||
'T5': [-0.81, -0.59],
|
||||
'P3': [-0.55, -0.67],
|
||||
'Pz': [0.0, -0.71],
|
||||
'P4': [0.55, -0.67],
|
||||
'T6': [0.81, -0.59],
|
||||
'O1': [-0.31, -0.95],
|
||||
'O2': [0.31, -0.95],
|
||||
// Extended 10-10 positions
|
||||
'AF3': [-0.38, 0.85],
|
||||
'AF4': [0.38, 0.85],
|
||||
'FC5': [-0.68, 0.35],
|
||||
'FC1': [-0.27, 0.38],
|
||||
'FC2': [0.27, 0.38],
|
||||
'FC6': [0.68, 0.35],
|
||||
'CP5': [-0.68, -0.35],
|
||||
'CP1': [-0.27, -0.38],
|
||||
'CP2': [0.27, -0.38],
|
||||
'CP6': [0.68, -0.35],
|
||||
'PO3': [-0.38, -0.85],
|
||||
'PO4': [0.38, -0.85],
|
||||
};
|
||||
|
||||
// Colormap functions
|
||||
const colormaps = {
|
||||
viridis: (t: number): [number, number, number] => {
|
||||
const c = [
|
||||
[0.267, 0.005, 0.329],
|
||||
[0.283, 0.141, 0.458],
|
||||
[0.254, 0.265, 0.530],
|
||||
[0.207, 0.372, 0.553],
|
||||
[0.164, 0.471, 0.558],
|
||||
[0.128, 0.567, 0.551],
|
||||
[0.135, 0.659, 0.518],
|
||||
[0.267, 0.749, 0.441],
|
||||
[0.478, 0.821, 0.318],
|
||||
[0.741, 0.873, 0.150],
|
||||
[0.993, 0.906, 0.144],
|
||||
];
|
||||
const i = Math.min(Math.floor(t * (c.length - 1)), c.length - 2);
|
||||
const f = t * (c.length - 1) - i;
|
||||
return [
|
||||
c[i][0] + f * (c[i + 1][0] - c[i][0]),
|
||||
c[i][1] + f * (c[i + 1][1] - c[i][1]),
|
||||
c[i][2] + f * (c[i + 1][2] - c[i][2]),
|
||||
];
|
||||
},
|
||||
hot: (t: number): [number, number, number] => {
|
||||
if (t < 0.33) return [t * 3, 0, 0];
|
||||
if (t < 0.67) return [1, (t - 0.33) * 3, 0];
|
||||
return [1, 1, (t - 0.67) * 3];
|
||||
},
|
||||
coolwarm: (t: number): [number, number, number] => {
|
||||
if (t < 0.5) {
|
||||
const s = t * 2;
|
||||
return [0.23 + 0.77 * s, 0.30 + 0.40 * s, 0.75 - 0.10 * s];
|
||||
}
|
||||
const s = (t - 0.5) * 2;
|
||||
return [0.75 + 0.25 * s, 0.70 - 0.60 * s, 0.65 - 0.55 * s];
|
||||
},
|
||||
jet: (t: number): [number, number, number] => {
|
||||
if (t < 0.125) return [0, 0, 0.5 + t * 4];
|
||||
if (t < 0.375) return [0, (t - 0.125) * 4, 1];
|
||||
if (t < 0.625) return [(t - 0.375) * 4, 1, 1 - (t - 0.375) * 4];
|
||||
if (t < 0.875) return [1, 1 - (t - 0.625) * 4, 0];
|
||||
return [1 - (t - 0.875) * 4, 0, 0];
|
||||
},
|
||||
};
|
||||
|
||||
// Radial basis function interpolation
|
||||
function rbfInterpolate(
|
||||
channels: TopoChannel[],
|
||||
x: number,
|
||||
y: number,
|
||||
epsilon: number = 2.0
|
||||
): number {
|
||||
if (channels.length === 0) return 0;
|
||||
|
||||
let numerator = 0;
|
||||
let denominator = 0;
|
||||
|
||||
for (const ch of channels) {
|
||||
const dx = x - ch.x;
|
||||
const dy = y - ch.y;
|
||||
const r2 = dx * dx + dy * dy;
|
||||
const weight = Math.exp(-epsilon * r2);
|
||||
numerator += weight * ch.value;
|
||||
denominator += weight;
|
||||
}
|
||||
|
||||
return denominator > 1e-10 ? numerator / denominator : 0;
|
||||
}
|
||||
|
||||
export function Topomap({
|
||||
channels,
|
||||
title = 'Topography',
|
||||
size = 300,
|
||||
colormap = 'viridis',
|
||||
showElectrodes = true,
|
||||
showLabels = false,
|
||||
onChannelClick,
|
||||
vmin: userVmin,
|
||||
vmax: userVmax,
|
||||
}: TopomapProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Compute value range
|
||||
const { vmin, vmax } = useMemo(() => {
|
||||
if (channels.length === 0) return { vmin: 0, vmax: 1 };
|
||||
const values = channels.map(c => c.value);
|
||||
const min = userVmin ?? Math.min(...values);
|
||||
const max = userVmax ?? Math.max(...values);
|
||||
const range = max - min;
|
||||
return {
|
||||
vmin: range > 0 ? min : min - 0.5,
|
||||
vmax: range > 0 ? max : max + 0.5,
|
||||
};
|
||||
}, [channels, userVmin, userVmax]);
|
||||
|
||||
// Color helper for external use (kept for API compatibility)
|
||||
const _getColor = useCallback((value: number): string => {
|
||||
const t = Math.max(0, Math.min(1, (value - vmin) / (vmax - vmin || 1)));
|
||||
const [r, g, b] = colormaps[colormap](t);
|
||||
return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`;
|
||||
}, [colormap, vmin, vmax]);
|
||||
void _getColor; // Suppress unused warning - available for external use
|
||||
|
||||
// Draw the topomap
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const radius = Math.min(cx, cy) * 0.85;
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Draw interpolated scalp map
|
||||
if (channels.length > 0) {
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let py = 0; py < height; py++) {
|
||||
for (let px = 0; px < width; px++) {
|
||||
const x = (px - cx) / radius;
|
||||
const y = -(py - cy) / radius; // Flip y for standard orientation
|
||||
const r2 = x * x + y * y;
|
||||
|
||||
if (r2 <= 1.0) { // Inside the head circle
|
||||
const value = rbfInterpolate(channels, x, y);
|
||||
const t = Math.max(0, Math.min(1, (value - vmin) / (vmax - vmin || 1)));
|
||||
const [cr, cg, cb] = colormaps[colormap](t);
|
||||
|
||||
const idx = (py * width + px) * 4;
|
||||
data[idx] = Math.round(cr * 255);
|
||||
data[idx + 1] = Math.round(cg * 255);
|
||||
data[idx + 2] = Math.round(cb * 255);
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
// Draw head outline
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw nose marker (at top)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - radius * 0.1, cy - radius);
|
||||
ctx.lineTo(cx, cy - radius * 1.15);
|
||||
ctx.lineTo(cx + radius * 0.1, cy - radius);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw ears
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx - radius - 5, cy, 8, 15, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx + radius + 5, cy, 8, 15, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw electrodes
|
||||
if (showElectrodes) {
|
||||
for (const ch of channels) {
|
||||
const px = cx + ch.x * radius;
|
||||
const py = cy - ch.y * radius; // Flip y
|
||||
|
||||
// Electrode marker
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
if (showLabels) {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(ch.name, px, py - 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw colorbar
|
||||
const barWidth = 15;
|
||||
const barHeight = height * 0.6;
|
||||
const barX = width - barWidth - 10;
|
||||
const barY = (height - barHeight) / 2;
|
||||
|
||||
for (let i = 0; i < barHeight; i++) {
|
||||
const t = 1 - i / barHeight;
|
||||
const [cr, cg, cb] = colormaps[colormap](t);
|
||||
ctx.fillStyle = `rgb(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)})`;
|
||||
ctx.fillRect(barX, barY + i, barWidth, 1);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(barX, barY, barWidth, barHeight);
|
||||
|
||||
// Colorbar labels
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(vmax.toFixed(2), barX + barWidth + 4, barY + 10);
|
||||
ctx.fillText(vmin.toFixed(2), barX + barWidth + 4, barY + barHeight);
|
||||
}, [channels, colormap, vmin, vmax, showElectrodes, showLabels]);
|
||||
|
||||
// Handle canvas clicks
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!onChannelClick || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) * 0.85;
|
||||
|
||||
// Find clicked channel
|
||||
for (const ch of channels) {
|
||||
const px = cx + ch.x * radius;
|
||||
const py = cy - ch.y * radius;
|
||||
const dist = Math.sqrt((x - px) ** 2 + (y - py) ** 2);
|
||||
if (dist < 10) {
|
||||
onChannelClick(ch.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [channels, onChannelClick]);
|
||||
|
||||
// Draw on mount and data change
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
draw();
|
||||
}
|
||||
}, [size, draw]);
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="flex items-center justify-center" style={{ height: size }}>
|
||||
<p className="text-zinc-500">No channel data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-zinc-400">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center p-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={size}
|
||||
height={size}
|
||||
onClick={handleClick}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create TopoChannel array from channel names and values
|
||||
*/
|
||||
export function createTopoChannels(
|
||||
channelNames: string[],
|
||||
values: number[]
|
||||
): TopoChannel[] {
|
||||
const channels: TopoChannel[] = [];
|
||||
|
||||
for (let i = 0; i < channelNames.length; i++) {
|
||||
const name = channelNames[i].toUpperCase();
|
||||
const pos = STANDARD_10_20[name];
|
||||
if (pos && i < values.length) {
|
||||
channels.push({
|
||||
name: channelNames[i],
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
value: values[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* RustyNeuro UI Components
|
||||
*/
|
||||
|
||||
// Signal visualization
|
||||
export { SignalViewer } from './SignalViewer';
|
||||
export type { SignalViewerProps, CursorPosition, Annotation } from './SignalViewer';
|
||||
|
||||
// 2D Topography
|
||||
export { Topomap, createTopoChannels, STANDARD_10_20 } from './Topomap';
|
||||
export type { TopomapProps, TopoChannel } from './Topomap';
|
||||
|
||||
// 3D Brain visualization
|
||||
export { BrainView3D, generateDemoSources, generateEEGSensors } from './BrainView3D';
|
||||
export type { BrainView3DProps, SourcePoint, SensorPoint, FreeSurferMeshData } from './BrainView3D';
|
||||
|
||||
// Channel selection
|
||||
export { ChannelSelector } from './ChannelSelector';
|
||||
export type { ChannelSelectorProps } from './ChannelSelector';
|
||||
|
||||
// Connectivity visualization
|
||||
export { ConnectivityMatrix, generateDemoConnectivity } from './ConnectivityMatrix';
|
||||
export type { ConnectivityMatrixProps } from './ConnectivityMatrix';
|
||||
|
||||
// Time-Frequency visualization
|
||||
export { TimeFrequencyPlot, generateDemoTfr } from './TimeFrequencyPlot';
|
||||
export type { TimeFrequencyPlotProps, TfrData } from './TimeFrequencyPlot';
|
||||
|
||||
// Power Spectral Density visualization
|
||||
export { PsdPlot, generateDemoPsd } from './PsdPlot';
|
||||
export type { PsdPlotProps, PsdData } from './PsdPlot';
|
||||
|
||||
// Statistical Analysis
|
||||
export { StatisticsPanel } from './StatisticsPanel';
|
||||
export type { default as StatisticsPanelProps } from './StatisticsPanel';
|
||||
Reference in New Issue
Block a user