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));