Followinf in the success of the 028 lab, I'm not going to use the
@visx/brush
component to create a timeline that is automaticaly moves across
time. The idea is to have a timeline that is moving across time, and have some
controls that can be used to start and stop the timeline.
With a bit of work, I was able to get the timeline to move across time, and have
a button that can be used to start and stop the timeline. I also added forward
and rewind buttons and have a speed property which changes the number days it
jumps each tick.
How it works
I started with the base <Timeline />
component from 028 and added some
buttons to the bottom. These I styles and could hook into the onClick()
to
start and stop the animation.
const [ selected , setSelected ] = useState <[ Date , Date ]>([
intervals [ 0 ],
intervals [ 1 ],
]);
const [ window , setWindow ] = useState <[ Date , Date ]>([
intervals [ 0 ],
intervals [ 1 ],
]);
const [ count , setCount ] = useState < number >( 0 );
const [ speed , setSpeed ] = useState < number >( 0 );
The state is used to keep track of the selected and window intervals, the count
of the current interval, and the speed at which the timeline moves.
const onBrushChange = ( domain : Bounds | null ) => {
if ( ! domain ) return ;
setSelected ([ new Date ( domain . x0 ), new Date ( domain . x1 )]);
};
const ontTick = () => {
if ( ! brushRef . current ) return ;
brushRef . current . updateBrush (( prevBrush ) => {
const newStart = addDays ( window [ 0 ], speed );
const newEnd = addDays ( window [ 1 ], speed );
const newExtent = brushRef . current ! . getExtent (
{ x : xScale ( newStart ) },
{ x : xScale ( newEnd ) },
);
setWindow ([ newStart , newEnd ]);
const newState : BaseBrushState = {
... prevBrush ,
start : { y : newExtent . y0 , x : newExtent . x0 },
end : { y : newExtent . y1 , x : newExtent . x1 },
extent : newExtent ,
};
return newState ;
});
};
const onReset = () => {
setSpeed ( 0 );
setWindow ([ intervals [ 0 ], intervals [ 1 ]]);
setSelected ([ intervals [ 0 ], intervals [ 1 ]]);
setCount ( 0 );
if ( ! brushRef . current ) return ;
brushRef . current . updateBrush (( prevBrush ) => {
const newExtent = brushRef . current ! . getExtent (
{ x : xScale ( intervals [ 0 ]) },
{ x : xScale ( intervals [ 1 ]) },
);
const newState : BaseBrushState = {
... prevBrush ,
start : { y : newExtent . y0 , x : newExtent . x0 },
end : { y : newExtent . y1 , x : newExtent . x1 },
extent : newExtent ,
};
return newState ;
});
};
The onBrushChange()
function is used to update the selected interval when the
brush is moved. The onTick()
function is used to update the window interval
and move the brush. The onReset()
function is used to reset the timeline to
the start.
useInterval (
() => {
setCount ( count + 1 );
ontTick ();
},
speed != 0 ? 200 : null ,
);
The useInterval()
hook is from
usehooks-ts is used to call
the onTick()
function every 200ms if the speed is not 0. This is used to
animate the timeline.
Ideas for improvement
Add a slider to control the speed
Add framer motion to smooth out the animation
Move the animation logic into a custom hook
import { Brush } from "@visx/brush" ;
import { AxisTop } from "@visx/axis" ;
import BaseBrush , { BaseBrushState } from "@visx/brush/lib/BaseBrush" ;
import { Bounds } from "@visx/brush/lib/types" ;
import { max , scaleBand , scaleTime } from "d3" ;
import { addDays , eachMonthOfInterval , format } from "date-fns" ;
import { FC , useMemo , useRef , useState } from "react" ;
import { FastForward , Pause , Play , Rewind } from "lucide-react" ;
import { useInterval } from "usehooks-ts" ;
const channels = [
{
channel : "CH 1" ,
start : new Date ( "2021-01-01" ),
end : new Date ( "2021-02-01" ),
},
{
channel : "CH 2" ,
start : new Date ( "2021-02-01" ),
end : new Date ( "2021-05-01" ),
},
{
channel : "CH 2" ,
start : new Date ( "2021-08-01" ),
end : new Date ( "2021-09-01" ),
},
{
channel : "CH 3" ,
start : new Date ( "2021-04-01" ),
end : new Date ( "2021-07-01" ),
},
];
const timeDomain = [ new Date ( "2021-01-01" ), new Date ( "2022-01-01" )];
const channelDomain = Array . from ( new Set ( channels . map (( c ) => c . channel )));
interface ChartProps {
height : number ;
width : number ;
}
export const Timeline : FC < ChartProps > = ({ height , width }) => {
const brushRef = useRef < BaseBrush | null >( null );
// Dimensions
const margin = {
top : 24 ,
bottom : 32 ,
left : 48 ,
right : 0 ,
};
const innerHeight = height - margin . top - margin . bottom ;
const innerWidth = width - margin . left - margin . right ;
const intervals = eachMonthOfInterval ({
start : timeDomain [ 0 ],
end : timeDomain [ 1 ],
});
// Scales
const xScale = scaleTime (). domain ( timeDomain ). range ([ 0 , innerWidth ]);
const yScale = scaleBand ()
. domain ( channelDomain )
. range ([ 0 , innerHeight ])
. paddingInner ( 0.1 );
// Brush
const initialBrushPosition = useMemo (
() => ({
start : { x : xScale ( intervals [ 0 ]) },
end : { x : xScale ( intervals [ 1 ]) },
}),
[ xScale ], // eslint-disable-line react-hooks/exhaustive-deps
);
// Handlers
const [ selected , setSelected ] = useState <[ Date , Date ]>([
intervals [ 0 ],
intervals [ 1 ],
]);
const [ window , setWindow ] = useState <[ Date , Date ]>([
intervals [ 0 ],
intervals [ 1 ],
]);
const [ count , setCount ] = useState < number >( 0 );
const [ speed , setSpeed ] = useState < number >( 0 );
const onBrushChange = ( domain : Bounds | null ) => {
if ( ! domain ) return ;
setSelected ([ new Date ( domain . x0 ), new Date ( domain . x1 )]);
};
const ontTick = () => {
if ( ! brushRef . current ) return ;
brushRef . current . updateBrush (( prevBrush ) => {
const newStart = addDays ( window [ 0 ], speed );
const newEnd = addDays ( window [ 1 ], speed );
const newExtent = brushRef . current ! . getExtent (
{ x : xScale ( newStart ) },
{ x : xScale ( newEnd ) },
);
setWindow ([ newStart , newEnd ]);
const newState : BaseBrushState = {
... prevBrush ,
start : { y : newExtent . y0 , x : newExtent . x0 },
end : { y : newExtent . y1 , x : newExtent . x1 },
extent : newExtent ,
};
return newState ;
});
};
const onReset = () => {
setSpeed ( 0 );
setWindow ([ intervals [ 0 ], intervals [ 1 ]]);
setSelected ([ intervals [ 0 ], intervals [ 1 ]]);
setCount ( 0 );
if ( ! brushRef . current ) return ;
brushRef . current . updateBrush (( prevBrush ) => {
const newExtent = brushRef . current ! . getExtent (
{ x : xScale ( intervals [ 0 ]) },
{ x : xScale ( intervals [ 1 ]) },
);
const newState : BaseBrushState = {
... prevBrush ,
start : { y : newExtent . y0 , x : newExtent . x0 },
end : { y : newExtent . y1 , x : newExtent . x1 },
extent : newExtent ,
};
return newState ;
});
};
useInterval (
() => {
setCount ( count + 1 );
ontTick ();
},
speed != 0 ? 200 : null ,
);
return (
< div
style = { {
background : "light grey" ,
height : height ,
width : width ,
overflow : "scroll" ,
} }
>
< svg width = { width } height = { margin . top + innerHeight } >
< g transform = { `translate( ${ margin . left } ${ margin . top } )` } >
{ /* Axis */ }
< AxisTop
scale = { xScale }
tickFormat = { ( d ) => format ( d as Date , "LLL" ) }
/>
{ intervals . map (( i , idx ) => (
< line
key = { `grid- ${ idx } ` }
x1 = { xScale ( i ) }
x2 = { xScale ( i ) }
y1 = { 0 }
y2 = { height }
stroke = "grey"
strokeOpacity = { 0.5 }
/>
)) }
{ /* Labels */ }
{ channels . map (( c , i ) => (
< text
key = { `text- ${ i } ` }
x = {- margin . left }
y = { ( yScale ( c . channel ) || 0 ) + yScale . bandwidth () / 2 }
fill = "black"
alignmentBaseline = "middle"
>
{ c . channel }
</ text >
)) }
{ /* Rects */ }
{ channels . map (( c , i ) => (
< rect
key = { `rect- ${ i } ` }
rx = { 4 }
x = { xScale ( c . start ) }
y = { yScale ( c . channel ) || 0 }
width = { clampZero ( xScale ( c . end ) - xScale ( c . start )) }
height = { yScale . bandwidth () }
fill = {
intersect ( selected , [ c . start , c . end ]) ? "tomato" : "SteelBlue"
}
/>
)) }
</ g >
< g transform = { `translate( ${ margin . left } ${ margin . top } )` } >
< Brush
xScale = { xScale }
yScale = { yScale }
height = { innerHeight }
width = { innerWidth }
margin = { margin }
innerRef = { brushRef }
initialBrushPosition = { initialBrushPosition }
resizeTriggerAreas = { [] }
onChange = { onBrushChange }
onClick = { onReset }
/>
</ g >
</ svg >
< div className = "not-prose dark:prose-invert grid h-8 grid-flow-col grid-cols-3 items-center gap-1" >
< div className = "flex gap-2" >
< button
onClick = { () => setSpeed ( speed - 1 ) }
className = "bg-primary text-primary-foreground hover:bg-primary/90 w-36 rounded px-4 py-2"
>
< Rewind />
</ button >
< button
onClick = { () => setSpeed ( speed == 0 ? 1 : 0 ) }
className = "bg-primary text-primary-foreground hover:bg-primary/90 w-36 rounded px-4 py-2"
>
{ speed != 0 ? < Pause /> : < Play /> }
</ button >
< button
onClick = { () => setSpeed ( speed + 1 ) }
className = "bg-primary text-primary-foreground hover:bg-primary/90 w-36 rounded px-4 py-2"
>
< FastForward />
</ button >
</ div >
< p className = "line-clamp-1" >
| { selected != undefined ? format ( selected [ 0 ], "LLL d, yyy" ) : null } -
- - { selected != undefined ? format ( selected [ 1 ], "LLL d, yyy" ) : null }{ " " }
|
</ p >
</ div >
</ div >
);
};
const clampZero = ( n : number ) => max ([ n , 0 ]) || 0 ;
const intersect = ( a : [ Date , Date ], b : [ Date , Date ]) =>
a [ 0 ] <= b [ 1 ] && b [ 0 ] <= a [ 1 ];