Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -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,
};
}
+513
View File
@@ -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' };
}
+385
View File
@@ -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;
}
+35
View File
@@ -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';