diff --git a/package-lock.json b/package-lock.json
index ab4b6c0..d8046d6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@diamondlightsource/cs-web-lib",
- "version": "0.10.14",
+ "version": "0.10.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@diamondlightsource/cs-web-lib",
- "version": "0.10.14",
+ "version": "0.10.15",
"license": "ISC",
"dependencies": {
"@mui/icons-material": "^7.3.7",
diff --git a/package.json b/package.json
index 67fe943..b66c25a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@diamondlightsource/cs-web-lib",
- "version": "0.10.14",
+ "version": "0.10.15",
"description": "Control system web library",
"main": "./dist/index.cjs",
"scripts": {
diff --git a/src/ui/widgets/EmbeddedDisplay/bobParser.ts b/src/ui/widgets/EmbeddedDisplay/bobParser.ts
index bf4d8c5..1481a93 100644
--- a/src/ui/widgets/EmbeddedDisplay/bobParser.ts
+++ b/src/ui/widgets/EmbeddedDisplay/bobParser.ts
@@ -607,6 +607,7 @@ export const BOB_SIMPLE_PARSERS: ParserDict = {
majorTickStepHint: ["major_tick_step_hint", bobParseNumber],
maximum: ["maximum", bobParseNumber],
minimum: ["minimum", bobParseNumber],
+ autoscale: ["autoscale", opiParseBoolean],
format: ["format", bobParseNumber],
emptyColor: ["empty_color", opiParseColor],
knobColor: ["knob_color", opiParseColor],
diff --git a/src/ui/widgets/XYPlot/xyPlot.test.tsx b/src/ui/widgets/XYPlot/xyPlot.test.tsx
index 848aec0..7b61b9c 100644
--- a/src/ui/widgets/XYPlot/xyPlot.test.tsx
+++ b/src/ui/widgets/XYPlot/xyPlot.test.tsx
@@ -9,9 +9,15 @@ import { useStyle } from "../../hooks/useStyle";
const linePlotMock = vi.fn();
vi.mock("@mui/x-charts", () => ({
- ChartsContainer: ({ children }: any) => (
-
{children}
+ ChartsDataProvider: ({ children }: any) => (
+ {children}
),
+ ChartsSurface: ({ children }: any) => (
+ {children}
+ ),
+ ChartsTooltip: () => ,
+ ChartsAxisHighlight: () => ,
+
BarPlot: () => ,
LinePlot: (props: any) => {
linePlotMock(props);
@@ -76,7 +82,8 @@ describe("XYPlotComponent", () => {
it("renders chart when dataset exists", () => {
render();
- expect(screen.getByTestId("charts-container")).toBeInTheDocument();
+ expect(screen.getByTestId("charts-provider")).toBeInTheDocument();
+ expect(screen.getByTestId("charts-surface")).toBeInTheDocument();
expect(screen.getByTestId("bar-plot")).toBeInTheDocument();
expect(screen.getByTestId("line-plot")).toBeInTheDocument();
expect(screen.getByTestId("mark-plot")).toBeInTheDocument();
@@ -97,7 +104,7 @@ describe("XYPlotComponent", () => {
expect(utils.buildXAxes).toHaveBeenCalledWith(
baseProps.traces,
mockStyle,
- baseProps.pvData
+ baseProps.xAxis
);
expect(utils.buildSeries).toHaveBeenCalledWith(
baseProps.traces,
@@ -154,8 +161,8 @@ describe("XYPlotComponent", () => {
expect(typeof props.slotProps.line).toBe("function");
});
- it("hides line when traceType is 0", () => {
- const traces = [{ traceType: 0 }, { traceType: 1 }];
+ it("hides line when traceType is 0 or 3", () => {
+ const traces = [{ traceType: 0 }, { traceType: 1 }, { traceType: 3 }];
render();
@@ -164,5 +171,6 @@ describe("XYPlotComponent", () => {
expect(lineFn({ seriesId: "0" })).toEqual({ stroke: "transparent" });
expect(lineFn({ seriesId: "1" })).toEqual({});
+ expect(lineFn({ seriesId: "2" })).toEqual({ stroke: "transparent" });
});
});
diff --git a/src/ui/widgets/XYPlot/xyPlot.tsx b/src/ui/widgets/XYPlot/xyPlot.tsx
index 5bcd9e6..ef9e2ab 100644
--- a/src/ui/widgets/XYPlot/xyPlot.tsx
+++ b/src/ui/widgets/XYPlot/xyPlot.tsx
@@ -11,20 +11,24 @@ import {
AxesProp,
ArchivedDataPropOpt,
IntPropOpt,
- TracesPropOpt
+ TracesPropOpt,
+ AxisProp
} from "../propTypes";
import { registerWidget } from "../register";
import { Box, Typography } from "@mui/material";
import {
- ChartsContainer,
BarPlot,
ChartsLegend,
ChartsXAxis,
ChartsYAxis,
LinePlot,
- MarkPlot
+ MarkPlot,
+ ChartsTooltip,
+ ChartsAxisHighlight,
+ ChartsSurface,
+ ChartsDataProvider
} from "@mui/x-charts";
-import { Axes } from "../../../types/axis";
+import { Axes, Axis } from "../../../types/axis";
import { fontToCss, newFont } from "../../../types/font";
import { useStyle } from "../../hooks/useStyle";
import { DatasetElementType } from "@mui/x-charts/internals";
@@ -37,9 +41,12 @@ import {
const widgetName = "xyplot";
+const traceTypesWithoutLines = [0, 3];
+
const XYPlotProps = {
traces: TracesPropOpt,
axes: AxesProp,
+ xAxis: AxisProp,
start: StringPropOpt,
end: StringPropOpt,
foregroundColor: ColorPropOpt,
@@ -73,20 +80,22 @@ export const XYPlotComponent = (props: XYPlotComponentProps): JSX.Element => {
const {
traces,
axes,
+ xAxis,
pvData,
title,
titleFont = newFont(),
scaleFont = newFont(),
labelFont = newFont(),
- showLegend = false,
+ showLegend = true,
visible = true
} = props;
const { yAxes, yAxesStyle } = useMemo(() => buildYAxes(axes as Axes), [axes]);
- const { xAxis, hasXAxisData } = useMemo(
- () => buildXAxes(traces, style, pvData),
- [traces, style, pvData]
+ const { xAxis: xAxisMui, hasXAxisData } = useMemo(
+ () => buildXAxes(traces, style, xAxis as Axis),
+ [traces, style, xAxis]
);
+
const series = useMemo(
() => buildSeries(traces, pvData, visible),
[traces, pvData, visible]
@@ -96,13 +105,25 @@ export const XYPlotComponent = (props: XYPlotComponentProps): JSX.Element => {
() => buildPlotDataSet(pvData),
[pvData]
);
+
if (!hasXAxisData) {
plotDataSet = plotDataSet.map((point, i) => ({ ...point, x: i }));
}
+ if (!visible) {
+ return ;
+ }
+
// Use end value - this doesn't seem to do anything in Phoebus?
return (
-
+
{
>
{title}
-
- {plotDataSet?.length > 0 && (
-
-
- {
- const trace = traces?.[Number(seriesId)];
- // this hides the line if no line should be visible
- if (trace?.traceType === 0) {
- return {
- stroke: "transparent"
- };
- }
- return {};
- }
- }}
- />
-
-
-
-
- {showLegend && (
-
+ {plotDataSet?.length > 0 && (
+
+
- )}
-
- )}
+ >
+
+
+
+
+ {
+ const trace = traces?.[Number(seriesId)];
+ // this hides the line if no line should be visible
+ if (
+ trace?.traceType != null &&
+ traceTypesWithoutLines.includes(trace.traceType)
+ ) {
+ return {
+ stroke: "transparent"
+ };
+ }
+ return {};
+ }
+ }}
+ />
+
+ {xAxis?.visible !== false && }
+ {yAxes.map(axis =>
+ axis.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 094fa63..8145d42 100644
--- a/src/ui/widgets/XYPlot/xyPlot.utilities.test.ts
+++ b/src/ui/widgets/XYPlot/xyPlot.utilities.test.ts
@@ -6,8 +6,8 @@ import {
buildYAxes
} from "./xyPlot.utilities";
-import { getPvValueByPvName } from "../utils";
import { Trace } from "../../../types/trace";
+import { Axis } from "../../../types/axis";
vi.mock("../../../types/dtypes", () => ({
dTypeCoerceArray: vi.fn(val => val)
@@ -61,6 +61,25 @@ describe("buildPlotDataSet", () => {
it("returns empty array when no valid data", () => {
expect(buildPlotDataSet([])).toEqual([]);
});
+
+ it("coerces values to numbers", () => {
+ const pvData = [{ effectivePvName: "a", value: ["1", "2"] }] as any;
+
+ const result = buildPlotDataSet(pvData);
+
+ expect(result).toEqual([{ a: 1 }, { a: 2 }]);
+ });
+
+ it("ignores entries with missing effectivePvName", () => {
+ const pvData = [
+ { value: [1, 2] }, // invalid
+ { effectivePvName: "a", value: [3, 4] }
+ ] as any;
+
+ const result = buildPlotDataSet(pvData);
+
+ expect(result).toEqual([{ a: 3 }, { a: 4 }]);
+ });
});
describe("buildSeries", () => {
@@ -120,6 +139,35 @@ describe("buildSeries", () => {
const result = buildSeries(undefined, [], true);
expect(result).toBeDefined();
});
+
+ it("applies marker shape correctly", () => {
+ const trace = {
+ ...baseTrace,
+ pointType: 3 // diamond
+ };
+
+ const result = buildSeries([trace], pvData, true);
+
+ expect(result[0]).toMatchObject({
+ shape: "diamond"
+ });
+ });
+
+ it("uses default label when name is missing", () => {
+ const trace = { ...baseTrace, name: "" };
+
+ const result = buildSeries([trace], pvData, true);
+
+ expect(result[0].label).toBe("Series 1");
+ });
+
+ it("sets yAxisId correctly", () => {
+ const trace = { ...baseTrace, axis: 2 };
+
+ const result = buildSeries([trace], pvData, true);
+
+ expect(result[0].yAxisId).toBe("2");
+ });
});
describe("buildXAxes", () => {
@@ -130,17 +178,18 @@ describe("buildXAxes", () => {
});
it("builds axis from trace with pv min/max", () => {
- (getPvValueByPvName as any).mockReturnValue({
- value: {
- display: {
- controlRange: { min: 0, max: 100 }
- }
- }
- });
-
const traces = [{ xPv: "time", axis: 0, traceType: 1 }] as any;
-
- const result = buildXAxes(traces, style, []);
+ const xAxis = {
+ title: "X title",
+ color: { colorString: "red" },
+ autoscale: false,
+ onRight: false,
+ logScale: false,
+ minimum: 0,
+ maximum: 100
+ } as Axis;
+
+ const result = buildXAxes(traces, style, xAxis);
expect(result.xAxis[0]).toMatchObject({
dataKey: "time",
@@ -151,27 +200,26 @@ describe("buildXAxes", () => {
expect(result.hasXAxisData).toBe(true);
});
- it("deduplicates axes by axisId", () => {
- (getPvValueByPvName as any).mockReturnValue({ value: {} });
-
- const traces = [
- { xPv: "time", axis: 0, traceType: 1 },
- { xPv: "time2", axis: 0, traceType: 1 }
- ] as any;
+ it("defaults dataKey to x when none provided", () => {
+ const result = buildXAxes(
+ [],
+ { colors: { color: "black" } } as any,
+ {} as any
+ );
- const result = buildXAxes(traces, style, []);
-
- expect(result.xAxis.length).toBe(1);
+ expect(result.xAxis[0].dataKey).toBe("x");
+ expect(result.hasXAxisData).toBe(false);
});
- it("uses default axis when none exist", () => {
- const result = buildXAxes([], style, []);
+ it("uses band scale for bar charts", () => {
+ const traces = [{ traceType: 5 }] as any;
+ const mockStyle = {
+ colors: { color: "black" }
+ } as any;
- expect(result.hasXAxisData).toBe(false);
- expect(result.xAxis[0]).toMatchObject({
- dataKey: "x",
- scaleType: "band"
- });
+ const result = buildXAxes(traces, mockStyle, {} as any);
+
+ expect(result.xAxis[0].scaleType).toBe("band");
});
});
@@ -195,7 +243,9 @@ describe("buildYAxes", () => {
position: "left"
});
- expect(result.yAxesStyle[".MuiChartsAxis-id-0"]).toBeDefined();
+ expect(
+ result.yAxesStyle['& .MuiChartsAxis-root[data-axis-id="0"]']
+ ).toBeDefined();
});
it("applies min/max when valid", () => {
@@ -279,4 +329,40 @@ describe("buildYAxes", () => {
expect(result.yAxes.length).toBe(1);
expect(result.yAxes[0].label).toBe("Default");
});
+
+ it("propagates visible flag", () => {
+ const axes = [
+ {
+ title: "Y",
+ color: { colorString: "black" },
+ visible: false,
+ autoscale: true,
+ onRight: false,
+ logScale: false
+ }
+ ] as any;
+
+ const result = buildYAxes(axes);
+
+ expect(result.yAxes[0].visible).toBe(false);
+ });
+
+ it("applies font styles", () => {
+ const axes = [
+ {
+ title: "Y",
+ color: { colorString: "black" },
+ titleFont: {},
+ scaleFont: {},
+ autoscale: true,
+ onRight: false,
+ logScale: false
+ }
+ ] as any;
+
+ const result = buildYAxes(axes);
+
+ expect(result.yAxes[0].labelStyle).toBeDefined();
+ expect(result.yAxes[0].tickLabelStyle).toBeDefined();
+ });
});
diff --git a/src/ui/widgets/XYPlot/xyPlot.utilities.ts b/src/ui/widgets/XYPlot/xyPlot.utilities.ts
index 5125640..317fb32 100644
--- a/src/ui/widgets/XYPlot/xyPlot.utilities.ts
+++ b/src/ui/widgets/XYPlot/xyPlot.utilities.ts
@@ -11,8 +11,7 @@ import { dTypeCoerceArray } from "../../../types/dtypes";
import { CurveType } from "@mui/x-charts";
import { Trace } from "../../../types/trace";
import { UseStyleResult } from "../../hooks/useStyle";
-import { getPvValueByPvName } from "../utils";
-import { Axes, newAxis } from "../../../types/axis";
+import { Axes, Axis, newAxis } from "../../../types/axis";
import { fontToCss } from "../../../types/font";
const MARKER_STYLES: (MarkShape | undefined)[] = [
@@ -72,6 +71,7 @@ export const buildSeries = (
const base = {
id: `${index}`,
dataKey: effectiveYPvName,
+ yAxisId: String(trace?.axis),
label: trace.name || `Series ${index + 1}`,
color: visible ? trace.color.colorString : "transparent"
};
@@ -102,56 +102,37 @@ export const buildSeries = (
export const buildXAxes = (
traces: (Trace | null | undefined)[] | undefined,
style: UseStyleResult,
- pvData: PvDatum[]
+ xAxisDefinition: Axis
) => {
- const xAxisPvNamesAndIds = (
- traces != null && traces?.length > 0 ? traces : [new Trace()]
- )
- ?.filter(trace => trace != null && trace?.xPv != null)
- ?.map(trace => {
- const { value } = getPvValueByPvName(pvData, trace?.xPv as string);
- const controlRange = value?.display?.controlRange;
- const pvMin = Number.isNaN(Number(controlRange?.min))
- ? undefined
- : controlRange?.min;
- const pvMax = Number.isNaN(Number(controlRange?.max))
- ? undefined
- : controlRange?.max;
-
- return {
- pvName: trace?.xPv,
- axisId: trace?.axis,
- pvMin,
- pvMax,
- scaleType: trace?.traceType === 5 ? "band" : "linear"
- };
- })
- ?.reduce((acc, curr) => {
- if (!acc.find(item => item.axisId === curr.axisId)) {
- acc.push(curr);
- }
- return acc;
- }, [] as any[]);
-
- const hasXAxisData = xAxisPvNamesAndIds.length > 0;
- if (!hasXAxisData) {
- xAxisPvNamesAndIds.push({ pvName: "x", axisId: 0, scaleType: "band" });
- }
-
- const xAxis: ReadonlyArray> = xAxisPvNamesAndIds?.map(
- xAxisData => {
- return {
- color: style?.colors?.color,
- dataKey: xAxisData.pvName,
- id: `${xAxisData?.axisId}`,
- min: xAxisData?.pvMin,
- max: xAxisData?.pvMax,
- scaleType: xAxisData?.scaleType
- };
+ const isBarChart = traces?.some(trace => trace?.traceType === 5);
+
+ const dataKey = traces?.filter(
+ trace => trace != null && trace?.xPv != null
+ )?.[0]?.xPv;
+
+ const xAxis: ReadonlyArray> = [
+ {
+ color: style?.colors?.color,
+ dataKey: dataKey ?? "x",
+ id: "X0",
+ label: xAxisDefinition.title,
+ scaleType: !isBarChart
+ ? xAxisDefinition.logScale
+ ? "symlog"
+ : "linear"
+ : "band",
+ min:
+ !xAxisDefinition?.autoscale && Number.isFinite(xAxisDefinition?.minimum)
+ ? xAxisDefinition?.minimum
+ : undefined,
+ max:
+ !xAxisDefinition?.autoscale && Number.isFinite(xAxisDefinition?.maximum)
+ ? xAxisDefinition?.maximum
+ : undefined
}
- );
+ ];
- return { xAxis, hasXAxisData };
+ return { xAxis, hasXAxisData: !!dataKey };
};
export const buildYAxes = (
@@ -168,16 +149,16 @@ export const buildYAxes = (
const yAxesStyle: { [k: string]: { [k2: string]: { stroke: string } } } =
Object.fromEntries(
localAxes.map(({ color }, idx) => [
- `.MuiChartsAxis-id-${idx}`,
+ `& .MuiChartsAxis-root[data-axis-id="${idx}"]`,
{
- ".MuiChartsAxis-line": { stroke: color.colorString },
- ".MuiChartsAxis-tick": { stroke: color.colorString }
+ "& .MuiChartsAxis-line": { stroke: `${color.colorString}` },
+ "& .MuiChartsAxis-tick": { stroke: `${color.colorString}` }
}
])
);
const yAxes: ReadonlyArray> = localAxes.map(
- (item, idx): YAxis => {
+ (item, idy): YAxis => {
const titleFont = fontToCss(item.titleFont);
const scaleFont = fontToCss(item.scaleFont);
@@ -198,10 +179,12 @@ export const buildYAxes = (
min !== undefined && max !== undefined && min >= max ? undefined : max;
return {
+ visible: item?.visible,
width: 55,
- id: idx,
+ id: String(idy),
label: item.title,
color: item.color?.colorString,
+ lineColor: item.color?.colorString,
labelStyle: {
...titleFont,
fill: item.color.colorString