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