go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/timeline/body.tsx (about) 1 // Copyright 2024 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { Box, styled } from '@mui/material'; 16 import { ScaleTime, axisLeft, axisTop, select } from 'd3'; 17 import { ReactNode, forwardRef, useEffect, useRef } from 'react'; 18 import { Virtuoso } from 'react-virtuoso'; 19 20 import { 21 useRulerState, 22 useRulerStateSetters, 23 useTimelineConfig, 24 } from './context'; 25 26 const Container = styled(Box)` 27 grid-area: body; 28 `; 29 30 interface OptionalChildrenProps { 31 readonly children?: ReactNode; 32 } 33 34 function BodyItem(props: OptionalChildrenProps) { 35 return <g {...props} />; 36 } 37 38 function BodyRuler() { 39 const state = useRulerState(); 40 const config = useTimelineConfig(); 41 42 return ( 43 <line 44 opacity={state === null ? 0 : 1} 45 stroke="red" 46 pointerEvents="none" 47 x1={state || 0} 48 x2={state || 0} 49 y2={config.itemCount * config.itemHeight} 50 /> 51 ); 52 } 53 54 const BodySvg = forwardRef<HTMLDivElement, OptionalChildrenProps>( 55 function BodySvg({ children, ...props }, _ref) { 56 const config = useTimelineConfig(); 57 const rulerStateSetters = useRulerStateSetters(); 58 const svgRef = useRef<SVGSVGElement | null>(null); 59 60 const horizontalGridLineElement = useRef<SVGGElement | null>(null); 61 useEffect(() => { 62 const horizontalGridLines = axisLeft(config.yScale) 63 .ticks(config.itemCount) 64 .tickFormat(() => '') 65 .tickSize(-config.bodyWidth) 66 .tickFormat(() => ''); 67 horizontalGridLines(select(horizontalGridLineElement.current!)); 68 }, [config.itemCount, config.yScale, config.bodyWidth]); 69 70 const verticalGridLineElement = useRef<SVGGElement | null>(null); 71 useEffect(() => { 72 const verticalGridLines = axisTop(config.xScale) 73 .ticks(config.timeInterval) 74 .tickSize(-config.itemCount * config.itemHeight) 75 .tickFormat(() => ''); 76 verticalGridLines(select(verticalGridLineElement.current!)); 77 }, [ 78 config.itemCount, 79 config.itemHeight, 80 config.xScale, 81 config.timeInterval, 82 ]); 83 84 const height = config.itemHeight * config.itemCount; 85 return ( 86 <svg 87 {...props} 88 height={height} 89 width={config.bodyWidth} 90 ref={svgRef} 91 onMouseOver={() => rulerStateSetters.setDisplay(true)} 92 onMouseOut={() => rulerStateSetters.setDisplay(false)} 93 onMouseMove={(e) => { 94 const svgBox = svgRef.current!.getBoundingClientRect(); 95 const x = e.clientX - svgBox.x; 96 rulerStateSetters.setX(x); 97 }} 98 > 99 <g transform="translate(0.5, 0.5)"> 100 <g 101 ref={horizontalGridLineElement} 102 transform={`translate(-1, 0)`} 103 css={{ '& line': { stroke: 'var(--divider-color)' } }} 104 /> 105 <g 106 ref={verticalGridLineElement} 107 transform={`translate(0, -1)`} 108 css={{ '& line': { stroke: 'var(--divider-color)' } }} 109 /> 110 {children} 111 <BodyRuler /> 112 </g> 113 <path 114 d={`m${config.bodyWidth - 0.5},0v${height + 2}`} 115 stroke="currentcolor" 116 /> 117 </svg> 118 ); 119 }, 120 ); 121 122 export interface BodyProps { 123 readonly content: ( 124 index: number, 125 xScale: ScaleTime<number, number, never>, 126 ) => ReactNode; 127 } 128 129 export function Body({ content }: BodyProps) { 130 const config = useTimelineConfig(); 131 132 return ( 133 <Container width={config.bodyWidth}> 134 <Virtuoso 135 useWindowScroll 136 components={{ Item: BodyItem, List: BodySvg }} 137 totalCount={config.itemCount} 138 fixedItemHeight={config.itemHeight} 139 itemContent={(index) => { 140 return ( 141 <g transform={`translate(0, ${(index + 0.5) * config.itemHeight})`}> 142 {content(index, config.xScale)} 143 </g> 144 ); 145 }} 146 /> 147 </Container> 148 ); 149 }