Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function Legend() {
<div className="pointer-events-auto bg-white/80 dark:bg-neutral-900/80 backdrop-blur rounded-md border border-gray-300 dark:border-neutral-700 p-2 max-w-xs">
<div className="text-xs font-semibold mb-1">Legend</div>
<div className="flex flex-col gap-1">
{series.map((s) => (
{series.filter(s => s.visible).map((s) => (
<button key={s.id} className="flex items-center gap-2 text-left text-xs hover:opacity-90 cursor-pointer" onClick={() => setEditingId(s.id)}>
<span className="inline-block w-3 h-3 rounded-sm" style={{ background: s.color }} />
<span className="truncate">{s.name}</span>
Expand Down
22 changes: 17 additions & 5 deletions src/components/StatsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { PlotSnapshot } from '../types/plot'
import { useRef, useState } from 'react'
import Button from './ui/Button'
import Checkbox from './ui/Checkbox'
import { CameraIcon } from '@heroicons/react/24/outline'
import * as htmlToImage from 'html-to-image'
import { useDataStore } from '../store/dataStore'

interface Props {
snapshot: PlotSnapshot
Expand Down Expand Up @@ -72,16 +74,26 @@ function computeStats(values: Float32Array, numBins = 24): SeriesStats {
return { min, max, mean, median, stddev, bins, count: n, mode }
}

function StatsCard({ name, color, data, id }: { name: string, color: string, data: Float32Array, id: number }) {
function StatsCard({ name, color, data, id, visible }: { name: string, color: string, data: Float32Array, id: number, visible: boolean }) {
const histRef = useRef<HTMLDivElement | null>(null)
const [tooltip, setTooltip] = useState<{ visible: boolean, x: number, y: number, text: string }>({ visible: false, x: 0, y: 0, text: '' })
const st = computeStats(data)
const store = useDataStore()
// Only compute stats if the series is visible
const st = visible ? computeStats(data) : { min: NaN, max: NaN, mean: NaN, median: NaN, stddev: NaN, bins: new Array(24).fill(0), count: 0, mode: NaN }
const maxBin = Math.max(1, ...st.bins)
const fmt = (x: number) => (Number.isFinite(x) ? x.toFixed(3) : '—')
return (
<div className="rounded-md p-2" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }} id={`stat-card-${id}`}>
<div className="rounded-md p-2 transition-opacity" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)', opacity: visible ? 1 : 0.4 }} id={`stat-card-${id}`}>
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium" style={{ color }}>{name}</div>
<div className="flex items-center gap-2">
<Checkbox
checked={visible}
onChange={() => store.toggleSeriesVisibility(id)}
title="Show/hide trace"
aria-label="Toggle trace visibility"
/>
<div className="text-sm font-medium" style={{ color }}>{name}</div>
</div>
<div className="flex items-center gap-2">
<div className="text-[10px] opacity-60">n={st.count}</div>
<Button size="sm" aria-label="Save PNG" title="Save PNG" onClick={async () => {
Expand Down Expand Up @@ -161,7 +173,7 @@ export function StatsPanel({ snapshot }: Props) {
return (
<div className="mt-2 grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
{snapshot.series.map((s) => (
<StatsCard key={s.id} id={s.id} name={s.name} color={s.color} data={snapshot.getSeriesData(s.id)} />
<StatsCard key={s.id} id={s.id} name={s.name} color={s.color} visible={s.visible} data={snapshot.getSeriesData(s.id)} />
))}
</div>
)
Expand Down
4 changes: 2 additions & 2 deletions src/components/__tests__/Legend.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import Legend from '../Legend'
const renameSeries = vi.fn()
const setSeriesColor = vi.fn()
const mockSeries = [
{ id: 0, name: 'Channel A', color: '#ff0000' },
{ id: 1, name: 'Channel B', color: '#00ff00' },
{ id: 0, name: 'Channel A', color: '#ff0000', visible: true },
{ id: 1, name: 'Channel B', color: '#00ff00', visible: true },
]

vi.mock('../../store/dataStore', () => ({
Expand Down
2 changes: 1 addition & 1 deletion src/components/__tests__/PlotCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { render } from '@testing-library/react'
import { PlotCanvas } from '../PlotCanvas'

function makeSnapshot(values: number[]) {
const series = [{ id: 0, name: 'S1', color: '#fff' }]
const series = [{ id: 0, name: 'S1', color: '#fff', visible: true }]
const data = new Float32Array(values)
return {
series,
Expand Down
4 changes: 2 additions & 2 deletions src/components/__tests__/SeriesPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import SeriesPanel from '../SeriesPanel'
const renameSeries = vi.fn()
const setSeriesColor = vi.fn()
const mockSeries = [
{ id: 0, name: 'S1', color: '#111111' },
{ id: 1, name: 'S2', color: '#222222' },
{ id: 0, name: 'S1', color: '#111111', visible: true },
{ id: 1, name: 'S2', color: '#222222', visible: true },
]

vi.mock('../../store/dataStore', () => ({
Expand Down
9 changes: 7 additions & 2 deletions src/components/__tests__/StatsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { PlotSnapshot } from '../../types/plot'
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { StatsPanel } from '../StatsPanel'
import { DataStoreProvider } from '../../store/dataStore'

function makeSnapshot(values: number[]) {
const series = [{ id: 0, name: 'S1', color: '#fff' }]
const series = [{ id: 0, name: 'S1', color: '#fff', visible: true }]
const data = new Float32Array(values)
return {
series,
Expand All @@ -20,7 +21,11 @@ function makeSnapshot(values: number[]) {
describe('StatsPanel', () => {
it('renders stats values for a single series', () => {
const snap = makeSnapshot([1, 2, 3, 4]) as unknown as PlotSnapshot
render(<StatsPanel snapshot={snap} />)
render(
<DataStoreProvider>
<StatsPanel snapshot={snap} />
</DataStoreProvider>
)
expect(screen.getByText('S1')).toBeInTheDocument()
expect(screen.getByText(/min:/i)).toBeInTheDocument()
expect(screen.getByText(/max:/i)).toBeInTheDocument()
Expand Down
18 changes: 15 additions & 3 deletions src/store/RingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class RingStore {

reset(capacity = 100000, viewPortSize = 200, seriesNames: string[]) {
// construct with a set of series
this.series = new Array(seriesNames.length).fill(0).map((_, i) => ({ id: i, name: seriesNames[i], color: DEFAULT_COLORS[i % DEFAULT_COLORS.length] }))
this.series = new Array(seriesNames.length).fill(0).map((_, i) => ({ id: i, name: seriesNames[i], color: DEFAULT_COLORS[i % DEFAULT_COLORS.length], visible: true }))
// create the buffers for storing the series data - these should be filled with NaN
this.buffers = new Array(seriesNames.length).fill(0).map(() => new Float32Array(capacity).fill(NaN))
// create the timestamps for each sample - these should be filled with NaN
Expand Down Expand Up @@ -195,7 +195,8 @@ export class RingStore {
const newSeries: PlotSeries = {
id: i,
name: `S${i + 1}`,
color: DEFAULT_COLORS[i % DEFAULT_COLORS.length]
color: DEFAULT_COLORS[i % DEFAULT_COLORS.length],
visible: true
}
this.series.push(newSeries)

Expand Down Expand Up @@ -289,10 +290,13 @@ export class RingStore {
}
}

// Compute y-range from the current viewport window
// Compute y-range from the current viewport window (only for visible series)
let yMin = Number.POSITIVE_INFINITY
let yMax = Number.NEGATIVE_INFINITY
for (let k = 0; k < seriesViews.length; k++) {
// Skip hidden series
if (!this.series[k]?.visible) continue

const arr = seriesViews[k]
for (let i = 0; i < arr.length; i++) {
const v = arr[i]
Expand Down Expand Up @@ -467,6 +471,14 @@ export class RingStore {
target.color = color
this.emit()
}

toggleSeriesVisibility(id: number) {
const target = this.series.find((m) => m.id === id)
if (!target) return
target.visible = !target.visible
this.invalidateViewPortCache() // visibility affects y-axis range calculation
this.emit()
}
}

const DEFAULT_COLORS = ['#60a5fa','#f472b6','#34d399','#fbbf24','#a78bfa','#f87171','#22d3ee','#84cc16']
Expand Down
22 changes: 19 additions & 3 deletions src/store/__tests__/RingStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ describe('RingStore', () => {
// Create 3 series by appending data
store.append([1, 2, 3])
const series = store.getSeries()
expect(series[0]).toEqual({ id: 0, name: 'S1', color: '#60a5fa' })
expect(series[1]).toEqual({ id: 1, name: 'S2', color: '#f472b6' })
expect(series[2]).toEqual({ id: 2, name: 'S3', color: '#34d399' })
expect(series[0]).toEqual({ id: 0, name: 'S1', color: '#60a5fa', visible: true })
expect(series[1]).toEqual({ id: 1, name: 'S2', color: '#f472b6', visible: true })
expect(series[2]).toEqual({ id: 2, name: 'S3', color: '#34d399', visible: true })
})

it('should set new series', () => {
Expand Down Expand Up @@ -68,6 +68,22 @@ describe('RingStore', () => {
store.renameSeries(999, 'InvalidID')
expect(store.getSeries()[0].name).toBe(originalName)
})

it('should toggle series visibility', () => {
store.append([1, 2, 3]) // Create series first
expect(store.getSeries()[0].visible).toBe(true)
store.toggleSeriesVisibility(0)
expect(store.getSeries()[0].visible).toBe(false)
store.toggleSeriesVisibility(0)
expect(store.getSeries()[0].visible).toBe(true)
})

it('should handle invalid series ID for toggle visibility', () => {
store.append([1, 2, 3]) // Create series first
const originalVisibility = store.getSeries()[0].visible
store.toggleSeriesVisibility(999)
expect(store.getSeries()[0].visible).toBe(originalVisibility)
})
})

describe('Data Appending', () => {
Expand Down
1 change: 1 addition & 0 deletions src/types/plot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface PlotSeries {
id: number
name: string
color: string
visible: boolean
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/utils/__tests__/chartExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { ViewPortData } from '../../store/RingStore'

const mockViewPortData: ViewPortData = {
series: [
{ id: 0, name: 'Temperature', color: '#ff0000' },
{ id: 1, name: 'Humidity', color: '#00ff00' }
{ id: 0, name: 'Temperature', color: '#ff0000', visible: true },
{ id: 1, name: 'Humidity', color: '#00ff00', visible: true }
],
getSeriesData: (id: number) => {
if (id === 0) return new Float32Array([23.5, 24.0, 24.5, NaN])
Expand All @@ -22,8 +22,8 @@ const mockViewPortData: ViewPortData = {

const mockStore = {
getSeries: () => [
{ id: 0, name: 'Temperature', color: '#ff0000' },
{ id: 1, name: 'Humidity', color: '#00ff00' }
{ id: 0, name: 'Temperature', color: '#ff0000', visible: true },
{ id: 1, name: 'Humidity', color: '#00ff00', visible: true }
],
getCapacity: () => 5,
writeIndex: 4,
Expand Down
20 changes: 19 additions & 1 deletion src/utils/__tests__/plotRendering.drawSeries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('drawSeries', () => {
const ctx = makeCtx()
const chart = { x: 0, y: 0, width: 100, height: 50 }
const snapshot = {
series: [{ id: 0, name: 'S1', color: '#fff' }],
series: [{ id: 0, name: 'S1', color: '#fff', visible: true }],
getSeriesData: () => new Float32Array([0, NaN, 1, 2]),
viewPortSize: 4,
} as unknown as import('../../store/RingStore').ViewPortData
Expand All @@ -24,6 +24,24 @@ describe('drawSeries', () => {
expect(ctx.beginPath).toHaveBeenCalled()
expect(ctx.stroke).toHaveBeenCalled()
})

it('skips hidden series', () => {
const ctx = makeCtx()
const chart = { x: 0, y: 0, width: 100, height: 50 }
const snapshot = {
series: [
{ id: 0, name: 'S1', color: '#fff', visible: false },
{ id: 1, name: 'S2', color: '#aaa', visible: true }
],
getSeriesData: (id: number) => id === 0 ? new Float32Array([1, 2, 3]) : new Float32Array([4, 5, 6]),
viewPortSize: 3,
} as unknown as import('../../store/RingStore').ViewPortData

drawSeries(ctx, chart, snapshot, -1, 10)

// Should only draw once (for visible series S2), not twice
expect(ctx.stroke).toHaveBeenCalledTimes(1)
})
})


8 changes: 7 additions & 1 deletion src/utils/plotRendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ export function drawSeries(

// Draw each series
for (const series of snapshot.series) {
// Skip hidden series
if (!series.visible) continue

const data = snapshot.getSeriesData(series.id)
if (!data || data.length === 0) continue

Expand Down Expand Up @@ -384,9 +387,12 @@ export function drawHoverTooltip(
const timestamp = times[sampleIndex]
if (!Number.isFinite(timestamp)) return // Skip NaN timestamps

// Collect all series values at this index
// Collect all series values at this index (only for visible series)
const values: Array<{ name: string; value: number; color: string }> = []
for (const series of snapshot.series) {
// Skip hidden series
if (!series.visible) continue

const data = snapshot.getSeriesData(series.id)
if (sampleIndex < data.length) {
const value = data[sampleIndex]
Expand Down