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  }