diff --git a/src/types/axis.test.ts b/src/types/axis.test.ts index 846c50ed..5506fc9d 100644 --- a/src/types/axis.test.ts +++ b/src/types/axis.test.ts @@ -36,8 +36,8 @@ describe("Axis", () => { autoscale: false, minimum: 0, maximum: 100, - scaleFont: new Font(), - titleFont: new Font(FontStyle.Bold), + scaleFont: new Font(12), + titleFont: new Font(14, FontStyle.Bold), onRight: false, xAxis: false }); diff --git a/src/types/axis.ts b/src/types/axis.ts index 40bb9ea9..27314e89 100644 --- a/src/types/axis.ts +++ b/src/types/axis.ts @@ -30,8 +30,8 @@ export class Axis { autoscale = false, minimum = 0, maximum = 100, - titleFont = new Font(FontStyle.Bold), - scaleFont = new Font(), + titleFont = new Font(14, FontStyle.Bold), + scaleFont = new Font(12, FontStyle.Regular), onRight = false, fromOpi = false } = {}) { diff --git a/src/types/font.ts b/src/types/font.ts index 465fe308..34b0f849 100644 --- a/src/types/font.ts +++ b/src/types/font.ts @@ -25,7 +25,7 @@ export class Font { ) { this.typeface = typeface ?? "Liberation sans"; this.style = style ?? FontStyle.Regular; - this.size = size; + this.size = size ?? 14; this.name = name; } diff --git a/src/types/plt.test.ts b/src/types/plt.test.ts new file mode 100644 index 00000000..07065915 --- /dev/null +++ b/src/types/plt.test.ts @@ -0,0 +1,72 @@ +import { Axis } from "./axis"; +import { Color } from "./color"; +import { Font } from "./font"; +import { Plt } from "./plt"; +import { Trace } from "./trace"; + +describe("Plt", () => { + it("constructs the plt with values", (): void => { + const testValues = { + title: "Testing", + axes: [new Axis(), new Axis({ color: Color.RED })], + pvlist: [new Trace({ yPv: "TEST" })], + background: Color.WHITE, + foreground: Color.RED, + scroll: false, + grid: true, + scrollStep: 5, + updatePeriod: 10, + bufferSize: 2000, + titleFont: new Font(14), + labelFont: new Font(8), + legendFont: new Font(8), + scaleFont: new Font(6), + start: "10 minutes", + end: "now" + }; + const plt = new Plt(testValues); + const actualValues = { + title: "Testing", + axes: [new Axis(), new Axis({ color: Color.RED })], + pvlist: [new Trace({ yPv: "TEST" })], + backgroundColor: Color.WHITE, + foregroundColor: Color.RED, + scroll: false, + showGrid: true, + scrollStep: 5, + updatePeriod: 10, + bufferSize: 2000, + titleFont: new Font(14), + labelFont: new Font(8), + legendFont: new Font(8), + scaleFont: new Font(6), + start: "10 minutes", + end: "now" + }; + expect(plt).toEqual(actualValues); + expect(plt).toBeInstanceOf(Plt); + }); + + it("construct the trace with only defaults", (): void => { + const plt = new Plt(); + expect(plt).toEqual({ + axes: [new Axis()], + pvlist: [new Trace()], + title: "", + backgroundColor: Color.WHITE, + foregroundColor: Color.BLACK, + scroll: true, + showGrid: false, + scrollStep: 5, + updatePeriod: 0, + bufferSize: 5000, + titleFont: new Font(), + labelFont: new Font(), + legendFont: new Font(), + scaleFont: new Font(), + start: "1 minute", + end: "now" + }); + expect(plt).toBeInstanceOf(Plt); + }); +}); diff --git a/src/types/plt.ts b/src/types/plt.ts new file mode 100644 index 00000000..be22b997 --- /dev/null +++ b/src/types/plt.ts @@ -0,0 +1,63 @@ +import { Axes, Axis } from "./axis"; +import { Color } from "./color"; +import { Font } from "./font"; +import { Trace } from "./trace"; + +export class Plt { + public title: string; + public axes: Axes; + public pvlist: Trace[]; + public backgroundColor: Color; + public foregroundColor: Color; + public scroll: boolean; + public scrollStep: number; + public updatePeriod: number; + public bufferSize: number; + public start: string; + public end: string; + public showGrid: boolean; + public titleFont: Font; + public scaleFont: Font; + public labelFont: Font; + public legendFont: Font; + + /** + * Set default values for properties not yet + * set, otherwise use set property. + */ + public constructor({ + title = "", + axes = [new Axis()], + pvlist = [new Trace()], + background = Color.WHITE, + foreground = Color.BLACK, + scroll = true, + grid = false, + scrollStep = 5, + updatePeriod = 0, + bufferSize = 5000, + titleFont = new Font(), + labelFont = new Font(), + legendFont = new Font(), + scaleFont = new Font(), + start = "1 minute", + end = "now" + } = {}) { + this.backgroundColor = background; + this.foregroundColor = foreground; + this.title = title; + this.scroll = scroll; + this.scrollStep = scrollStep; + this.axes = axes; + this.pvlist = pvlist; + this.updatePeriod = updatePeriod; + this.bufferSize = bufferSize; + this.showGrid = grid; + this.start = start; + this.end = end; + this.titleFont = titleFont; + this.scaleFont = scaleFont; + this.labelFont = labelFont; + this.legendFont = legendFont; + } +} diff --git a/src/types/props.ts b/src/types/props.ts index 2905ed67..08294ad2 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -5,9 +5,10 @@ import { WidgetActions } from "../ui/widgets/widgetActions"; import { Border } from "./border"; import { Position } from "./position"; import { PV } from "./pv"; -import { Trace } from "./trace"; +import { Archiver, Trace } from "./trace"; import { Axes, Axis } from "./axis"; import { Points } from "./points"; +import { Plt } from "./plt"; export type GenericProp = | string @@ -27,7 +28,9 @@ export type GenericProp = | Trace[] | Axes | Axis - | Points; + | Points + | Archiver + | Plt; export interface Expression { boolExp: string; diff --git a/src/types/trace.test.ts b/src/types/trace.test.ts index 305d06ce..8b39bef7 100644 --- a/src/types/trace.test.ts +++ b/src/types/trace.test.ts @@ -14,7 +14,11 @@ describe("Trace", () => { pointSize: 10, visible: false, xPv: "TEST-01:PV", - yPv: "TEST-02:PV" + yPv: "TEST-02:PV", + archive: { + name: "Test", + url: "Testing.diamond.ac.uk" + } }; const trace = new Trace(testValues); @@ -34,7 +38,11 @@ describe("Trace", () => { pointType: 0, pointSize: 1, visible: true, - yPv: "" + yPv: "", + archive: { + name: "", + url: "" + } }); expect(trace).toBeInstanceOf(Trace); }); diff --git a/src/types/trace.ts b/src/types/trace.ts index edd12513..f318070d 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -1,5 +1,10 @@ import { Color } from "./color"; +export interface Archiver { + name: string; + url: string; +} + export class Trace { public name: string; public axis: number; @@ -18,6 +23,7 @@ export class Trace { public concatenateData?: boolean; public updateDelay?: number; public updateMode?: number; + public archive?: Archiver | undefined; public constructor({ name = "", @@ -37,7 +43,8 @@ export class Trace { concatenateData = true, updateDelay = 100, updateMode = 0, - plotMode = 0 + plotMode = 0, + archive = { name: "", url: "" } } = {}) { // xPV property only exists on XYPlot if (xPv) this.xPv = xPv; @@ -51,6 +58,7 @@ export class Trace { this.pointType = pointType; this.pointSize = pointSize; this.visible = visible; + if (archive) this.archive = archive; if (fromOpi) { this.antiAlias = antiAlias; this.bufferSize = bufferSize; diff --git a/src/ui/hooks/useArchivedData.test.tsx b/src/ui/hooks/useArchivedData.test.tsx new file mode 100644 index 00000000..2123e3aa --- /dev/null +++ b/src/ui/hooks/useArchivedData.test.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { useArchivedData } from "./useArchivedData"; +import { Plt } from "../../types/plt"; +import { vi } from "vitest"; +import { Axis } from "../../types/axis"; +import { Trace } from "../../types/trace"; +import { render, act, screen } from "@testing-library/react"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Global {} + } +} + +interface GlobalFetch extends NodeJS.Global { + fetch: any; +} +const globalWithFetch = global as GlobalFetch; + +const mockSuccessResponse: any = JSON.stringify([ + { + secs: (new Date().getTime() - 250000) / 1000, + val: 52 + }, + { + secs: (new Date().getTime() - 200000) / 1000, + val: 45 + }, + { + secs: (new Date().getTime() - 70000) / 1000, + val: 60 + } +]); +const mockJsonPromise = Promise.resolve( + JSON.parse( + `[{"data": ${mockSuccessResponse}, "meta": { "name": "TEST:PV" }}]` + ) +); +const mockFetchPromise = Promise.resolve({ + json: (): Promise => mockJsonPromise +}); +const mockFetch = (): Promise => mockFetchPromise; +vi.spyOn(globalWithFetch, "fetch").mockImplementation(mockFetch); + +// Helper component to allow calling the useConnection hook. +const ArchivedDataTester = (props: { plt: Plt }): JSX.Element => { + const [data, dataLoaded] = useArchivedData(props.plt); + return ( +
+
loaded: {dataLoaded ? "true" : "false"}
+
data: {data.toString()}
+
+ ); +}; + +describe("useArchivedData", (): void => { + it("returns values if successful archiver call", async () => { + const plt = new Plt({ + pvlist: [ + new Trace({ + archive: { + name: "Primary", + url: "http://archiver.diamond.ac.uk/retrieval" + }, + yPv: "TEST:PV" + }) + ], + axes: [new Axis()] + }); + await act(async () => { + return render(); + }); + const returnData = [ + { dateTime: "2025-10-27T09:14:27.828Z", "ca://TEST:PV": 52 }, + { dateTime: "2025-10-27T09:15:17.828Z", "ca://TEST:PV": 45 }, + { dateTime: "2025-10-27T09:17:27.828Z", "ca://TEST:PV": 60 } + ]; + expect(screen.getByText("loaded: true")).toBeInTheDocument(); + expect( + screen.getByText(`data: ${returnData.toString()}`) + ).toBeInTheDocument(); + }); +}); diff --git a/src/ui/hooks/useArchivedData.tsx b/src/ui/hooks/useArchivedData.tsx new file mode 100644 index 00000000..06ac6dce --- /dev/null +++ b/src/ui/hooks/useArchivedData.tsx @@ -0,0 +1,76 @@ +import log from "loglevel"; +import { PV } from "../../types"; +import { TimeSeriesPoint } from "../widgets/StripChart/stripChart"; +import { convertStringTimePeriod, trimArchiveData } from "../widgets/utils"; +import { useState, useEffect } from "react"; +import { Plt } from "../../types/plt"; + +/** + * Fetch archived data for each PV from archivers available + */ +export function useArchivedData(plt: Plt): [TimeSeriesPoint[], boolean] { + const [data, setData] = useState([]); + const [archiveDataLoaded, setArchiveDataLoaded] = useState(false); + + useEffect(() => { + // Runs whenever pvlist updated and fetches archiver data + const fetchArchivedPVData = async () => { + // Fetch archiver data for period + const startTime = convertStringTimePeriod(plt.start); + const endTime = convertStringTimePeriod(plt.end); + const min = new Date(new Date().getTime() - startTime); + const max = new Date(new Date().getTime() - endTime); + const timeString = `&from=${min.toISOString()}&to=${max.toISOString()}`; + // Combine requests to same archver together to make single, multiple-PV request + const archivers: { [key: string]: string } = {}; + plt.pvlist.forEach(trace => { + //compile all pvs at same archiver + if (trace.archive?.url) { + if (!Object.keys(archivers).includes(trace.archive.url)) { + archivers[trace.archive.url] = + `${trace.archive.url}/data/getDataForPVs.json?pv=mean_${plt.updatePeriod}(${trace.yPv})`; + } else { + archivers[trace.archive.url] += + `&pv=mean_${plt.updatePeriod}(${trace.yPv})`; + } + } + }); + let fetchedData: TimeSeriesPoint[] = []; + // Request data for each archiver and translate into correct data format + for (const url of Object.values(archivers)) { + let tmpData: any[] = fetchedData; + try { + const resp = await fetch(`${url}${timeString}`); + const json = await resp.json(); + json.forEach((data: any) => { + // Trim each dataset down and push into fetchedData + const pvName = new PV(data.meta.name).qualifiedName(); + const trimmedData = trimArchiveData( + plt.updatePeriod, + plt.bufferSize, + data.data + ); + tmpData = trimmedData.map((item: any, idx: number) => { + return { + ...tmpData[idx], + dateTime: new Date(item.secs * 1000), + [pvName]: item.val + }; + }); + }); + fetchedData = tmpData; + } catch (e: any) { + log.error( + `Failed to fetch archiver data for PVs from address ${url}: ${e.error}.` + ); + } + } + setArchiveDataLoaded(true); + setData(fetchedData); + }; + // Only fetch once + if (!archiveDataLoaded) fetchArchivedPVData(); + }, [archiveDataLoaded, plt]); + + return [data, archiveDataLoaded]; +} diff --git a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx new file mode 100644 index 00000000..d4e1e653 --- /dev/null +++ b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import { act, render, screen } from "@testing-library/react"; +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { Color, DType } from "../../../types"; +import { DataBrowserComponent } from "./dataBrowser"; +import { Trace } from "../../../types/trace"; +import { Axis } from "../../../types/axis"; +import { Plt } from "../../../types/plt"; +import { PvDatum } from "../../../redux/csState"; +import { DTime } from "../../../types/dtypes"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Global {} + } +} + +interface GlobalFetch extends NodeJS.Global { + fetch: any; +} +const globalWithFetch = global as GlobalFetch; + +// Mock the MUI X-Charts components +vi.mock("@mui/x-charts", () => ({ + LineChart: vi.fn(({ series, xAxis, yAxis, dataset, sx }) => ( +
+ )) +})); + +vi.mock("@mui/material", () => ({ + Box: vi.fn(({ children }) =>
{children}
), + Typography: vi.fn(({ children }) => ( +
{children}
+ )) +})); + +describe("DataBrowserComponent", () => { + // Basic test setup + const buildPvDatum = ( + pvName: string, + value: number, + date: Date = new Date() + ) => { + return { + effectivePvName: pvName, + connected: true, + readonly: true, + value: { + getDoubleValue: () => value, + getTime: () => new DTime(date) + } as Partial as DType + } as Partial as PvDatum; + }; + + const defaultProps = { + pvData: [buildPvDatum("TEST:PV", 50)], + plt: new Plt({ + pvlist: [ + new Trace({ + archive: { + name: "Primary", + url: "http://archiver.diamond.ac.uk/retrieval" + }, + yPv: "TEST:PV" + }) + ], + axes: [new Axis()] + }) + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockSuccessResponse: any = JSON.stringify([ + { + secs: (new Date().getTime() - 250000) / 1000, + val: 52 + }, + { + secs: (new Date().getTime() - 200000) / 1000, + val: 45 + }, + { + secs: (new Date().getTime() - 70000) / 1000, + val: 60 + } + ]); + const mockJsonPromise = Promise.resolve( + JSON.parse( + `[{"data": ${mockSuccessResponse}, "meta": { "name": "TEST:PV" }}]` + ) + ); + const mockFetchPromise = Promise.resolve({ + json: (): Promise => mockJsonPromise + }); + const mockFetch = (): Promise => mockFetchPromise; + vi.spyOn(globalWithFetch, "fetch").mockImplementation(mockFetch); + + describe("Rendering", () => { + test("renders with default props", async () => { + await act(async () => { + render(); + }); + + const lineChart = screen.getByTestId("line-chart"); + expect(lineChart).toBeDefined(); + + const yAxisData = JSON.parse(lineChart.getAttribute("data-yaxis") ?? ""); + const xAxisData = JSON.parse(lineChart.getAttribute("data-xaxis") ?? ""); + expect(yAxisData[0].position).toBe("left"); + expect(xAxisData[0].scaleType).toBe("time"); + }); + + test("renders with 1 y axis on either side", async () => { + const axes = [ + new Axis({ color: Color.RED }), + new Axis({ color: Color.BLUE, onRight: true }) + ]; + const newProps = { + ...defaultProps, + plt: new Plt({ axes: axes }) + }; + await act(async () => { + render(); + }); + + const lineChart = screen.getByTestId("line-chart"); + const yAxisData = JSON.parse(lineChart.getAttribute("data-yaxis") ?? ""); + + expect(yAxisData[0].color).toBe(Color.RED.toString()); + expect(yAxisData[1].color).toBe(Color.BLUE.toString()); + expect(yAxisData[1].position).toBe("right"); + }); + + test("renders with 5 minute archived data", async () => { + const newProps = { + ...defaultProps, + plt: new Plt({ + start: "5 min", + end: "now", + pvlist: [ + new Trace({ + archive: { + name: "Primary", + url: "http://archiver.diamond.ac.uk/retrieval" + }, + yPv: "TEST:PV" + }) + ], + axes: [new Axis()] + }) + }; + + const { rerender } = await act(async () => { + return render(); + }); + const lineChart = screen.getByTestId("line-chart"); + expect(lineChart).toBeDefined(); + const xAxisData = JSON.parse(lineChart.getAttribute("data-xaxis") ?? ""); + + const expectedDiff = 300000; // 5 * 60 * 1000 + const actualDiff = + new Date(xAxisData[0].max).getTime() - + new Date(xAxisData[0].min).getTime(); + + expect(actualDiff).toBe(expectedDiff); + + // Send a new value to append to archived data + await act(async () => { + rerender( + + ); + }); + const newLineChart = screen.getByTestId("line-chart"); + expect(newLineChart).toBeDefined(); + const seriesData = JSON.parse( + newLineChart.getAttribute("data-series") ?? "" + ); + + const dataset = JSON.parse( + newLineChart.getAttribute("data-dataset") ?? "" + ); + + expect(dataset.length).toBe(4); + expect(dataset[0]["ca://TEST:PV"]).toBe(52); + expect(seriesData[0].dataKey).toBe("TEST:PV"); + }); + }); +}); diff --git a/src/ui/widgets/DataBrowser/dataBrowser.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx new file mode 100644 index 00000000..dc481572 --- /dev/null +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Widget } from "../widget"; +import { PVComponent, PVWidgetPropType } from "../widgetProps"; +import { + BoolPropOpt, + InferWidgetProps, + PltProp, + PvPropOpt +} from "../propTypes"; +import { registerWidget } from "../register"; +import { StripChartComponent } from "../StripChart/stripChart"; +import { useArchivedData } from "../../hooks/useArchivedData"; + +const DataBrowserProps = { + plt: PltProp, + selectionValuePv: PvPropOpt, + showToolbar: BoolPropOpt, + visible: BoolPropOpt +}; + +// Needs to be exported for testing +export type DataBrowserComponentProps = InferWidgetProps< + typeof DataBrowserProps +> & + PVComponent; + +export const DataBrowserComponent = ( + props: DataBrowserComponentProps +): JSX.Element => { + const { plt } = props; + const [data, dataLoaded] = useArchivedData(plt); + + return ( + + ); +}; + +const DataBrowserWidgetProps = { + ...DataBrowserProps, + ...PVWidgetPropType +}; + +export const DataBrowser = ( + props: InferWidgetProps +): JSX.Element => ; + +registerWidget(DataBrowser, DataBrowserWidgetProps, "databrowser"); diff --git a/src/ui/widgets/Device/device.tsx b/src/ui/widgets/Device/device.tsx index 1463b586..46f99141 100644 --- a/src/ui/widgets/Device/device.tsx +++ b/src/ui/widgets/Device/device.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { Widget, commonCss } from "./../widget"; import { WidgetPropType } from "./../widgetProps"; import { InferWidgetProps, StringPropOpt } from "./../propTypes"; @@ -28,37 +28,48 @@ export const DeviceComponent = ( // from the macro DESC on the screen. const displayMacros = useContext(MacroContext).macros; const deviceName = props.deviceName ?? (displayMacros["DESC"] || ""); - let componentDescription: WidgetDescription; - let border = new Border(BorderStyle.Dotted, Color.DISCONNECTED, 3); + const [component, setComponent] = useState(); + const [border, setBorder] = useState( + new Border(BorderStyle.Dotted, Color.DISCONNECTED, 3) + ); const replacedDeviceName = `dev://${deviceName.replace(/\s/g, "")}`; const description = useDevice(replacedDeviceName); - try { - let jsonResponse = {}; - if (description && description.value) { - jsonResponse = JSON.parse(description?.value?.stringValue || ""); - border = Border.NONE; - const jsonObject = parseResponse(jsonResponse as any); - componentDescription = parseObject(jsonObject, "ca"); - } else { - componentDescription = errorWidget( - `No device ${replacedDeviceName} found.`, - new RelativePosition("100%", "50px") + useEffect(() => { + const loadComponent = async () => { + let componentDescription: WidgetDescription; + try { + let jsonResponse = {}; + if (description && description.value) { + jsonResponse = JSON.parse(description?.value?.stringValue || ""); + setBorder(Border.NONE); + const jsonObject = parseResponse(jsonResponse as any); + + componentDescription = await parseObject(jsonObject, "ca"); + } else { + componentDescription = errorWidget( + `No device ${replacedDeviceName} found.`, + new RelativePosition("100%", "50px") + ); + } + } catch { + componentDescription = errorWidget( + `Failed to load device widget ${deviceName}` + ); + } + setComponent( + widgetDescriptionToComponent({ + position: new RelativePosition("100%", "100%"), + type: "display", + children: [componentDescription] + }) ); - } - } catch { - componentDescription = errorWidget( - `Failed to load device widget ${deviceName}` - ); - } - const Component = widgetDescriptionToComponent({ - position: new RelativePosition("100%", "100%"), - type: "display", - children: [componentDescription] - }); + }; + loadComponent(); + }, [description, deviceName, replacedDeviceName]); const style = commonCss({ border }); - return
{Component}
; + return
{component}
; }; const DeviceWidgetProps = { diff --git a/src/ui/widgets/EmbeddedDisplay/bobParser.test.ts b/src/ui/widgets/EmbeddedDisplay/bobParser.test.ts index b8c4b00a..1d0b581d 100644 --- a/src/ui/widgets/EmbeddedDisplay/bobParser.test.ts +++ b/src/ui/widgets/EmbeddedDisplay/bobParser.test.ts @@ -43,8 +43,8 @@ describe("bob widget parser", (): void => {
`; - it("parses a label widget", (): void => { - const widget = parseBob(labelString, "ca", PREFIX) + it("parses a label widget", async (): Promise => { + const widget = (await parseBob(labelString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.type).toEqual("label"); // Boolean type @@ -80,8 +80,8 @@ describe("bob widget parser", (): void => { false `; - it("parses a readback widget", (): void => { - const widget = parseBob(readbackString, "xxx", PREFIX) + it("parses a readback widget", async (): Promise => { + const widget = (await parseBob(readbackString, "xxx", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.pvMetadataList[0].pvName).toEqual(PV.parse("xxx://abc")); }); @@ -92,8 +92,8 @@ describe("bob widget parser", (): void => { 300 300 `; - it("handles a missing dimension", (): void => { - const display = parseBob(noXString, "xxx", "PREFIX"); + it("handles a missing dimension", async (): Promise => { + const display = await parseBob(noXString, "xxx", "PREFIX"); // Is this correct? expect(display.x).toEqual(undefined); }); @@ -113,8 +113,8 @@ describe("bob widget parser", (): void => { 50 `; - it("parses defaults", (): void => { - const widget = parseBob(readbackDefaults, "xxx", PREFIX) + it("parses defaults", async (): Promise => { + const widget = (await parseBob(readbackDefaults, "xxx", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.precisionFromPv).toEqual(true); expect(widget.showUnits).toEqual(true); @@ -140,8 +140,8 @@ describe("bob widget parser", (): void => { false `; - it("parses precision and units", (): void => { - const widget = parseBob(readbackPrecisionUnits, "xxx", PREFIX) + it("parses precision and units", async (): Promise => { + const widget = (await parseBob(readbackPrecisionUnits, "xxx", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.precisionFromPv).toEqual(undefined); expect(widget.precision).toEqual(2); @@ -165,8 +165,8 @@ describe("bob widget parser", (): void => { 6 `; - it("parses string format", (): void => { - const widget = parseBob(readbackStringFormat, "xxx", PREFIX) + it("parses string format", async (): Promise => { + const widget = (await parseBob(readbackStringFormat, "xxx", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.formatType).toEqual("string"); }); diff --git a/src/ui/widgets/EmbeddedDisplay/bobParser.ts b/src/ui/widgets/EmbeddedDisplay/bobParser.ts index 9c5b98e5..7c1c9a29 100644 --- a/src/ui/widgets/EmbeddedDisplay/bobParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/bobParser.ts @@ -1,5 +1,11 @@ import { REGISTERED_WIDGETS } from "../register"; -import { ComplexParserDict, parseWidget, ParserDict, toArray } from "./parser"; +import { + ComplexParserDict, + parseWidget, + ParserDict, + toArray, + parseChildProps +} from "./parser"; import { XmlDescription, OPI_COMPLEX_PARSERS, @@ -10,8 +16,7 @@ import { opiParseColor, opiParseString, opiParseMacros, - opiParseBoolean, - opiParseFont + opiParseBoolean } from "./opiParser"; import { xml2js, ElementCompact } from "xml-js"; import log from "loglevel"; @@ -36,7 +41,7 @@ import { WidgetDescription } from "../createComponent"; import { Point, Points } from "../../../types/points"; import { Axis } from "../../../types/axis"; import { Trace } from "../../../types/trace"; -import { snakeCaseToCamelCase } from "../utils"; +import { parsePlt } from "./pltParser"; const BOB_WIDGET_MAPPING: { [key: string]: any } = { action_button: "actionbutton", @@ -45,6 +50,7 @@ const BOB_WIDGET_MAPPING: { [key: string]: any } = { byte_monitor: "bytemonitor", checkbox: "checkbox", combo: "menubutton", + databrowser: "databrowser", display: "display", ellipse: "ellipse", embedded: "embeddedDisplay", @@ -77,6 +83,7 @@ export const WIDGET_DEFAULT_SIZES: { [key: string]: [number, number] } = { checkbox: [100, 20], choice: [100, 43], combo: [100, 30], + databrowser: [400, 300], display: [800, 800], ellipse: [100, 50], embedded: [400, 300], @@ -259,11 +266,11 @@ function bobParseTraces(props: any): Trace[] { // of an array if (props.trace.length > 1) { props.trace.forEach((trace: any) => { - parsedProps = bobParseChildProps(trace); + parsedProps = parseChildProps(trace, BOB_SIMPLE_PARSERS); traces.push(new Trace(parsedProps)); }); } else { - parsedProps = bobParseChildProps(props.trace); + parsedProps = parseChildProps(props.trace, BOB_SIMPLE_PARSERS); traces.push(new Trace(parsedProps)); } } @@ -283,11 +290,11 @@ function bobParseYAxes(props: any): Axis[] { // of an array if (props.y_axis.length > 1) { props.y_axis.forEach((axis: any) => { - parsedProps = bobParseChildProps(axis); + parsedProps = parseChildProps(axis, BOB_SIMPLE_PARSERS); axes.push(new Axis(parsedProps)); }); } else { - parsedProps = bobParseChildProps(props.y_axis); + parsedProps = parseChildProps(props.y_axis, BOB_SIMPLE_PARSERS); axes.push(new Axis(parsedProps)); } } @@ -300,24 +307,10 @@ function bobParseYAxes(props: any): Axis[] { * @returns an Axis object. */ function bobParseXAxis(props: any): Axis { - const parsedProps = bobParseChildProps(props.x_axis); + const parsedProps = parseChildProps(props.x_axis, BOB_SIMPLE_PARSERS); return new Axis({ xAxis: true, ...parsedProps }); } -function bobParseChildProps(props: any): any { - const obj: { [key: string]: any } = {}; // Object to assign props to - Object.entries(props).forEach((entry: any) => { - const [key, value] = entry; - // For each prop, convert the name and parse - const newName = snakeCaseToCamelCase(key); - if (newName && BOB_SIMPLE_PARSERS.hasOwnProperty(newName)) { - const [, propParser] = BOB_SIMPLE_PARSERS[newName]; - obj[newName] = propParser(value); - } - }); - return obj; -} - /** * Creates a WidgetActions object from the actions tied to the json object * @param jsonProp @@ -466,8 +459,8 @@ const BOB_SIMPLE_PARSERS: ParserDict = { color: ["color", opiParseColor], traceType: ["trace_type", bobParseNumber], onRight: ["on_right", opiParseBoolean], - titleFont: ["title_font", opiParseFont], - scaleFont: ["scale_font", opiParseFont], + titleFont: ["title_font", bobParseFont], + scaleFont: ["scale_font", bobParseFont], start: ["start", opiParseString], end: ["end", opiParseString] }; @@ -482,11 +475,11 @@ const BOB_COMPLEX_PARSERS: ComplexParserDict = { xAxis: bobParseXAxis }; -export function parseBob( +export async function parseBob( xmlString: string, defaultProtocol: string, filepath: string -): WidgetDescription { +): Promise { // Convert it to a "compact format" const compactJSON = xml2js(xmlString, { compact: true @@ -514,10 +507,12 @@ export function parseBob( rules: (rules: Rule[]): Rule[] => opiParseRules(rules, defaultProtocol, false), traces: (props: ElementCompact) => bobParseTraces(props["traces"]), - axes: (props: ElementCompact) => bobParseYAxes(props["y_axes"]) + axes: (props: ElementCompact) => bobParseYAxes(props["y_axes"]), + plt: async (props: ElementCompact) => + await parsePlt(props["file"], props._attributes?.type) }; - const displayWidget = parseWidget( + const displayWidget = await parseWidget( compactJSON.display, bobGetTargetWidget, "widget", diff --git a/src/ui/widgets/EmbeddedDisplay/jsonParser.test.ts b/src/ui/widgets/EmbeddedDisplay/jsonParser.test.ts index 7e5fc823..e0f9d95b 100644 --- a/src/ui/widgets/EmbeddedDisplay/jsonParser.test.ts +++ b/src/ui/widgets/EmbeddedDisplay/jsonParser.test.ts @@ -29,9 +29,9 @@ describe("json widget parser", (): void => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const label = Label; - it("parses a display widget", (): void => { - const widget = parseJson(displayString, "ca", PREFIX); - expect(widget.type).toEqual("display"); + it("parses a display widget", async (): Promise => { + const widget = await parseJson(displayString, "ca", PREFIX); + expect((await widget).type).toEqual("display"); // Position type expect(widget.position).toEqual(new RelativePosition()); // Font type not present on Display widget. @@ -55,8 +55,8 @@ describe("json widget parser", (): void => { } ] }`; - it("handles font and position on a label widget", (): void => { - const widget = parseJson(fontLabelString, "ca", PREFIX) + it("handles font and position on a label widget", async (): Promise => { + const widget = (await parseJson(fontLabelString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.font).toEqual(new Font(13, FontStyle.Bold)); expect(widget.position).toEqual( @@ -89,8 +89,8 @@ describe("json widget parser", (): void => { } ] }`; - it("handles a rule on a display widget", (): void => { - const widget = parseJson(ruleString, "ca", PREFIX); + it("handles a rule on a display widget", async (): Promise => { + const widget = await parseJson(ruleString, "ca", PREFIX); const rule = { name: "border rule", prop: "border", diff --git a/src/ui/widgets/EmbeddedDisplay/jsonParser.ts b/src/ui/widgets/EmbeddedDisplay/jsonParser.ts index 18252a0d..24493715 100644 --- a/src/ui/widgets/EmbeddedDisplay/jsonParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/jsonParser.ts @@ -139,11 +139,11 @@ function jsonGetTargetWidget(props: any): React.FC { * @param jsonString objects in the correct format. * @param defaultProtocol default protocol to use for PVs. */ -export function parseObject( +export async function parseObject( object: any, // eslint-disable-line @typescript-eslint/explicit-module-boundary-types defaultProtocol: string, path?: string -): WidgetDescription { +): Promise { const simpleParsers: ParserDict = { ...SIMPLE_PARSERS, pvName: [ @@ -155,7 +155,7 @@ export function parseObject( (rules: Rule[]): Rule[] => jsonParseRules(rules, defaultProtocol) ] }; - return parseWidget( + return await parseWidget( object, jsonGetTargetWidget, "children", @@ -172,10 +172,10 @@ export function parseObject( * @param jsonString JSON string in the correct format. * @param defaultProtocol default protocol to use for PVs. */ -export function parseJson( +export async function parseJson( jsonString: string, defaultProtocol: string, path: string -): WidgetDescription { - return parseObject(JSON.parse(jsonString), defaultProtocol, path); +): Promise { + return await parseObject(JSON.parse(jsonString), defaultProtocol, path); } diff --git a/src/ui/widgets/EmbeddedDisplay/opiParser.test.ts b/src/ui/widgets/EmbeddedDisplay/opiParser.test.ts index c94f0f54..ec2a9c85 100644 --- a/src/ui/widgets/EmbeddedDisplay/opiParser.test.ts +++ b/src/ui/widgets/EmbeddedDisplay/opiParser.test.ts @@ -18,8 +18,8 @@ describe("opi widget parser", (): void => { 30 40 `; - it("parses a display widget", (): void => { - const displayWidget = parseOpi(displayString, "ca", PREFIX); + it("parses a display widget", async (): Promise => { + const displayWidget = await parseOpi(displayString, "ca", PREFIX); expect(displayWidget.position).toEqual( new RelativePosition("0px", "0px", "30px", "40px") ); @@ -72,8 +72,8 @@ describe("opi widget parser", (): void => { `; - it("parses a label widget", (): void => { - const widget = parseOpi(labelString, "ca", PREFIX) + it("parses a label widget", async (): Promise => { + const widget = (await parseOpi(labelString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.type).toEqual("label"); // Boolean type @@ -121,8 +121,8 @@ describe("opi widget parser", (): void => { `; - it("parses a widget with a rule", (): void => { - const widget = parseOpi(ruleString, "ca", PREFIX) + it("parses a widget with a rule", async (): Promise => { + const widget = (await parseOpi(ruleString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.rules.length).toEqual(1); const rule: Rule = widget.rules[0]; @@ -156,8 +156,8 @@ describe("opi widget parser", (): void => { `; - it("parses a widget with a child widget", (): void => { - const widget = parseOpi(childString, "ca", PREFIX) + it("parses a widget with a child widget", async (): Promise => { + const widget = (await parseOpi(childString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.children?.length).toEqual(1); }); @@ -184,8 +184,8 @@ describe("opi widget parser", (): void => { `; - it("parses a widget with an action", (): void => { - const widget = parseOpi(actionString, "ca", PREFIX) + it("parses a widget with an action", async (): Promise => { + const widget = (await parseOpi(actionString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.actions.actions.length).toEqual(1); const action = widget.actions.actions[0]; @@ -237,8 +237,8 @@ describe("opi widget parser", (): void => { `; - it("parses an input widget", (): void => { - const widget = parseOpi(inputString, "ca", PREFIX) + it("parses an input widget", async (): Promise => { + const widget = (await parseOpi(inputString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.textAlign).toEqual("right"); // Adds ca:// prefix. @@ -255,10 +255,10 @@ describe("opi widget parser", (): void => { `; - it("doesn't parse an invalid string", (): void => { + it("doesn't parse an invalid string", async (): Promise => { // Reduce logging when expecting error. log.setLevel("error"); - const widget = parseOpi(invalidString, "ca", PREFIX) + const widget = (await parseOpi(invalidString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.text).toBeUndefined(); log.setLevel("info"); @@ -274,10 +274,10 @@ describe("opi widget parser", (): void => { not-a-bool `; - it("doesn't parse an invalid bool", (): void => { + it("doesn't parse an invalid bool", async (): Promise => { // Reduce logging when expecting error. log.setLevel("error"); - const widget = parseOpi(invalidBool, "ca", PREFIX) + const widget = (await parseOpi(invalidBool, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.enabled).toBeUndefined(); log.setLevel("info"); @@ -401,8 +401,8 @@ $(trace_0_y_pv_value) 168 `; - it("parses xygraph widget", (): void => { - const widget = parseOpi(xygraphString, "ca", PREFIX) + it("parses xygraph widget", async (): Promise => { + const widget = (await parseOpi(xygraphString, "ca", PREFIX)) .children?.[0] as WidgetDescription; expect(widget.traces.length).toEqual(1); expect(widget.axes.length).toEqual(3); diff --git a/src/ui/widgets/EmbeddedDisplay/opiParser.ts b/src/ui/widgets/EmbeddedDisplay/opiParser.ts index 2ee6bc5d..88bf212f 100644 --- a/src/ui/widgets/EmbeddedDisplay/opiParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/opiParser.ts @@ -311,7 +311,7 @@ export const opiParseRules = ( * Creates a Number object from a json properties object * @param jsonProp */ -function opiParseNumber(jsonProp: ElementCompact): number { +export function opiParseNumber(jsonProp: ElementCompact): number { return Number(jsonProp._text); } @@ -857,11 +857,11 @@ export const OPI_PATCHERS: PatchFunction[] = [ opiPatchActions ]; -export function parseOpi( +export async function parseOpi( xmlString: string, defaultProtocol: string, filepath: string -): WidgetDescription { +): Promise { // Convert it to a "compact format" const compactJSON = xml2js(xmlString, { compact: true @@ -893,7 +893,7 @@ export function parseOpi( log.debug(compactJSON.display); - const displayWidget = parseWidget( + const displayWidget = await parseWidget( compactJSON.display, opiGetTargetWidget, "widget", diff --git a/src/ui/widgets/EmbeddedDisplay/parser.ts b/src/ui/widgets/EmbeddedDisplay/parser.ts index 55373b1a..ff938f30 100644 --- a/src/ui/widgets/EmbeddedDisplay/parser.ts +++ b/src/ui/widgets/EmbeddedDisplay/parser.ts @@ -17,6 +17,7 @@ import { WidgetDescription } from "../createComponent"; import { StringProp, PositionProp } from "../propTypes"; import { ElementCompact } from "xml-js"; import { PV } from "../../../types"; +import { snakeCaseToCamelCase } from "../utils"; // Specific widgets we should allow empty string parsing for const PARSE_EMPTY_STRINGS = ["text", "label", "on_label", "off_label", "title"]; @@ -42,13 +43,13 @@ export type ParserDict = { }; export type ComplexParserDict = { - [key: string]: (value: any) => GenericProp; + [key: string]: (value: any) => GenericProp | Promise; }; export type PatchFunction = (props: WidgetDescription, path?: string) => void; /* Take an object representing a widget and return our widget description. */ -export function genericParser( +export async function genericParser( widget: any, // eslint-disable-line @typescript-eslint/explicit-module-boundary-types targetWidget: React.FC, simpleParsers: ParserDict, @@ -56,7 +57,7 @@ export function genericParser( // Whether props with no registered function should be passed through // with no parsing. passThrough: boolean -): WidgetDescription { +): Promise { const newProps: any = { type: targetWidget }; const allProps = { type: StringProp, @@ -74,7 +75,7 @@ export function genericParser( try { if (widget.hasOwnProperty(opiPropName)) { if (!isEmpty(widget[opiPropName])) { - newProps[prop] = propParser(widget[opiPropName]); + newProps[prop] = await propParser(widget[opiPropName]); log.debug(`result ${newProps[prop]}`); // For certain simple string props we want to accept an empty value e.g. text } else if ( @@ -95,7 +96,7 @@ export function genericParser( log.debug(`complex parser for ${prop}`); const propParser = complexParsers[prop]; try { - newProps[prop] = propParser(widget); + newProps[prop] = await propParser(widget); log.debug(`result ${newProps[prop]}`); } catch (e) { log.warn(`Could not convert complex prop ${prop}:`); @@ -105,19 +106,43 @@ export function genericParser( newProps[prop] = widget[prop]; } } - // TO DO - temporary method of using the top level trace PV for - // plot widgets as the PV. This is a placeholder until support for - // multiple PVs per widget is implemented + + // Parse PV names out of traces for plots into pv property if (newProps.hasOwnProperty("traces")) { newProps.pvMetadataList = newProps.traces?.map((trace: any) => ({ pvName: PV.parse(trace.yPv) })); + } else if (newProps.hasOwnProperty("plt")) { + newProps.pvMetadataList = newProps.plt.pvlist.map((trace: any) => ({ + pvName: PV.parse(trace.yPv) + })); } return newProps; } -export function parseWidget( +export function parseChildProps( + props: ElementCompact, + parser: ParserDict +): any { + const obj: { [key: string]: any } = {}; // Object to assign props to + Object.entries(props).forEach((entry: any) => { + const [key, value] = entry; + // For each prop, convert the name and parse + const newName = snakeCaseToCamelCase(key); + if (newName && parser.hasOwnProperty(newName)) { + if (isEmpty(props[key]) && PARSE_EMPTY_STRINGS.includes(key)) { + obj[newName] = ""; + } else { + const [, propParser] = parser[newName]; + obj[newName] = propParser(value); + } + } + }); + return obj; +} + +export async function parseWidget( props: any, // eslint-disable-line @typescript-eslint/explicit-module-boundary-types getTargetWidget: (props: any) => React.FC, childrenName: string, @@ -126,9 +151,9 @@ export function parseWidget( passThrough: boolean, patchFunctions: PatchFunction[], filepath?: string -): WidgetDescription { +): Promise { const targetWidget = getTargetWidget(props); - const widgetDescription = genericParser( + const widgetDescription = await genericParser( props, targetWidget, simpleParsers, @@ -141,18 +166,20 @@ export function parseWidget( } /* Child widgets */ const childWidgets = toArray(props[childrenName]); - widgetDescription.children = childWidgets.map((child: any) => { - return parseWidget( - child, - getTargetWidget, - childrenName, - simpleParsers, - complexParsers, - passThrough, - patchFunctions, - filepath - ); - }); + widgetDescription.children = await Promise.all( + childWidgets.map(async (child: any) => { + return await parseWidget( + child, + getTargetWidget, + childrenName, + simpleParsers, + complexParsers, + passThrough, + patchFunctions, + filepath + ); + }) + ); // Default to true if precision is not defined. // Applicable to BOB files. diff --git a/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts b/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts new file mode 100644 index 00000000..ab2476d3 --- /dev/null +++ b/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts @@ -0,0 +1,109 @@ +import { vi } from "vitest"; +import { Color } from "../../../types"; +import { parsePlt } from "./pltParser"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Global {} + } +} + +interface GlobalFetch extends NodeJS.Global { + fetch: any; +} +const globalWithFetch = global as GlobalFetch; + +describe("plt parser", (): void => { + const displayString = ` + + + true + 3.0 + 5 + false + 2025-09-29 09:45:36.000 + 2025-10-14 09:45:57.000 + STAGGER + + 51 + 77 + 179 + + + 204 + 204 + 204 + + Liberation Sans|20|1 + Liberation Sans|14|1 + Liberation Sans|12|0 + Liberation Sans|14|0 + + + true + Value 2 + false + true + false + + 0 + 0 + 0 + + -31.0 + 330.0 + false + false + false + + + + + + + TEST:PV + true + TEST:PV + 0 + + 0 + 255 + 0 + + AREA + 2 + SOLID + NONE + 2 + 0 + 0.0 + 5000 + OPTIMIZED + + Primary Test + pbraw://test.diamond.ac.uk/retrieval + 1 + + + +`; + it("parses a plt file", async (): Promise => { + const mockSuccessResponse: any = displayString; + const mockPromise = Promise.resolve(mockSuccessResponse); + const mockFetchPromise = Promise.resolve({ + text: (): Promise => mockPromise + }); + const mockFetch = (): Promise => mockFetchPromise; + vi.spyOn(globalWithFetch, "fetch").mockImplementation(mockFetch); + const plt = await parsePlt({ _text: "fakefile.plt" }, "databrowser"); + expect(plt.backgroundColor).toEqual(Color.fromRgba(204, 204, 204)); + // Check custom props parsed correctly + expect(plt.axes.length).toEqual(1); + expect(plt.pvlist[0].archive).toEqual({ + name: "Primary Test", + url: "http://test.diamond.ac.uk/retrieval" + }); + }); +}); diff --git a/src/ui/widgets/EmbeddedDisplay/pltParser.ts b/src/ui/widgets/EmbeddedDisplay/pltParser.ts new file mode 100644 index 00000000..75b7a626 --- /dev/null +++ b/src/ui/widgets/EmbeddedDisplay/pltParser.ts @@ -0,0 +1,222 @@ +import { xml2js, ElementCompact } from "xml-js"; +import { Color, Font, FontStyle } from "../../../types"; +import { + XmlDescription, + opiParseBoolean, + opiParseString, + opiParseNumber +} from "./opiParser"; +import { parseChildProps, ParserDict } from "./parser"; +import { Axis } from "../../../types/axis"; +import { Archiver, Trace } from "../../../types/trace"; +import { Plt } from "../../../types/plt"; + +const PLT_PARSERS: ParserDict = { + start: ["start", opiParseString], + end: ["end", opiParseString], + grid: ["grid", opiParseBoolean], + scroll: ["scroll", opiParseBoolean], + scrollStep: ["scroll_step", opiParseNumber], + updatePeriod: ["update_period", opiParseNumber], + bufferSize: ["buffer_size", opiParseNumber], + background: ["background", pltParseColor], + foreground: ["foreground", pltParseColor], + color: ["color", pltParseColor], + traceType: ["trace_type", opiParseString], + useAxisName: ["use_axis_name", opiParseBoolean], + useTraceNames: ["use_trace_names", opiParseBoolean], + right: ["right", opiParseBoolean], + displayName: ["display_name", opiParseString], + waveformIndex: ["waveform_index", opiParseNumber], + period: ["period", opiParseNumber], + linewidth: ["linewidth", opiParseNumber], + pointType: ["point_type", pltParsePointType], + name: ["name", opiParseString], + ringSize: ["ring_size", opiParseNumber], + request: ["request", opiParseString], + archive: ["archive", pltParseArchiver], + titleFont: ["title_font", pltParseFont], + scaleFont: ["scale_font", pltParseFont], + labelFont: ["label_font", pltParseFont], + legendFont: ["legend_font", pltParseFont], + min: ["min", opiParseNumber], + max: ["max", opiParseNumber], + axis: ["axis", opiParseNumber] +}; + +/** + * Parses font from plt + * @param jsonProp + * @returns Font object + */ +function pltParseFont(jsonProp: ElementCompact) { + const fontStyles: { [key: string]: FontStyle } = { + 0: FontStyle.Regular, + 1: FontStyle.Bold, + 2: FontStyle.Italic, + 3: FontStyle.BoldItalic + }; + const fontElements = (jsonProp._text as string).split("|"); + const style = Number(fontElements.pop()); + const size = Number(fontElements.pop()); + const typeface = fontElements.pop(); + const font = new Font(size, fontStyles[style], typeface); + return font; +} + +/** + * Converts point type as string to number matching + * bob and opi format + * @param jsonProp + * @returns number + */ +function pltParsePointType(jsonProp: ElementCompact) { + const point = jsonProp._text || "NONE"; + const pointTypes: any = { + NONE: 0, + SQUARES: 1, + CIRCLES: 2, + DIAMONDS: 3, + XMARKS: 4, + TRIANGLES: 5 + }; + return pointTypes[point]; +} + +/** + * Parses Archiver Appliance reference, ensuring the + * given URL starts with HTTP:// for requests + * @param jsonProp + * @returns Archiver object + */ +function pltParseArchiver(jsonProp: ElementCompact) { + // Ensure archiver is using HTTP address + const url = opiParseString(jsonProp.url); + const archiverURL = `http://${url.split("://")[1]}`; + const archive: Archiver = { + name: opiParseString(jsonProp.name), + url: archiverURL + }; + return archive; +} + +/** + * Parses list of traces (pvs) + * @param props + * @returns list of Trace objects + */ +function pltParsePvlist(props: ElementCompact) { + const traces: Trace[] = []; + let parsedProps: any = {}; + // Create object to store PV names and their associated axes + const pvAxes: any = {}; + if (props) { + // If only one trace, we are passed an object instead + // of an array + const propsPvList = props.pv.length > 1 ? props.pv : [props.pv]; + propsPvList.forEach((trace: any) => { + parsedProps = parseChildProps(trace, PLT_PARSERS); + // If another trace on same axis, add to list + pvAxes.hasOwnProperty(parsedProps.axis) + ? pvAxes[parsedProps.axis].push(parsedProps.name) + : (pvAxes[parsedProps.axis] = [parsedProps.name]); + traces.push( + new Trace({ + ...parsedProps, + yPv: parsedProps.name, + lineWidth: parsedProps.linewidth + }) + ); + }); + } + return [traces, pvAxes]; +} + +/** + * Parses axes from plt, using the PV name associated with that axis as a title if needed + * @param props + * @returns list of Axis objects + */ +function pltParseAxes(props: ElementCompact, pvAxes: any) { + const axes: Axis[] = []; + let parsedProps: any = {}; + if (props) { + // If only once axis, we are passed an object instead + // of an array + const propAxes = props.axis.length > 1 ? props.axis : [props.axis]; + propAxes.forEach((axis: any, idx: number) => { + parsedProps = parseChildProps(axis, PLT_PARSERS); + axes.push( + new Axis({ + ...parsedProps, + fromOpi: false, + showGrid: parsedProps.grid, + onRight: parsedProps.right, + title: parsedProps.useAxisName + ? parsedProps.name + : pvAxes[idx].join("\n"), + titleFont: parsedProps.labelFont, + minimum: parsedProps.min, + maximum: parsedProps.max + }) + ); + }); + } + return axes; +} + +/** + * Parses Color + * @param jsonProp + * @returns Color object + */ +function pltParseColor(jsonProp: ElementCompact) { + return Color.fromRgba( + parseInt(jsonProp.red._text), + parseInt(jsonProp.green._text), + parseInt(jsonProp.blue._text), + 1 + ); +} + +/** + * Parsees Plt file for Databrowser, returning a list of all + * props + * @param xmlString + * @returns + */ +export async function parsePlt( + file: ElementCompact, + widgetType?: string | number +): Promise { + // TO DO - check file ext is plt + let props = new Plt(); + if (widgetType === "databrowser" && typeof file._text === "string") { + const databrowser: XmlDescription = await fetchPltFile(file._text); + // Parse the simple props + const [pvlist, pvAxes] = pltParsePvlist(databrowser["pvlist"]); + const axes = pltParseAxes(databrowser["axes"], pvAxes); + props = new Plt({ + ...parseChildProps(databrowser, PLT_PARSERS), + pvlist: pvlist, + axes: axes + }); + } + return props; +} + +/** + * Async fetch of Plt file, converted into JSON + * @param file + * @returns JSON object + */ +async function fetchPltFile(file: string) { + const filePromise = await fetch(file); + const contents = await filePromise.text(); + // Convert it to a "compact format" + const compactJSON = xml2js(contents, { + compact: true + }) as XmlDescription; + const databrowser = compactJSON.databrowser; + return databrowser; +} diff --git a/src/ui/widgets/EmbeddedDisplay/useOpiFile.ts b/src/ui/widgets/EmbeddedDisplay/useOpiFile.ts index 132b6dde..b64b6041 100644 --- a/src/ui/widgets/EmbeddedDisplay/useOpiFile.ts +++ b/src/ui/widgets/EmbeddedDisplay/useOpiFile.ts @@ -46,13 +46,13 @@ async function fetchAndConvert( // Convert the contents to widget description style object switch (fileExt) { case "bob": - description = parseBob(contents, protocol, parentDir); + description = await parseBob(contents, protocol, parentDir); break; case "json": - description = parseJson(contents, protocol, parentDir); + description = await parseJson(contents, protocol, parentDir); break; case "opi": - description = parseOpi(contents, protocol, parentDir); + description = await parseOpi(contents, protocol, parentDir); break; } } diff --git a/src/ui/widgets/StripChart/stripChart.test.tsx b/src/ui/widgets/StripChart/stripChart.test.tsx index 2947fc69..20634fb7 100644 --- a/src/ui/widgets/StripChart/stripChart.test.tsx +++ b/src/ui/widgets/StripChart/stripChart.test.tsx @@ -110,10 +110,12 @@ describe("StripChartComponent", () => { test("renders with 5 minute x axis period", () => { const expectedDiff = 300000; // 5 * 60 * 1000 - render(); + const { rerender } = render( + + ); + rerender(); const lineChart = screen.getByTestId("line-chart"); const xAxisData = JSON.parse(lineChart.getAttribute("data-xaxis") ?? ""); - const actualDiff = new Date(xAxisData[0].max).getTime() - new Date(xAxisData[0].min).getTime(); diff --git a/src/ui/widgets/StripChart/stripChart.tsx b/src/ui/widgets/StripChart/stripChart.tsx index bc0f7562..dcceacbf 100644 --- a/src/ui/widgets/StripChart/stripChart.tsx +++ b/src/ui/widgets/StripChart/stripChart.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Widget } from "../widget"; import { PVComponent, PVWidgetPropType } from "../widgetProps"; @@ -9,7 +9,9 @@ import { ColorPropOpt, StringPropOpt, TracesProp, - AxesProp + AxesProp, + ArchivedDataPropOpt, + IntPropOpt } from "../propTypes"; import { registerWidget } from "../register"; import { Box, Typography } from "@mui/material"; @@ -42,7 +44,11 @@ const StripChartProps = { scaleFont: FontPropOpt, showLegend: BoolPropOpt, showToolbar: BoolPropOpt, - visible: BoolPropOpt + visible: BoolPropOpt, + archivedData: ArchivedDataPropOpt, + archivedDataLoaded: BoolPropOpt, + bufferSize: IntPropOpt, + updatePeriod: IntPropOpt }; // Needs to be exported for testing @@ -51,7 +57,7 @@ export type StripChartComponentProps = InferWidgetProps< > & PVComponent; -interface TimeSeriesPoint { +export interface TimeSeriesPoint { dateTime: Date; [key: string]: Date | number | null; } @@ -72,7 +78,11 @@ export const StripChartComponent = ( foregroundColor = Color.fromRgba(0, 0, 0, 1), backgroundColor = Color.fromRgba(255, 255, 255, 1), start = "1 minute", - visible = true + visible = true, + archivedData = [], + archivedDataLoaded = false, + updatePeriod = 0, + bufferSize = 10000 } = props; // If we're passed an empty array fill in defaults @@ -80,17 +90,30 @@ export const StripChartComponent = ( () => (axes.length > 0 ? [...axes] : [new Axis({ xAxis: false })]), [axes] ); - // Convert start time into milliseconds period const timePeriod = useMemo(() => convertStringTimePeriod(start), [start]); + const [dateRange, setDateRange] = useState<{ minX: Date; maxX: Date }>({ minX: new Date(new Date().getTime() - timePeriod), maxX: new Date() }); - const [data, setData] = useState([]); + // Use useRef so rerender isn't triggered (overwriting the archivedData) when data updated + const data = useRef([]); + const dataLoaded = useRef(false); + + useEffect(() => { + // Only update data once the archiveData has loaded + // This is never triggered for base striptool, but works for + // databrowser + if (archivedDataLoaded && !dataLoaded.current) { + data.current = archivedData as TimeSeriesPoint[]; + dataLoaded.current = true; + } + }, [archivedData, archivedDataLoaded]); useEffect(() => { const updateDataMap = (timeSeries: TimeSeriesPoint[]) => { + // Add check for update period here if (pvData) { const allDates = Object.values(pvData) .map(pvItem => pvItem?.value?.getTime()?.datetime) @@ -106,6 +129,15 @@ export const StripChartComponent = ( allDates[0] ); + // Don't update if update period hasn't passed + if ( + timeSeries.length > 0 && + mostRecentDate.getTime() - timeSeries[0].dateTime.getTime() <= + updatePeriod * 1000 + ) { + return timeSeries; + } + // Remove outdated data points let i = 0; while ( @@ -115,8 +147,15 @@ export const StripChartComponent = ( ) { i++; } - const truncatedTimeSeries = + let truncatedTimeSeries = i - 1 > 0 ? timeSeries.slice(i - 1) : timeSeries; + truncatedTimeSeries = + truncatedTimeSeries.length >= bufferSize + ? truncatedTimeSeries.slice( + truncatedTimeSeries.length + 1 - bufferSize + ) + : truncatedTimeSeries; + // If buffer size exceeded, remove old data // create new data point let newTimeseriesPoint: TimeSeriesPoint = { dateTime: mostRecentDate }; @@ -131,7 +170,6 @@ export const StripChartComponent = ( return [...truncatedTimeSeries, newTimeseriesPoint]; } - return timeSeries; }; @@ -139,8 +177,8 @@ export const StripChartComponent = ( minX: new Date(new Date().getTime() - timePeriod), maxX: new Date() }); - setData(currentData => updateDataMap(currentData)); - }, [timePeriod, pvData]); + data.current = updateDataMap(data.current); + }, [timePeriod, pvData, bufferSize, updatePeriod]); const { yAxes, yAxesStyle } = useMemo(() => { // For some reason the below styling doesn't change axis line and tick @@ -160,20 +198,33 @@ export const StripChartComponent = ( const yAxes: ReadonlyArray> = localAxes.map( (item, idx): YAxis => ({ - width: 45, + width: 55, id: idx, label: item.title, color: item.color?.toString(), labelStyle: { - font: item.titleFont.css(), + fontSize: item.titleFont.css().fontSize, + fontStyle: item.titleFont.css().fontStyle, + fontFamily: item.titleFont.css().fontFamily, + fontWeight: item.titleFont.css().fontWeight, fill: item.color.toString() }, tickLabelStyle: { - font: item.scaleFont.css(), - fill: item.color.toString() + fontSize: item.scaleFont.css().fontSize, + fontStyle: item.scaleFont.css().fontStyle, + fontFamily: item.scaleFont.css().fontFamily, + fontWeight: item.scaleFont.css().fontWeight, + fill: item.color.toString(), + angle: item.onRight ? 90 : -90 }, + valueFormatter: (value: any, context: any) => + context.location === "tooltip" + ? `${value}` + : value.length > 4 + ? `${value.toExponential(3)}` + : value, scaleType: item.logScale ? "symlog" : "linear", - position: "left", + position: item.onRight ? "right" : "left", min: item.autoscale ? undefined : item.minimum, max: item.autoscale ? undefined : item.maximum }) @@ -250,7 +301,7 @@ export const StripChartComponent = ( ", (): void => { - it("renders one child", () => { + it("renders one child", async () => { const child = { type: "label", position: "relative", text: "hello" }; - const { queryByText } = render( - - - - ); - - expect(queryByText("hello")).toBeInTheDocument(); + const { findByText } = await act(() => { + return render( + + + + ); + }); + expect(await findByText("hello")).toBeInTheDocument(); }); - it("renders error widget for incorrect child", () => { + it("renders error widget for incorrect child", async () => { const child = { type: "image" }; // Suppress logging for expected error. log.setLevel("error"); - const { queryByText } = render( - - - - ); + const { findByText } = await act(() => { + return render( + + + + ); + }); log.setLevel("info"); - - expect(queryByText(/Error/)).toBeInTheDocument(); + expect(await findByText(/Error/)).toBeInTheDocument(); }); - it("changes tabs on click", () => { + it("changes tabs on click", async () => { const child1 = { type: "label", position: "relative", @@ -49,14 +51,21 @@ describe("", (): void => { position: "relative", text: "bye" }; - const { queryByText } = render( - - - - ); - expect(queryByText("hello")).toBeInTheDocument(); - fireEvent.click(queryByText("two") as HTMLDivElement); - expect(queryByText("bye")).toBeInTheDocument(); + const { findByText } = await act(() => { + return render( + + + + ); + }); + + expect(await findByText("hello")).toBeInTheDocument(); + + await act(async () => { + fireEvent.click((await findByText("two")) as HTMLDivElement); + }); + + expect(await findByText("bye")).toBeInTheDocument(); }); }); diff --git a/src/ui/widgets/Tabs/tabContainer.tsx b/src/ui/widgets/Tabs/tabContainer.tsx index d8120d81..10af68e2 100644 --- a/src/ui/widgets/Tabs/tabContainer.tsx +++ b/src/ui/widgets/Tabs/tabContainer.tsx @@ -8,7 +8,7 @@ * * See also the dynamic tabs widget. */ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import log from "loglevel"; @@ -39,20 +39,29 @@ export const TabContainerComponent = ( props: InferWidgetProps ): JSX.Element => { const [childIndex, setIndex] = useState(0); + const [children, setChildren] = useState([]); // TODO: Find out if this repeated calculation can be done in the useMemo hook for measurable performance gains - const children = Object.values(props.tabs).map((child, index) => { - try { - const childObject = parseObject(child, "ca"); - childObject["scroll"] = true; - return widgetDescriptionToComponent(childObject, index); - } catch (e) { - const message = `Error transforming children into components`; - log.warn(message); - log.warn(e); - return widgetDescriptionToComponent(errorWidget(message), index); - } - }); + useEffect(() => { + const loadChildren = async () => { + const nodes = await Promise.all( + Object.values(props.tabs).map(async (child, index) => { + try { + const childObject = await parseObject(child, "ca"); + childObject["scroll"] = true; + return widgetDescriptionToComponent(childObject, index); + } catch (e) { + const message = `Error transforming children into components`; + log.warn(message); + log.warn(e); + return widgetDescriptionToComponent(errorWidget(message), index); + } + }) + ); + setChildren(nodes); + }; + loadChildren(); + }, [props.tabs]); const tabNames = Object.keys(props.tabs); const onTabSelected = (index: number): void => { diff --git a/src/ui/widgets/index.ts b/src/ui/widgets/index.ts index 49dc2ee7..0f9d2781 100644 --- a/src/ui/widgets/index.ts +++ b/src/ui/widgets/index.ts @@ -14,6 +14,7 @@ export { BoolButton } from "./BoolButton/boolButton"; export { ByteMonitor } from "./ByteMonitor/byteMonitor"; export { Checkbox } from "./Checkbox/checkbox"; export { ChoiceButton } from "./ChoiceButton/choiceButton"; +export { DataBrowser } from "./DataBrowser/dataBrowser"; export { Device } from "./Device/device"; export { DrawerWidget } from "./Drawer/drawer"; export { DropDown } from "./DropDown/dropDown"; diff --git a/src/ui/widgets/propTypes.ts b/src/ui/widgets/propTypes.ts index 2de58cf0..8a5533d0 100644 --- a/src/ui/widgets/propTypes.ts +++ b/src/ui/widgets/propTypes.ts @@ -8,6 +8,7 @@ import { FileDescription } from "../../misc/fileContext"; import { Trace } from "../../types/trace"; import { Axis } from "../../types/axis"; import { Points } from "../../types/points"; +import { Plt } from "../../types/plt"; export type ExcludeNulls = { [P in keyof T]: Exclude; @@ -63,6 +64,9 @@ export const TracesPropOpt = PropTypes.arrayOf(TracePropOpt); export const AxesProp = PropTypes.arrayOf(AxisProp).isRequired; export const AxesPropOpt = PropTypes.arrayOf(AxisPropOpt); +export const PltProp = PropTypes.instanceOf(Plt).isRequired; +export const PltPropOpt = PropTypes.instanceOf(Plt); + export const FuncPropOpt = PropTypes.instanceOf(Function); export const FuncProp = FuncPropOpt.isRequired; @@ -191,3 +195,10 @@ export const ActionsPropType = PropTypes.shape({ executeAsOne: BoolPropOpt, actions: PropTypes.arrayOf(ActionPropType).isRequired }); + +export const TimeSeriesPointPropType = PropTypes.shape({ + dateTime: PropTypes.instanceOf(Date), + pv: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.number]) +}); + +export const ArchivedDataPropOpt = PropTypes.arrayOf(TimeSeriesPointPropType); diff --git a/src/ui/widgets/utils.ts b/src/ui/widgets/utils.ts index a5352815..dd06eb89 100644 --- a/src/ui/widgets/utils.ts +++ b/src/ui/widgets/utils.ts @@ -127,25 +127,68 @@ export function calculateRotationTransform( * @returns period of time in milliseconds */ export function convertStringTimePeriod(period: string): number { - // Array of all time period strings - const times = [ - { unit: "second", value: 1 }, - { unit: "minute", value: 60 }, - { unit: "hour", value: 3600 }, - { unit: "day", value: 86400 }, - { unit: "week", value: 604800 }, - { unit: "month", value: 705600 }, - { unit: "year", value: 31536000 } - ]; - let match = times.find(item => period.includes(item.unit)); - if (match === undefined) match = times[1]; - // Find number of time period - const multiplier = match - ? parseInt(period.replace(match.unit, "").trim()) - : 1; - // If multiplier can't be parsed, default again to 1 minute, and calculate time - const time = (isNaN(multiplier) ? 1 : multiplier) * match.value * 1000; - return time; + if (!period) return 60; + if (period === "now") return 0; + // Check if this is a date + const date = new Date(period).getTime(); + if (isNaN(date)) { + // Check if period is negative + const isNegative = period.charAt(0) === "-" ? true : false; + // Array of all time period strings + const times = [ + { unit: "sec", value: 1 }, + { unit: "min", value: 60 }, + { unit: "hour", value: 3600 }, + { unit: "day", value: 86400 }, + { unit: "week", value: 604800 }, + { unit: "month", value: 705600 }, + { unit: "year", value: 31536000 } + ]; + let match = times.find(item => period.includes(item.unit)); + if (match === undefined) match = times[1]; + // Find number of time period + const multiplier = match + ? parseFloat(period.replace(match.unit, "").trim()) + : 1; + // If multiplier can't be parsed, default again to 1 minute, and calculate time + const time = + (isNaN(multiplier) ? 1 : multiplier) * + match.value * + (isNegative ? -1000 : 1000); + return time; + } else { + // Date worked + return date; + } +} + +/** + * Trims down data collected from the archiver to reflect the + * requested period between updates and maximum dataset size + * @param updatePeriod seconds between value updates + * @param bufferSize max number of data points + * @param data data to trim down + */ +export function trimArchiveData( + updatePeriod: number, + bufferSize: number, + data: any[] +) { + // Cut data down by updatePeriod + let lastValueIndex = 0; + const filteredData = data.filter((item: any, idx) => { + // returns the first value + if (!idx) return true; + if (item.secs - updatePeriod > data[lastValueIndex].secs) { + lastValueIndex = idx; + return true; + } + return false; + }); + // If dataset is still over buffersize, remove first difference + const sizeDiff = filteredData.length - bufferSize; + if (sizeDiff > 0) filteredData.splice(0, sizeDiff); + return filteredData; } export const getPvValueAndName = (pvDataCollection: PvDatum[], index = 0) => {