import { GlyphCircle } from "@visx/glyph"; import { scaleLinear } from "@visx/scale"; import { interpolateNumber as d3InterpolateNumber } from "d3-interpolate"; import { observer } from "mobx-react"; import { Ref, forwardRef, useImperativeHandle, useMemo } from "react"; import { ChartPoint } from "../../../Charts/ChartData"; import { ChartItem } from "../../../ModelMixins/ChartableMixin"; import Glyphs from "./Glyphs"; import type { XScale, YScale } from "./types"; interface MomentPointsChartProps { id: string; chartItem: ChartItem; basisItem?: ChartItem; basisItemScales?: { x: XScale; y: YScale }; scales: { x: XScale; y: YScale }; glyph?: string; } export type ChartZoomFunction = (scales: { x: (arg0: number | Date) => number; y: (arg0: number) => number; }) => void; const _MomentPointsChart = function MomentPointsChart( props: MomentPointsChartProps, ref: Ref<{ doZoom: ChartZoomFunction }> ) { const { id, chartItem, basisItem, basisItemScales, scales, glyph = "circle" } = props; const points = useMemo(() => { if (basisItem && basisItemScales) { // We want to stick the chartItem points to the basis item, to do this we // interpolate the chart item points to match the basis item points. This // interpolation should not affect the scale of the chart item points. const basisToSourceScale = scaleLinear({ domain: basisItemScales.y.domain(), range: scales.y.domain() }); const interpolatedPoints = chartItem.points.map((p) => ({ ...p, ...interpolate(p, basisItem.points, basisToSourceScale) })); return interpolatedPoints; } return chartItem.points; }, [chartItem, basisItem, basisItemScales, scales]); // Allow BottomDockChart and others to control zoom useImperativeHandle( ref, () => ({ doZoom(scales) { if (points.length === 0) { return; } const vGlyphs = document.querySelectorAll(`g#${id} > g.visx-glyph`); vGlyphs.forEach((vGlyph, i) => { const point = points[i]; if (point) { const left = scales.x(point.x); const top = scales.y(point.y); const scale = point.isSelected ? "scale(1.4, 1.4)" : ""; vGlyph.setAttribute( "transform", `translate(${left}, ${top}) ${scale}` ); vGlyph.setAttribute( "fill-opacity", `${point.isSelected ? 1.0 : 0.3}` ); } }); } }), [id, points] ); const baseKey = `moment-point-${chartItem.categoryName}-${chartItem.name}`; const fillColor = chartItem.getColor(); const isClickable = chartItem.onClick !== undefined; const clickProps = (point: (typeof points)[0]) => { if (isClickable) { return { pointerEvents: "all", cursor: "pointer", onClick: () => chartItem.onClick(point) }; } return {}; }; const Glyph = Glyphs[glyph as keyof typeof Glyphs] ?? GlyphCircle; return ( {points.map((p, i) => ( ))} ); }; /** Interpolates the given source point {x, y} to the closet point in the `sortedPoints` array. * * The source point and `sortedBasisPoints` may be of different scale, so we use `basisToSourceScale` * to generate a point in the original source items scale. */ function interpolate( p: ChartPoint, sortedBasisPoints: ChartPoint[], basisToSourceScale: ReturnType ) { // MomentPointsChart always has Dates for x coordinates const x = p.x as Date; const closest = closestPointIndex(x, sortedBasisPoints); if (closest === undefined) { return p; } const a = sortedBasisPoints[closest]; const b = sortedBasisPoints[closest + 1]; if (a === undefined || b === undefined) { return p; } const aTime = (a.x as Date).getTime(); const bTime = (b.x as Date).getTime(); const xAsPercentage = (x.getTime() - aTime) / (bTime - aTime); const interpolated = { x, y: d3InterpolateNumber( basisToSourceScale(a.y) as number, basisToSourceScale(b.y) as number )(xAsPercentage) }; return interpolated; } function closestPointIndex(x: Date, sortedPoints: ChartPoint[]) { const xTime = x.getTime(); const index = sortedPoints.findIndex((p) => (p.x as Date).getTime() >= xTime); if (index === -1) { return undefined; } if (index === 0) { return 0; } return index - 1; } export default observer(forwardRef(_MomentPointsChart));