Skip to content

Commit 8ab8825

Browse files
author
Greg Harris
authored
Merge pull request #202 from DiamondLightSource/xycharts-markers
Add capability to draw markers to the xychart
2 parents c8d6e54 + 9f54799 commit 8ab8825

9 files changed

Lines changed: 848 additions & 19 deletions

File tree

src/types/markers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Color, ColorUtils } from "./color";
2+
3+
export interface Marker {
4+
color: Color;
5+
pvName: string;
6+
interactive: boolean;
7+
visible: boolean;
8+
}
9+
10+
export type Markers = Marker[];
11+
12+
export const newMarker = (props: {
13+
color?: Color;
14+
pvName?: string;
15+
interactive?: boolean;
16+
visible?: boolean;
17+
}): Marker => ({
18+
color: props.color ?? ColorUtils.fromRgba(0, 0, 255),
19+
pvName: props.pvName ?? "",
20+
interactive: props.interactive ?? false,
21+
visible: props.visible ?? true
22+
});

src/types/props.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ResponsiveLayout
1717
} from "./responsiveBreakpoints";
1818
import { Rois } from "./rois";
19+
import { Markers } from "./markers";
1920

2021
export type GenericProp =
2122
| string
@@ -35,6 +36,7 @@ export type GenericProp =
3536
| WidgetActions
3637
| OpiFile
3738
| Trace[]
39+
| Markers
3840
| Axes
3941
| Axis
4042
| Points

src/ui/widgets/EmbeddedDisplay/bobParser.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
bobParseResponsiveMargins
5959
} from "./BobParsers/responsiveLayoutBobParser";
6060
import { newRoi, Rois } from "../../../types/rois";
61+
import { Markers, newMarker } from "../../../types/markers";
6162

