import { Event } from '@visx/visx'; import type { ScaleLinear, ScaleTime } from 'd3-scale'; import { useAtom, useSetAtom } from 'jotai'; import { all, equals, find, isEmpty, isNil, isNotNil, keys, map, negate, omit, pick, pipe, pluck, reduce, toPairs, values } from 'ramda'; import { type MouseEvent, type MutableRefObject, type ReactElement, useMemo } from 'react'; import { makeStyles } from 'tss-react/mui'; import { formatMetricName, getLineForMetric, getLinesForMetrics, getTimeValue, getYScale } from '../../common/timeSeries'; import type { Line, TimeValue } from '../../common/timeSeries/models'; import { computeGElementMarginLeft, computPixelsToShiftMouse } from '../../common/utils'; import { margin } from '../common'; import type { AnnotationEvent, GraphInterval, InteractedZone, InteractedZone as ZoomPreviewModel } from '../models'; import Annotations from './Annotations'; import type { TimelineEvent } from './Annotations/models'; import Bar from './Bar'; import { changeMousePositionDerivedAtom, eventMouseDownAtom, eventMouseLeaveAtom, eventMouseUpAtom, graphTooltipDataAtom, type MousePosition } from './interactionWithGraphAtoms'; import TimeShiftZones from './TimeShiftZones'; import ZoomPreview from './ZoomPreview'; import { applyingZoomAtomAtom } from './ZoomPreview/zoomPreviewAtoms'; const useStyles = makeStyles()(() => ({ overlay: { cursor: 'crosshair' } })); interface CommonData { graphHeight: number; graphSvgRef: MutableRefObject; graphWidth: number; lines: Array; timeSeries: Array; xScale: ScaleTime; yScalesPerUnit: Record>; } interface TimeShiftZonesData extends InteractedZone { graphInterval: GraphInterval; } interface Props { annotationData?: AnnotationEvent; commonData: CommonData; timeShiftZonesData: TimeShiftZonesData; zoomData: ZoomPreviewModel; transformMatrix?: { fx?: (pointX: number) => number; fy?: (pointY: number) => number; }; maxLeftAxisCharacters: number; additionalZoomMargin?: number; } const InteractionWithGraph = ({ zoomData, commonData, annotationData, timeShiftZonesData, transformMatrix, maxLeftAxisCharacters, additionalZoomMargin = 0 }: Props): ReactElement => { const { classes } = useStyles(); const [isApplyingZoom, setIsApplyingZoom] = useAtom(applyingZoomAtomAtom); const setEventMouseDown = useSetAtom(eventMouseDownAtom); const setEventMouseUp = useSetAtom(eventMouseUpAtom); const setEventMouseLeave = useSetAtom(eventMouseLeaveAtom); const changeMousePosition = useSetAtom(changeMousePositionDerivedAtom); const setGraphTooltipData = useSetAtom(graphTooltipDataAtom); const { graphHeight, graphWidth, graphSvgRef, xScale, timeSeries, lines, yScalesPerUnit } = commonData; const displayZoomPreview = zoomData?.enable ?? true; const displayEventAnnotations = !isNil(annotationData?.data) && !isEmpty(annotationData?.data); const displayTimeShiftZones = timeShiftZonesData?.enable ?? true; const mouseLeave = (event: MouseEvent): void => { setEventMouseLeave(event.nativeEvent); setEventMouseDown(null); updateMousePosition(null); setGraphTooltipData(null); }; const mouseUp = (event: MouseEvent): void => { setEventMouseUp(event.nativeEvent); setEventMouseDown(null); }; const mouseMove = (event: MouseEvent): void => { const mousePoint = Event.localPoint( graphSvgRef?.current as SVGSVGElement, event ); if (!mousePoint) { return; } updateMousePosition([ transformMatrix?.fx?.(mousePoint.x) ?? mousePoint.x, transformMatrix?.fy?.(mousePoint.y) ?? mousePoint.y ]); }; const mouseEnter = (event: MouseEvent): void => { if (event.buttons === 1 && isApplyingZoom) { mouseDown(event); } if (event.buttons === 0 && isApplyingZoom) { setIsApplyingZoom(false); } }; const mouseDown = (event: MouseEvent): void => { setEventMouseDown(event.nativeEvent); }; const graphMarginLeft = useMemo( () => computeGElementMarginLeft(maxLeftAxisCharacters) + additionalZoomMargin, [additionalZoomMargin, maxLeftAxisCharacters] ); const updateMousePosition = (pointPosition: MousePosition): void => { if (isNil(pointPosition)) { changeMousePosition({ position: null }); setGraphTooltipData(null); return; } const pixelToShift = computPixelsToShiftMouse(xScale); const timeValue = getTimeValue({ marginLeft: graphMarginLeft, timeSeries, x: pointPosition[0] - pixelToShift, xScale }); if (isNil(timeValue)) { changeMousePosition({ position: null }); setGraphTooltipData(null); return; } const date = timeValue.timeTick; const displayedMetricIds = pluck('metric_id', lines); const filteredMetricsValue = pick(displayedMetricIds, timeValue); const areAllValuesEmpty = pipe(values, all(isNil))(filteredMetricsValue); const linesData = getLinesForMetrics({ lines, metricIds: keys(filteredMetricsValue).map(Number) }); if (areAllValuesEmpty) { changeMousePosition({ position: pointPosition }); setGraphTooltipData(null); return; } const distanceWithPointPositionPerMetric = reduce( (acc, [metricId, value]) => { if (isNil(value)) { return acc; } const lineData = getLineForMetric({ lines, metric_id: Number(metricId) }); const yScale = getYScale({ invert: (lineData as Line).invert, unit: (lineData as Line).unit, yScalesPerUnit }); if (isNotNil(lineData?.stackOrder)) { const test = Object.entries(omit(['timeTick'], timeValue)).reduce( (acc, [key, value]) => { const line = getLineForMetric({ lines, metric_id: Number(key) }); const isBelowStackOrder = isNotNil(line?.stackOrder) && (line?.stackOrder as number) >= (lineData.stackOrder as number); if (isBelowStackOrder) { return acc + value; } return acc; }, 0 ); const y0 = yScale(test); const diffBetweenY0AndPointPosition = Math.abs( y0 - margin.top - (graphHeight - pointPosition[1]) ); return { ...acc, [metricId]: diffBetweenY0AndPointPosition }; } const y0 = yScale(value); const diffBetweenY0AndPointPosition = Math.abs( y0 + margin.top - pointPosition[1] ); return { ...acc, [metricId]: diffBetweenY0AndPointPosition }; }, {}, Object.entries(filteredMetricsValue) ); const nearestY0 = Math.min(...values(distanceWithPointPositionPerMetric)); const nearestLine = pipe( toPairs, find(([, y0]) => equals(y0, nearestY0)) as () => [string, number] )(distanceWithPointPositionPerMetric); changeMousePosition({ position: pointPosition }); setGraphTooltipData({ date, highlightedMetricId: Number(nearestLine[0]), metrics: map( ({ metric_id, color, unit, legend, name, invert }) => ({ color, id: metric_id, name: formatMetricName({ legend, name }), unit, value: invert ? negate(timeValue?.[metric_id]) : timeValue?.[metric_id] }), linesData ).filter(({ value }) => !isNil(value)) }); }; return ( {displayZoomPreview && ( )} {displayEventAnnotations && ( } graphHeight={graphHeight - margin.bottom} graphSvgRef={graphSvgRef} graphWidth={graphWidth} xScale={xScale} /> )} {displayTimeShiftZones && ( )} ); }; export default InteractionWithGraph;