diff --git a/src/types/markers.ts b/src/types/markers.ts new file mode 100644 index 00000000..a194a753 --- /dev/null +++ b/src/types/markers.ts @@ -0,0 +1,22 @@ +import { Color, ColorUtils } from "./color"; + +export interface Marker { + color: Color; + pvName: string; + interactive: boolean; + visible: boolean; +} + +export type Markers = Marker[]; + +export const newMarker = (props: { + color?: Color; + pvName?: string; + interactive?: boolean; + visible?: boolean; +}): Marker => ({ + color: props.color ?? ColorUtils.fromRgba(0, 0, 255), + pvName: props.pvName ?? "", + interactive: props.interactive ?? false, + visible: props.visible ?? true +}); diff --git a/src/types/props.ts b/src/types/props.ts index f4eb853b..af9ee241 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -16,6 +16,7 @@ import { ResponsiveLayout } from "./responsiveBreakpoints"; import { Rois } from "./rois"; +import { Markers } from "./markers"; export type GenericProp = | string @@ -35,6 +36,7 @@ export type GenericProp = | WidgetActions | OpiFile | Trace[] + | Markers | Axes | Axis | Points diff --git a/src/ui/widgets/EmbeddedDisplay/bobParser.ts b/src/ui/widgets/EmbeddedDisplay/bobParser.ts index 74e7a9ea..24536ee3 100644 --- a/src/ui/widgets/EmbeddedDisplay/bobParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/bobParser.ts @@ -58,6 +58,7 @@ import { bobParseResponsiveMargins } from "./BobParsers/responsiveLayoutBobParser"; import { newRoi, Rois } from "../../../types/rois"; +import { Markers, newMarker } from "../../../types/markers"; const BOB_WIDGET_MAPPING: { [key: string]: any } = { action_button: "actionbutton", @@ -331,6 +332,27 @@ function bobParseTraces(props: any): Trace[] { return traces; } +/** + * Parses props from an array of markers + * @param props list of props for this element + * @returns a array of marker objects + */ +function bobParseMarker(props: any): Markers { + if (props?.marker == null) { + return [] as Markers; + } + + let markers = props.marker; + if (!Array.isArray(markers)) { + markers = [markers]; + } + + return markers.map((m: any) => { + const parsedProps = parseChildProps(m, BOB_SIMPLE_PARSERS); + return newMarker(parsedProps); + }); +} + /** * Parses props from an array of Y axes * @param props @@ -623,6 +645,7 @@ export const BOB_SIMPLE_PARSERS: ParserDict = { displayHorizontal: ["displayHorizontal", opiParseBoolean], xPv: ["xPv", opiParseString], yPv: ["yPv", opiParseString], + pvName: ["pv_name", opiParseString], heightPv: ["heightPv", opiParseString], widthPv: ["widthPv", opiParseString], axis: ["axis", bobParseNumber], @@ -721,6 +744,7 @@ export async function parseBob( scripts: (scripts: ElementCompact): Script[] => scriptParser(scripts, defaultProtocol, false), traces: (props: ElementCompact) => bobParseTraces(props["traces"]), + marker: (props: ElementCompact) => bobParseMarker(props["marker"]), axes: (props: ElementCompact) => bobParseYAxes(props["y_axes"]), regionsOfInterest: (props: ElementCompact) => bobParseRois(props["rois"]), plt: async (props: ElementCompact) => diff --git a/src/ui/widgets/EmbeddedDisplay/parser.ts b/src/ui/widgets/EmbeddedDisplay/parser.ts index 82def6bc..e92aa5db 100644 --- a/src/ui/widgets/EmbeddedDisplay/parser.ts +++ b/src/ui/widgets/EmbeddedDisplay/parser.ts @@ -138,6 +138,13 @@ export async function genericParser( ); } + if (newProps.hasOwnProperty("marker")) { + const markerPVNames = newProps.marker?.map((marker: any) => ({ + pvName: PVUtils.parse(marker.pvName) + })); + newProps.pvMetadataList = [...newProps?.pvMetadataList, ...markerPVNames]; + } + // attach an id if it does not exist if (!newProps?.id) { newProps["id"] = `${newProps.type}_${crypto.randomUUID()}`; diff --git a/src/ui/widgets/XYPlot/xyPlot.test.tsx b/src/ui/widgets/XYPlot/xyPlot.test.tsx index 7b61b9c8..439b7421 100644 --- a/src/ui/widgets/XYPlot/xyPlot.test.tsx +++ b/src/ui/widgets/XYPlot/xyPlot.test.tsx @@ -26,7 +26,10 @@ vi.mock("@mui/x-charts", () => ({ MarkPlot: () =>
, ChartsXAxis: () =>
, ChartsYAxis: () =>
, - ChartsLegend: () =>
+ ChartsLegend: () =>
, + ChartsReferenceLine: (props: any) => ( +
+ ) })); vi.mock("../../hooks/useStyle", () => ({ @@ -37,7 +40,8 @@ vi.mock("./xyPlot.utilities", () => ({ buildPlotDataSet: vi.fn(), buildSeries: vi.fn(), buildXAxes: vi.fn(), - buildYAxes: vi.fn() + buildYAxes: vi.fn(), + buildMarkerDataSet: vi.fn() })); const mockStyle = { @@ -46,6 +50,7 @@ const mockStyle = { const baseProps: any = { traces: [], + marker: [], axes: [], pvData: [], title: "Test Title", @@ -111,22 +116,63 @@ describe("XYPlotComponent", () => { baseProps.pvData, baseProps.visible ); - expect(utils.buildPlotDataSet).toHaveBeenCalledWith(baseProps.pvData); + + expect(utils.buildPlotDataSet).toHaveBeenCalledWith( + baseProps.pvData, + baseProps.traces + ); + + expect(utils.buildMarkerDataSet).toHaveBeenCalledWith( + baseProps.pvData, + baseProps.marker + ); }); it("adds x index when no x-axis data", () => { (utils.buildXAxes as any).mockReturnValue({ - xAxis: [], + xAxis: [{ id: "0", dataKey: "x" }], hasXAxisData: false }); - (utils.buildPlotDataSet as any).mockReturnValue([{ y: 10 }, { y: 20 }]); + const mockDataset = [{ y: 10 }, { y: 20 }]; + (utils.buildPlotDataSet as any).mockReturnValue(mockDataset); + + render(); + + // Verify the component rendered (dataset was transformed) + expect(screen.getByTestId("charts-provider")).toBeInTheDocument(); + }); + + it("does not render X-axis when xAxis.visible is false", () => { + const propsWithHiddenXAxis = { + ...baseProps, + xAxis: { visible: false } + }; + + render(); + + expect(screen.queryByTestId("x-axis")).not.toBeInTheDocument(); + }); + it("renders X-axis by default", () => { render(); - const call = (utils.buildPlotDataSet as any).mock.results[0].value; + expect(screen.getByTestId("x-axis")).toBeInTheDocument(); + }); + + it("renders only visible Y-axes", () => { + (utils.buildYAxes as any).mockReturnValue({ + yAxes: [ + { id: "0", visible: true }, + { id: "1", visible: false } + ], + yAxesStyle: {} + }); + + render(); - expect(call).toBeDefined(); + const yAxes = screen.getAllByTestId("y-axis"); + expect(yAxes).toHaveLength(1); }); it("renders legend when enabled", () => { @@ -151,6 +197,53 @@ describe("XYPlotComponent", () => { ); }); + it("renders markers when marker data exists", () => { + const mockMarkers = [ + { + pvName: "marker1", + pvValue: 5, + visible: true, + color: { colorString: "red" } + } + ]; + + (utils.buildMarkerDataSet as any).mockReturnValue(mockMarkers); + + render(); + + expect(screen.getByTestId("charts-surface")).toBeInTheDocument(); + }); + + it("only renders visible markers with pvValue", () => { + const mockMarkers = [ + { + pvName: "m1", + pvValue: 5, + visible: true, + color: { colorString: "red" } + }, + { + pvName: "m2", + pvValue: null, + visible: true, + color: { colorString: "blue" } + }, + { + pvName: "m3", + pvValue: 10, + visible: false, + color: { colorString: "green" } + } + ]; + + (utils.buildMarkerDataSet as any).mockReturnValue(mockMarkers); + + render(); + + const markers = screen.getAllByTestId("reference-line"); + expect(markers).toHaveLength(1); + }); + it("passes slotProps logic to LinePlot", () => { const traces = [{ traceType: 0 }, { traceType: 1 }]; @@ -173,4 +266,23 @@ describe("XYPlotComponent", () => { expect(lineFn({ seriesId: "1" })).toEqual({}); expect(lineFn({ seriesId: "2" })).toEqual({ stroke: "transparent" }); }); + + it("handles undefined traces gracefully", () => { + const propsWithUndefinedTraces = { + ...baseProps, + traces: undefined + }; + + expect(() => + render() + ).not.toThrow(); + }); + + it("handles empty pvData", () => { + (utils.buildPlotDataSet as any).mockReturnValue([]); + + render(); + + expect(screen.queryByTestId("charts-provider")).not.toBeInTheDocument(); + }); }); diff --git a/src/ui/widgets/XYPlot/xyPlot.tsx b/src/ui/widgets/XYPlot/xyPlot.tsx index a8ea2563..c544e2c8 100644 --- a/src/ui/widgets/XYPlot/xyPlot.tsx +++ b/src/ui/widgets/XYPlot/xyPlot.tsx @@ -12,7 +12,8 @@ import { ArchivedDataPropOpt, IntPropOpt, TracesPropOpt, - AxisProp + AxisProp, + MarkersPropOpt } from "../propTypes"; import { registerWidget } from "../register"; import { Box, Typography } from "@mui/material"; @@ -26,19 +27,22 @@ import { ChartsTooltip, ChartsAxisHighlight, ChartsSurface, - ChartsDataProvider + ChartsDataProvider, + ChartsReferenceLine } from "@mui/x-charts"; import { Axes, Axis } from "../../../types/axis"; import { fontToCss, newFont } from "../../../types/font"; import { useStyle } from "../../hooks/useStyle"; import { DatasetElementType } from "@mui/x-charts/internals"; import { + buildMarkerDataSet, buildPlotDataSet, buildSeries, buildXAxes, buildYAxes } from "./xyPlot.utilities"; import { Trace } from "../../../types/trace"; +import { Marker } from "../../../types/markers"; const widgetName = "xyplot"; @@ -46,6 +50,7 @@ const traceTypesWithoutLines = [0, 3]; const XYPlotProps = { traces: TracesPropOpt, + marker: MarkersPropOpt, axes: AxesProp, xAxis: AxisProp, start: StringPropOpt, @@ -80,6 +85,7 @@ export const XYPlotComponent = (props: XYPlotComponentProps): JSX.Element => { const { traces, + marker, axes, xAxis, pvData, @@ -103,8 +109,13 @@ export const XYPlotComponent = (props: XYPlotComponentProps): JSX.Element => { ); let plotDataSet: DatasetElementType[] = useMemo( - () => buildPlotDataSet(pvData), - [pvData] + () => buildPlotDataSet(pvData, traces as Trace[]), + [pvData, traces] + ); + + const markerDataSet = useMemo( + () => buildMarkerDataSet(pvData, marker as Marker[]), + [pvData, marker] ); if (!hasXAxisData) { @@ -203,6 +214,20 @@ export const XYPlotComponent = (props: XYPlotComponentProps): JSX.Element => { ) : null )} + + {markerDataSet + ?.filter(m => m?.pvValue) + ?.map(marker => + marker.visible !== false ? ( + + ) : null + )} {showLegend && ( diff --git a/src/ui/widgets/XYPlot/xyPlot.utilities.test.ts b/src/ui/widgets/XYPlot/xyPlot.utilities.test.ts index 8145d428..23ea7252 100644 --- a/src/ui/widgets/XYPlot/xyPlot.utilities.test.ts +++ b/src/ui/widgets/XYPlot/xyPlot.utilities.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { + buildMarkerDataSet, buildPlotDataSet, buildSeries, buildXAxes, @@ -10,7 +11,8 @@ import { Trace } from "../../../types/trace"; import { Axis } from "../../../types/axis"; vi.mock("../../../types/dtypes", () => ({ - dTypeCoerceArray: vi.fn(val => val) + dTypeCoerceArray: vi.fn(val => val), + dTypeCoerceDouble: vi.fn(val => Number(val)) })); vi.mock("../utils", () => ({ @@ -31,14 +33,154 @@ vi.mock("../../../types/font", () => ({ fontToCss: vi.fn(() => ({})) })); +describe("buildMarkerDataSet", () => { + it("builds marker dataset from pvData", () => { + const pvData = [ + { effectivePvName: "sensor.marker1", value: 10 }, + { effectivePvName: "sensor.marker2", value: 20 } + ] as any; + + const markers = [ + { + pvName: "marker1", + visible: true, + color: { colorString: "red" } + }, + { + pvName: "marker2", + visible: true, + color: { colorString: "blue" } + } + ] as any; + + const result = buildMarkerDataSet(pvData, markers); + + expect(result).toEqual([ + { + pvName: "marker1", + visible: true, + color: { colorString: "red" }, + pvValue: 10 + }, + { + pvName: "marker2", + visible: true, + color: { colorString: "blue" }, + pvValue: 20 + } + ]); + }); + + it("returns empty array when no markers provided", () => { + const result = buildMarkerDataSet([], []); + expect(result).toEqual([]); + }); + + it("returns empty array when markers is undefined", () => { + const result = buildMarkerDataSet([], undefined as any); + expect(result).toEqual([]); + }); + + it("handles marker with no matching pvData", () => { + const pvData = [{ effectivePvName: "sensor.other", value: 10 }] as any; + + const markers = [ + { pvName: "marker1", visible: true, color: { colorString: "red" } } + ] as any; + + const result = buildMarkerDataSet(pvData, markers); + + expect(result).toEqual([ + { + pvName: "marker1", + visible: true, + color: { colorString: "red" }, + pvValue: undefined + } + ]); + }); + + it("filters out null markers", () => { + const pvData = [{ effectivePvName: "sensor.marker1", value: 10 }] as any; + + const markers = [ + null, + { pvName: "marker1", visible: true, color: { colorString: "red" } }, + undefined + ] as any; + + const result = buildMarkerDataSet(pvData, markers); + + expect(result).toHaveLength(1); + expect(result[0].pvName).toBe("marker1"); + }); + + it("filters out markers without pvName", () => { + const pvData = [{ effectivePvName: "sensor.marker1", value: 10 }] as any; + + const markers = [ + { visible: true, color: { colorString: "red" } }, // no pvName + { pvName: "marker1", visible: true, color: { colorString: "blue" } } + ] as any; + + const result = buildMarkerDataSet(pvData, markers); + + expect(result).toHaveLength(1); + expect(result[0].pvName).toBe("marker1"); + }); + + it("handles pvData with null value", () => { + const pvData = [{ effectivePvName: "sensor.marker1", value: null }] as any; + + const markers = [ + { pvName: "marker1", visible: true, color: { colorString: "red" } } + ] as any; + + const result = buildMarkerDataSet(pvData, markers); + + expect(result[0].pvValue).toBeUndefined(); + }); + + it("uses endsWith matching for pvName", () => { + const pvData = [ + { effectivePvName: "prefix.sensor.marker1", value: 10 } + ] as any; + + const markers = [ + { pvName: "marker1", visible: true, color: { colorString: "red" } } + ] as any; + + const result = buildMarkerDataSet(pvData, markers); + + expect(result[0].pvValue).toBe(10); + }); +}); + describe("buildPlotDataSet", () => { + const baseTraces = [ + { + yPv: "a", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + } as Trace, + { + yPv: "b", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + } as Trace + ]; + it("builds dataset aligned by shortest series", () => { const pvData = [ { effectivePvName: "a", value: [1, 2, 3] }, { effectivePvName: "b", value: [10, 20] } ] as any; - const result = buildPlotDataSet(pvData); + const result = buildPlotDataSet(pvData, baseTraces); expect(result).toEqual([ { a: 1, b: 10 }, @@ -53,19 +195,19 @@ describe("buildPlotDataSet", () => { { effectivePvName: "b", value: [1, 2] } ] as any; - const result = buildPlotDataSet(pvData); + const result = buildPlotDataSet(pvData, baseTraces); expect(result).toEqual([{ b: 1 }, { b: 2 }]); }); it("returns empty array when no valid data", () => { - expect(buildPlotDataSet([])).toEqual([]); + expect(buildPlotDataSet([], baseTraces)).toEqual([]); }); it("coerces values to numbers", () => { const pvData = [{ effectivePvName: "a", value: ["1", "2"] }] as any; - const result = buildPlotDataSet(pvData); + const result = buildPlotDataSet(pvData, baseTraces); expect(result).toEqual([{ a: 1 }, { a: 2 }]); }); @@ -76,7 +218,7 @@ describe("buildPlotDataSet", () => { { effectivePvName: "a", value: [3, 4] } ] as any; - const result = buildPlotDataSet(pvData); + const result = buildPlotDataSet(pvData, baseTraces); expect(result).toEqual([{ a: 3 }, { a: 4 }]); }); @@ -168,6 +310,254 @@ describe("buildSeries", () => { expect(result[0].yAxisId).toBe("2"); }); + + it("filters out null traces", () => { + const pvData = [ + { effectivePvName: "sensor.temp" }, + { effectivePvName: "sensor.pressure" } + ] as any; + + const traces = [ + { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + }, + null, + { + yPv: "pressure", + name: "Pressure", + color: { colorString: "blue" }, + pointType: 1, + traceType: 1 + }, + undefined + ] as any; + + const result = buildSeries(traces, pvData, true); + + expect(result).toHaveLength(2); + expect(result[0].label).toBe("Temp"); + expect(result[1].label).toBe("Pressure"); + }); + + it("hides marks when pointType is 0", () => { + const pvData = [{ effectivePvName: "sensor.temp" }] as any; + const trace = { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 0, + traceType: 1 + } as any; + + const result = buildSeries([trace], pvData, true); + + expect(result[0]).toMatchObject({ + showMark: false + }); + }); + + it("handles multiple traces with different types", () => { + const pvData = [ + { effectivePvName: "sensor.temp" }, + { effectivePvName: "sensor.pressure" } + ] as any; + + const traces = [ + { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 // line + }, + { + yPv: "pressure", + name: "Pressure", + color: { colorString: "blue" }, + pointType: 0, + traceType: 5 // bar + } + ] as any; + + const result = buildSeries(traces, pvData, true); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe("line"); + expect(result[1].type).toBe("bar"); + }); + + it("uses linear curve by default", () => { + const pvData = [{ effectivePvName: "sensor.temp" }] as any; + const trace = { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + } as any; + + const result = buildSeries([trace], pvData, true); + + expect(result[0]).toMatchObject({ + curve: "linear" + }); + }); + + it("sets connectNulls to false", () => { + const pvData = [{ effectivePvName: "sensor.temp" }] as any; + const trace = { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + } as any; + + const result = buildSeries([trace], pvData, true); + + expect(result[0]).toMatchObject({ + connectNulls: false + }); + }); + + it("applies all marker shapes correctly", () => { + const pvData = [{ effectivePvName: "sensor.temp" }] as any; + + const markerShapes = [ + { pointType: 0, expected: undefined }, + { pointType: 1, expected: "square" }, + { pointType: 2, expected: "circle" }, + { pointType: 3, expected: "diamond" }, + { pointType: 4, expected: "cross" }, + { pointType: 5, expected: "triangle" } + ]; + + markerShapes.forEach(({ pointType, expected }) => { + const trace = { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType, + traceType: 1 + } as any; + + const result = buildSeries([trace], pvData, true); + + expect(result[0]).toMatchObject({ + shape: expected + }); + }); + }); + + it("filters traces when effectivePvName doesn't match any pvData", () => { + const pvData = [{ effectivePvName: "sensor.temp" }] as any; + + const traces = [ + { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + }, + { + yPv: "humidity", // This doesn't match any pvData + name: "Humidity", + color: { colorString: "blue" }, + pointType: 1, + traceType: 1 + } + ] as any; + + const result = buildSeries(traces, pvData, true); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe("Temp"); + }); + + it("handles endsWith matching for yPv", () => { + const pvData = [{ effectivePvName: "prefix.long.path.sensor.temp" }] as any; + + const trace = { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + } as any; + + const result = buildSeries([trace], pvData, true); + + expect(result).toHaveLength(1); + expect(result[0].dataKey).toBe("prefix.long.path.sensor.temp"); + }); + + it("assigns correct series ID", () => { + const pvData = [ + { effectivePvName: "sensor.temp" }, + { effectivePvName: "sensor.pressure" } + ] as any; + + const traces = [ + { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + }, + { + yPv: "pressure", + name: "Pressure", + color: { colorString: "blue" }, + pointType: 1, + traceType: 1 + } + ] as any; + + const result = buildSeries(traces, pvData, true); + + expect(result[0].id).toBe("0"); + expect(result[1].id).toBe("1"); + }); + + it("sets labelMarkType to line for line series", () => { + const pvData = [{ effectivePvName: "sensor.temp" }] as any; + const trace = { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 1 + } as any; + + const result = buildSeries([trace], pvData, true); + + expect(result[0]).toMatchObject({ + labelMarkType: "line" + }); + }); + + it("does not include labelMarkType for bar series", () => { + const pvData = [{ effectivePvName: "sensor.temp" }] as any; + const trace = { + yPv: "temp", + name: "Temp", + color: { colorString: "red" }, + pointType: 1, + traceType: 5 + } as any; + + const result = buildSeries([trace], pvData, true); + + expect(result[0]).not.toHaveProperty("labelMarkType"); + expect(result[0]).not.toHaveProperty("curve"); + expect(result[0]).not.toHaveProperty("showMark"); + }); }); describe("buildXAxes", () => { @@ -221,6 +611,79 @@ describe("buildXAxes", () => { expect(result.xAxis[0].scaleType).toBe("band"); }); + + it("uses symlog scale when logScale is true", () => { + const traces = [{ xPv: "time", traceType: 1 }] as any; + const xAxis = { + title: "X", + logScale: true, + autoscale: true + } as any; + + const result = buildXAxes(traces, style, xAxis); + + expect(result.xAxis[0].scaleType).toBe("symlog"); + }); + + it("does not apply min/max when autoscale is true", () => { + const traces = [{ xPv: "time", traceType: 1 }] as any; + const xAxis = { + title: "X", + autoscale: true, + minimum: 0, + maximum: 100 + } as any; + + const result = buildXAxes(traces, style, xAxis); + + expect(result.xAxis[0].min).toBeUndefined(); + expect(result.xAxis[0].max).toBeUndefined(); + }); + + it("ignores non-finite min/max values", () => { + const traces = [{ xPv: "time", traceType: 1 }] as any; + const xAxis = { + title: "X", + autoscale: false, + minimum: Infinity, + maximum: -Infinity + } as any; + + const result = buildXAxes(traces, style, xAxis); + + expect(result.xAxis[0].min).toBeUndefined(); + expect(result.xAxis[0].max).toBeUndefined(); + }); + + it("uses first trace with xPv when multiple exist", () => { + const traces = [ + { xPv: "time1", traceType: 1 }, + { xPv: "time2", traceType: 1 } + ] as any; + const xAxis = {} as any; + + const result = buildXAxes(traces, style, xAxis); + + expect(result.xAxis[0].dataKey).toBe("time1"); + }); + + it("applies axis label from xAxisDefinition", () => { + const traces = [{ xPv: "time", traceType: 1 }] as any; + const xAxis = { + title: "Time (seconds)" + } as any; + + const result = buildXAxes(traces, style, xAxis); + + expect(result.xAxis[0].label).toBe("Time (seconds)"); + }); + + it("handles undefined traces", () => { + const result = buildXAxes(undefined, style, {} as any); + + expect(result.xAxis[0].dataKey).toBe("x"); + expect(result.hasXAxisData).toBe(false); + }); }); describe("buildYAxes", () => { @@ -365,4 +828,136 @@ describe("buildYAxes", () => { expect(result.yAxes[0].labelStyle).toBeDefined(); expect(result.yAxes[0].tickLabelStyle).toBeDefined(); }); + + it("handles multiple axes", () => { + const axes = [ + { + title: "Y1", + color: { colorString: "red" }, + autoscale: true, + onRight: false, + logScale: false + }, + { + title: "Y2", + color: { colorString: "blue" }, + autoscale: true, + onRight: true, + logScale: false + } + ] as any; + + const result = buildYAxes(axes); + + expect(result.yAxes).toHaveLength(2); + expect(result.yAxes[0].position).toBe("left"); + expect(result.yAxes[1].position).toBe("right"); + }); + + it("formats small values in exponential notation", () => { + const axes = [ + { + title: "Y", + color: { colorString: "black" }, + autoscale: true, + onRight: false, + logScale: false + } + ] as any; + + const { yAxes } = buildYAxes(axes); + const formatter = yAxes[0].valueFormatter; + + expect(formatter(0.0001, { location: "tick" })).toMatch(/e/); + expect(formatter(0.001, { location: "tick" })).toBe("0.001"); + }); + + it("formats negative values correctly", () => { + const axes = [ + { + title: "Y", + color: { colorString: "black" }, + autoscale: true, + onRight: false, + logScale: false + } + ] as any; + + const { yAxes } = buildYAxes(axes); + const formatter = yAxes[0].valueFormatter; + + expect(formatter(-5, { location: "tooltip" })).toBe("-5"); + expect(formatter(-10000, { location: "tick" })).toMatch(/-.*e/); + }); + + it("handles equal min and max (creates invalid range)", () => { + const axes = [ + { + title: "Y", + color: { colorString: "black" }, + autoscale: false, + minimum: 5, + maximum: 5, + onRight: false, + logScale: false + } + ] as any; + + const result = buildYAxes(axes); + + expect(result.yAxes[0].min).toBeUndefined(); + expect(result.yAxes[0].max).toBeUndefined(); + }); + + it("sets correct axis IDs", () => { + const axes = [ + { + title: "Y1", + color: { colorString: "red" }, + autoscale: true, + onRight: false, + logScale: false + }, + { + title: "Y2", + color: { colorString: "blue" }, + autoscale: true, + onRight: false, + logScale: false + } + ] as any; + + const result = buildYAxes(axes); + + expect(result.yAxes[0].id).toBe("0"); + expect(result.yAxes[1].id).toBe("1"); + }); + + it("creates style for each axis", () => { + const axes = [ + { + title: "Y1", + color: { colorString: "red" }, + autoscale: true, + onRight: false, + logScale: false + }, + { + title: "Y2", + color: { colorString: "blue" }, + autoscale: true, + onRight: false, + logScale: false + } + ] as any; + + const result = buildYAxes(axes); + + expect( + result.yAxesStyle['& .MuiChartsAxis-root[data-axis-id="0"]'] + ).toBeDefined(); + expect( + result.yAxesStyle['& .MuiChartsAxis-root[data-axis-id="1"]'] + ).toBeDefined(); + }); }); diff --git a/src/ui/widgets/XYPlot/xyPlot.utilities.ts b/src/ui/widgets/XYPlot/xyPlot.utilities.ts index b1e756dc..facba2ca 100644 --- a/src/ui/widgets/XYPlot/xyPlot.utilities.ts +++ b/src/ui/widgets/XYPlot/xyPlot.utilities.ts @@ -7,12 +7,13 @@ import { YAxis } from "@mui/x-charts/internals"; import { PvDatum } from "../../../redux/csState"; -import { dTypeCoerceArray } from "../../../types/dtypes"; +import { dTypeCoerceArray, dTypeCoerceDouble } from "../../../types/dtypes"; import { CurveType } from "@mui/x-charts"; import { newTrace, Trace } from "../../../types/trace"; import { UseStyleResult } from "../../hooks/useStyle"; import { Axes, Axis, newAxis } from "../../../types/axis"; import { fontToCss } from "../../../types/font"; +import { Marker } from "../../../types/markers"; const MARKER_STYLES: (MarkShape | undefined)[] = [ undefined, @@ -24,7 +25,8 @@ const MARKER_STYLES: (MarkShape | undefined)[] = [ ]; export const buildPlotDataSet = ( - pvData: PvDatum[] + pvData: PvDatum[], + traces: (Trace | null | undefined)[] ): DatasetElementType[] => { const remappedData = pvData ?.filter( @@ -34,6 +36,12 @@ export const buildPlotDataSet = ( datum?.value && dTypeCoerceArray(datum?.value).length > 0 ) + ?.filter( + datum => + traces && + (traces.some(t => t?.yPv && datum?.effectivePvName?.endsWith(t?.yPv)) || + traces.some(t => t?.xPv && datum?.effectivePvName?.endsWith(t?.xPv))) + ) ?.map(datum => ({ [datum?.effectivePvName]: datum?.value != null ? dTypeCoerceArray(datum?.value) : [] @@ -50,6 +58,31 @@ export const buildPlotDataSet = ( ); }; +export const buildMarkerDataSet = ( + pvData: PvDatum[], + markers: (Marker | null | undefined)[] +) => { + if (!markers || markers?.length === 0) { + return []; + } + + return markers + ?.filter(marker => marker != null && marker?.pvName) + ?.map(marker => { + const pvDatum = pvData.find( + d => + d?.effectivePvName && + marker?.pvName && + d?.effectivePvName?.endsWith(marker.pvName) + ); + return { + ...marker, + pvValue: + pvDatum?.value != null ? dTypeCoerceDouble(pvDatum?.value) : undefined + }; + }); +}; + export const buildSeries = ( traces: (Trace | null | undefined)[] | undefined, pvData: PvDatum[], diff --git a/src/ui/widgets/propTypes.ts b/src/ui/widgets/propTypes.ts index c1275352..eb68b70b 100644 --- a/src/ui/widgets/propTypes.ts +++ b/src/ui/widgets/propTypes.ts @@ -130,6 +130,15 @@ export const AxisPropOpt = PropTypes.shape({ }); export const AxisProp = AxisPropOpt.isRequired; +export const MarkerProp = PropTypes.shape({ + color: ColorPropOpt, + pvName: StringPropOpt, + interactive: BoolPropOpt, + visible: BoolPropOpt +}); + +export const MarkersPropOpt = PropTypes.arrayOf(MarkerProp); + export const AxesProp = PropTypes.arrayOf(AxisProp).isRequired; export const AxesPropOpt = PropTypes.arrayOf(AxisPropOpt);