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