116 lines
3.4 KiB
TypeScript
116 lines
3.4 KiB
TypeScript
'use client';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
import type { IntervalListState } from '@/lib/visualizations/types';
|
|
import { IntervalBar } from '../primitives/interval-bar';
|
|
import { AnimatePresence } from 'framer-motion';
|
|
|
|
interface IntervalViewProps {
|
|
intervalList: IntervalListState;
|
|
className?: string;
|
|
}
|
|
|
|
const PIXELS_PER_UNIT = 20;
|
|
const BAR_HEIGHT = 28;
|
|
const ROW_GAP = 8;
|
|
|
|
export function IntervalView({
|
|
intervalList,
|
|
className,
|
|
}: IntervalViewProps) {
|
|
// Calculate timeline range
|
|
const minValue = intervalList.minValue ?? 0;
|
|
const maxValue = intervalList.maxValue ?? Math.max(
|
|
...intervalList.intervals.map((i) => i.end),
|
|
20
|
|
);
|
|
|
|
const timelineWidth = (maxValue - minValue) * PIXELS_PER_UNIT;
|
|
|
|
// Generate tick marks for the number line
|
|
const tickStep = maxValue <= 10 ? 1 : maxValue <= 20 ? 2 : 5;
|
|
const ticks: number[] = [];
|
|
for (let i = minValue; i <= maxValue; i += tickStep) {
|
|
ticks.push(i);
|
|
}
|
|
|
|
// Calculate row assignments to prevent overlapping intervals
|
|
const rows: IntervalListState['intervals'][] = [];
|
|
for (const interval of intervalList.intervals) {
|
|
let placed = false;
|
|
for (const row of rows) {
|
|
const overlaps = row.some(
|
|
(existing) => !(interval.end <= existing.start || interval.start >= existing.end)
|
|
);
|
|
if (!overlaps) {
|
|
row.push(interval);
|
|
placed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!placed) {
|
|
rows.push([interval]);
|
|
}
|
|
}
|
|
|
|
const intervalsHeight = rows.length * (BAR_HEIGHT + ROW_GAP) - ROW_GAP;
|
|
|
|
return (
|
|
<div className={cn('flex flex-col items-center', className)}>
|
|
{intervalList.label && (
|
|
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
|
|
{intervalList.label}
|
|
</span>
|
|
)}
|
|
|
|
<div className="relative" style={{ width: `${timelineWidth}px` }}>
|
|
{/* Intervals area */}
|
|
<div
|
|
className="relative mb-2"
|
|
style={{ height: `${Math.max(intervalsHeight, BAR_HEIGHT)}px` }}
|
|
>
|
|
<AnimatePresence mode="popLayout">
|
|
{rows.map((row, rowIndex) =>
|
|
row.map((interval) => (
|
|
<div
|
|
key={interval.id}
|
|
style={{ position: 'absolute', top: `${rowIndex * (BAR_HEIGHT + ROW_GAP)}px` }}
|
|
>
|
|
<IntervalBar
|
|
interval={interval}
|
|
pixelsPerUnit={PIXELS_PER_UNIT}
|
|
minValue={minValue}
|
|
height={BAR_HEIGHT}
|
|
/>
|
|
</div>
|
|
))
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Number line */}
|
|
<div className="relative h-6">
|
|
{/* Horizontal line */}
|
|
<div
|
|
className="absolute top-0 h-px bg-[var(--border)]"
|
|
style={{ width: `${timelineWidth}px` }}
|
|
/>
|
|
|
|
{/* Tick marks and labels */}
|
|
{ticks.map((tick) => {
|
|
const x = (tick - minValue) * PIXELS_PER_UNIT;
|
|
return (
|
|
<div key={tick} className="absolute" style={{ left: `${x}px` }}>
|
|
<div className="h-2 w-px bg-[var(--border)]" />
|
|
<span className="absolute -translate-x-1/2 mt-1 text-xs text-[var(--muted-foreground)]">
|
|
{tick}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|