6263
const BOB_WIDGET_MAPPING: { [key: string]: any } = {
6364
action_button: "actionbutton",
@@ -331,6 +332,27 @@ function bobParseTraces(props: any): Trace[] {
331332
return traces;
332333
}
333334

335+
/**
336+
* Parses props from an array of markers
337+
* @param props list of props for this element
338+
* @returns a array of marker objects
339+
*/
340+
function bobParseMarker(props: any): Markers {
341+
if (props?.marker == null) {
342+
return [] as Markers;
343+
}
344+
345+
let markers = props.marker;
346+
if (!Array.isArray(markers)) {
347+
markers = [markers];
348+
}
349+
350+
return markers.map((m: any) => {
351+
const parsedProps = parseChildProps(m, BOB_SIMPLE_PARSERS);
352+
return newMarker(parsedProps);
353+
});
354+
}
355+
334356
/**
335357
* Parses props from an array of Y axes
336358
* @param props
@@ -623,6 +645,7 @@ export const BOB_SIMPLE_PARSERS: ParserDict = {
623645
displayHorizontal: ["displayHorizontal", opiParseBoolean],
624646
xPv: ["xPv", opiParseString],
625647
yPv: ["yPv", opiParseString],
648+
pvName: ["pv_name", opiParseString],
626649
heightPv: ["heightPv", opiParseString],
627650
widthPv: ["widthPv", opiParseString],
628651
axis: ["axis", bobParseNumber],
@@ -721,6 +744,7 @@ export async function parseBob(
721744
scripts: (scripts: ElementCompact): Script[] =>
722745
scriptParser(scripts, defaultProtocol, false),
723746
traces: (props: ElementCompact) => bobParseTraces(props["traces"]),
747+
marker: (props: ElementCompact) => bobParseMarker(props["marker"]),
724748
axes: (props: ElementCompact) => bobParseYAxes(props["y_axes"]),
725749
regionsOfInterest: (props: ElementCompact) => bobParseRois(props["rois"]),
726750
plt: async (props: ElementCompact) =>

src/ui/widgets/EmbeddedDisplay/parser.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ export async function genericParser(
138138
);
139139
}
140140

141+
if (newProps.hasOwnProperty("marker")) {
142+
const markerPVNames = newProps.marker?.map((marker: any) => ({
143+
pvName: PVUtils.parse(marker.pvName)
144+
}));
145+
newProps.pvMetadataList = [...newProps?.pvMetadataList, ...markerPVNames];
146+
}
147+
141148
// attach an id if it does not exist
142149
if (!newProps?.id) {
143150
newProps["id"] = `${newProps.type}_${crypto.randomUUID()}`;

src/ui/widgets/XYPlot/xyPlot.test.tsx

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ vi.mock("@mui/x-charts", () => ({
2626
MarkPlot: () => <div data-testid="mark-plot" />,
2727
ChartsXAxis: () => <div data-testid="x-axis" />,
2828
ChartsYAxis: () => <div data-testid="y-axis" />,
29-
ChartsLegend: () => <div data-testid="legend" />
29+
ChartsLegend: () => <div data-testid="legend" />,
30+
ChartsReferenceLine: (props: any) => (
31+
<div data-testid="reference-line" data-x={props.x} />
32+
)
3033
}));
3134

3235
vi.mock("../../hooks/useStyle", () => ({
@@ -37,7 +40,8 @@ vi.mock("./xyPlot.utilities", () => ({
3740
buildPlotDataSet: vi.fn(),
3841
buildSeries: vi.fn(),
3942
buildXAxes: vi.fn(),
40-
buildYAxes: vi.fn()
43+
buildYAxes: vi.fn(),
44+
buildMarkerDataSet: vi.fn()
4145
}));
4246

4347
const mockStyle = {
@@ -46,6 +50,7 @@ const mockStyle = {
4650

4751
const baseProps: any = {
4852
traces: [],
53+
marker: [],
4954
axes: [],
5055
pvData: [],
5156
title: "Test Title",
@@ -111,22 +116,63 @@ describe("XYPlotComponent", () => {
111116
baseProps.pvData,
112117
baseProps.visible
113118
);
114-
expect(utils.buildPlotDataSet).toHaveBeenCalledWith(baseProps.pvData);
119+
120+
expect(utils.buildPlotDataSet).toHaveBeenCalledWith(
121+
baseProps.pvData,
122+
baseProps.traces
123+
);
124+
125+
expect(utils.buildMarkerDataSet).toHaveBeenCalledWith(
126+
baseProps.pvData,
127+
baseProps.marker
128+
);
115129
});
116130

117131
it("adds x index when no x-axis data", () => {
118132
(utils.buildXAxes as any).mockReturnValue({
119-
xAxis: [],
133+
xAxis: [{ id: "0", dataKey: "x" }],
120134
hasXAxisData: false
121135
});
122136

123-
(utils.buildPlotDataSet as any).mockReturnValue([{ y: 10 }, { y: 20 }]);
137+
const mockDataset = [{ y: 10 }, { y: 20 }];
138+
(utils.buildPlotDataSet as any).mockReturnValue(mockDataset);
139+
140+
render(<XYPlotComponent {...baseProps} />);
141+
142+
// Verify the component rendered (dataset was transformed)
143+
expect(screen.getByTestId("charts-provider")).toBeInTheDocument();
144+
});
145+
146+
it("does not render X-axis when xAxis.visible is false", () => {
147+
const propsWithHiddenXAxis = {
148+
...baseProps,
149+
xAxis: { visible: false }
150+
};
151+
152+
render(<XYPlotComponent {...propsWithHiddenXAxis} />);
153+
154+
expect(screen.queryByTestId("x-axis")).not.toBeInTheDocument();
155+
});
124156

157+
it("renders X-axis by default", () => {
125158
render(<XYPlotComponent {...baseProps} />);
126159

127-
const call = (utils.buildPlotDataSet as any).mock.results[0].value;
160+
expect(screen.getByTestId("x-axis")).toBeInTheDocument();
161+
});
162+
163+
it("renders only visible Y-axes", () => {
164+
(utils.buildYAxes as any).mockReturnValue({
165+
yAxes: [
166+
{ id: "0", visible: true },
167+
{ id: "1", visible: false }
168+
],
169+
yAxesStyle: {}
170+
});
171+
172+
render(<XYPlotComponent {...baseProps} />);
128173

129-
expect(call).toBeDefined();
174+
const yAxes = screen.getAllByTestId("y-axis");
175+
expect(yAxes).toHaveLength(1);
130176
});
131177

132178
it("renders legend when enabled", () => {
@@ -151,6 +197,53 @@ describe("XYPlotComponent", () => {
151197
);
152198
});
153199

200+
it("renders markers when marker data exists", () => {
201+
const mockMarkers = [
202+
{
203+
pvName: "marker1",
204+
pvValue: 5,
205+
visible: true,
206+
color: { colorString: "red" }
207+
}
208+
];
209+
210+
(utils.buildMarkerDataSet as any).mockReturnValue(mockMarkers);
211+
212+
render(<XYPlotComponent {...baseProps} marker={mockMarkers} />);
213+
214+
expect(screen.getByTestId("charts-surface")).toBeInTheDocument();
215+
});
216+
217+
it("only renders visible markers with pvValue", () => {
218+
const mockMarkers = [
219+
{
220+
pvName: "m1",
221+
pvValue: 5,
222+
visible: true,
223+
color: { colorString: "red" }
224+
},
225+
{
226+
pvName: "m2",
227+
pvValue: null,
228+
visible: true,
229+
color: { colorString: "blue" }
230+
},
231+
{
232+
pvName: "m3",
233+
pvValue: 10,
234+
visible: false,
235+
color: { colorString: "green" }
236+
}
237+
];
238+
239+
(utils.buildMarkerDataSet as any).mockReturnValue(mockMarkers);
240+
241+
render(<XYPlotComponent {...baseProps} />);
242+
243+
const markers = screen.getAllByTestId("reference-line");
244+
expect(markers).toHaveLength(1);
245+
});
246+
154247
it("passes slotProps logic to LinePlot", () => {
155248
const traces = [{ traceType: 0 }, { traceType: 1 }];
156249

@@ -173,4 +266,23 @@ describe("XYPlotComponent", () => {
173266
expect(lineFn({ seriesId: "1" })).toEqual({});
174267
expect(lineFn({ seriesId: "2" })).toEqual({ stroke: "transparent" });
175268
});
269+
270+
it("handles undefined traces gracefully", () => {
271+
const propsWithUndefinedTraces = {
272+
...baseProps,
273+
traces: undefined
274+
};
275+
276+
expect(() =>
277+
render(<XYPlotComponent {...propsWithUndefinedTraces} />)
278+
).not.toThrow();
279+
});
280+
281+
it("handles empty pvData", () => {
282+
(utils.buildPlotDataSet as any).mockReturnValue([]);
283+
284+
render(<XYPlotComponent {...baseProps} pvData={[]} />);
285+
286+
expect(screen.queryByTestId("charts-provider")).not.toBeInTheDocument();
287+
});
176288
});

src/ui/widgets/XYPlot/xyPlot.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
ArchivedDataPropOpt,
1313
IntPropOpt,
1414
TracesPropOpt,
15-
AxisProp
15+
AxisProp,
16+
MarkersPropOpt
1617
} from "../propTypes";
1718
import { registerWidget } from "../register";
1819
import { Box, Typography } from "@mui/material";
@@ -26,26 +27,30 @@ import {
2627
ChartsTooltip,
2728
ChartsAxisHighlight,
2829
ChartsSurface,
29-
ChartsDataProvider
30+
ChartsDataProvider,
31+
ChartsReferenceLine
3032
} from "@mui/x-charts";
3133
import { Axes, Axis } from "../../../types/axis";
3234
import { fontToCss, newFont } from "../../../types/font";
3335
import { useStyle } from "../../hooks/useStyle";
3436
import { DatasetElementType } from "@mui/x-charts/internals";
3537
import {
38+
buildMarkerDataSet,
3639
buildPlotDataSet,
3740
buildSeries,
3841
buildXAxes,
3942
buildYAxes
4043
} from "./xyPlot.utilities";
4144
import { Trace } from "../../../types/trace";
45+
import { Marker } from "../../../types/markers";
4246

4347
const widgetName = "xyplot";
4448

4549
const traceTypesWithoutLines = [0, 3];
4650

4751
const XYPlotProps = {
4852
traces: TracesPropOpt,
53+
marker: MarkersPropOpt,
4954
axes: AxesProp,
5055
xAxis: AxisProp,
5156
start: StringPropOpt,
@@ -80,6 +85,7 @@ export const XYPlotComponent = (props: XYPlotComponentProps): JSX.Element => {
8085

8186
const {
8287
traces,
88+
marker,
8389
axes,
8490
xAxis,
8591
pvData,
@@ -103,8 +109,13 @@ export const XYPlotComponent = (props: XYPlotComponentProps): JSX.Element => {
103109
);
104110

105111
let plotDataSet: DatasetElementType<number>[] = useMemo(
106-
() => buildPlotDataSet(pvData),
107-
[pvData]
112+
() => buildPlotDataSet(pvData, traces as Trace[]),
113+
[pvData, traces]
114+
);
115+
116+
const markerDataSet = useMemo(
117+
() => buildMarkerDataSet(pvData, marker as Marker[]),
118+
[pvData, marker]
108119
);
109120

110121
if (!hasXAxisData) {
@@ -203,6 +214,20 @@ export const XYPlotComponent = (props: XYPlotComponentProps): JSX.Element => {
203214
<ChartsYAxis key={axis.id} axisId={axis.id} />
204215
) : null
205216
)}
217+
218+
{markerDataSet
219+
?.filter(m => m?.pvValue)
220+
?.map(marker =>
221+
marker.visible !== false ? (
222+
<ChartsReferenceLine
223+
key={marker.pvName}
224+
x={marker.pvValue as number}
225+
lineStyle={{
226+
stroke: marker.color?.colorString ?? "black"
227+
}}
228+
/>
229+
) : null
230+
)}
206231
</ChartsSurface>
207232

208233
{showLegend && (

0 commit comments

Comments
 (0)