From f36a9924e4fa09ce6f21b70e01451b0414ed4987 Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Tue, 14 Oct 2025 16:01:39 +0100 Subject: [PATCH 01/10] Create Databrowser widget --- src/types/plt.ts | 60 +++++ src/types/props.ts | 7 +- src/types/trace.ts | 10 +- src/ui/widgets/DataBrowser/dataBrowser.tsx | 102 ++++++++ src/ui/widgets/EmbeddedDisplay/bobParser.ts | 53 ++--- src/ui/widgets/EmbeddedDisplay/opiParser.ts | 6 +- src/ui/widgets/EmbeddedDisplay/parser.ts | 68 ++++-- src/ui/widgets/EmbeddedDisplay/pltParser.ts | 234 +++++++++++++++++++ src/ui/widgets/EmbeddedDisplay/useOpiFile.ts | 4 +- src/ui/widgets/StripChart/stripChart.tsx | 27 ++- src/ui/widgets/index.ts | 1 + src/ui/widgets/propTypes.ts | 11 + src/ui/widgets/utils.ts | 52 +++-- 13 files changed, 544 insertions(+), 91 deletions(-) create mode 100644 src/types/plt.ts create mode 100644 src/ui/widgets/DataBrowser/dataBrowser.tsx create mode 100644 src/ui/widgets/EmbeddedDisplay/pltParser.ts diff --git a/src/types/plt.ts b/src/types/plt.ts new file mode 100644 index 00000000..7a656df8 --- /dev/null +++ b/src/types/plt.ts @@ -0,0 +1,60 @@ +import { Axes } 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 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 = [], + pvlist = [], + background = Color.WHITE, + foreground = Color.BLACK, + scroll = true, + grid = false, + scrollStep = 5, + updatePeriod = 1, + 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.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 1ba8fb0d..8f00e58d 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 @@ -26,7 +27,9 @@ export type GenericProp = | Trace[] | Axes | Axis - | Points; + | Points + | Archiver + | Plt; export interface Expression { boolExp: string; diff --git a/src/types/trace.ts b/src/types/trace.ts index edd12513..784e3043 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 = undefined } = {}) { // 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/widgets/DataBrowser/dataBrowser.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx new file mode 100644 index 00000000..10a71f86 --- /dev/null +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from "react"; +import log from "loglevel"; +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 { convertStringTimePeriod } from "../utils"; + +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, setData] = useState<{ + x: Date[]; + y: any[]; + min?: Date; + max?: Date; + }>({ x: [], y: [] }); + const [archiveDataLoaded, setArchiveDataLoaded] = useState(false); + + useEffect(() => { + // Runs whenever pvlist updated and fetches archiver data + const fetchArchivedPVData = async () => { + { + // TO DO - use when multiple PVs enabled + // plt.pvlist.forEach((trace) => { + // //Make call to getPvsData for multiple pvs + // }) + try { + // 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 archiverCall = `${plt.pvlist[0].archive?.url}/data/getData.json?pv=${plt.pvlist[0].yPv}&from=${min.toISOString()}&to=${max.toISOString()}`; + const resp = await fetch(archiverCall); + const json = await resp.json(); + + setData({ + x: json[0].data.map((item: any) => { + return new Date(item.secs * 1000); + }), + y: json[0].data.map((item: any) => { + return item.val; + }), + min: min, + max: max + }); + setArchiveDataLoaded(true); + } catch (e) { + log.error( + `Failed to fetch archiver data for PV ${plt.pvlist[0].yPv} from ${plt.pvlist[0].archive?.url}.` + ); + } + } + }; + fetchArchivedPVData(); + }, []); + + return archiveDataLoaded ? ( + + ) : ( + <> + ); +}; + +const DataBrowserWidgetProps = { + ...DataBrowserProps, + ...PVWidgetPropType +}; + +export const DataBrowser = ( + props: InferWidgetProps +): JSX.Element => ; + +registerWidget(DataBrowser, DataBrowserWidgetProps, "databrowser"); diff --git a/src/ui/widgets/EmbeddedDisplay/bobParser.ts b/src/ui/widgets/EmbeddedDisplay/bobParser.ts index f0c119b0..65a29c3b 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 @@ -512,10 +505,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/opiParser.ts b/src/ui/widgets/EmbeddedDisplay/opiParser.ts index 2ee6bc5d..85461128 100644 --- a/src/ui/widgets/EmbeddedDisplay/opiParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/opiParser.ts @@ -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 dac078aa..bf5024e3 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}:`); @@ -108,14 +109,37 @@ export function genericParser( // 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 - if (newProps.hasOwnProperty("traces")) { + if (newProps.hasOwnProperty("traces") && newProps.traces.length > 0) { newProps.pvName = PV.parse(newProps.traces[0].yPv); + } else if (newProps.hasOwnProperty("plt") && newProps.plt.pvlist.length > 0) { + newProps.pvName = PV.parse(newProps.plt.pvlist[0].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, @@ -124,9 +148,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, @@ -139,18 +163,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.ts b/src/ui/widgets/EmbeddedDisplay/pltParser.ts new file mode 100644 index 00000000..ab5ee57b --- /dev/null +++ b/src/ui/widgets/EmbeddedDisplay/pltParser.ts @@ -0,0 +1,234 @@ +import { xml2js, ElementCompact } from "xml-js"; +import { Color, Font, FontStyle } from "../../../types"; +import { XmlDescription, opiParseBoolean, opiParseString } from "./opiParser"; +import { parseChildProps, ParserDict } from "./parser"; +import { bobParseNumber } from "./bobParser"; +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", bobParseNumber], + updatePeriod: ["update_period", bobParseNumber], + 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", bobParseNumber], + period: ["period", bobParseNumber], + linewidth: ["linewidth", bobParseNumber], + pointType: ["point_type", pltParsePointType], + name: ["name", opiParseString], + ringSize: ["ring_size", bobParseNumber], + request: ["request", opiParseString], + archive: ["archive", pltParseArchiver], + titleFont: ["title_font", pltParseFont], + scaleFont: ["scale_font", pltParseFont], + labelFont: ["label_font", pltParseFont], + legendFont: ["legend_font", pltParseFont], + min: ["min", bobParseNumber], + max: ["max", bobParseNumber], + axis: ["axis", bobParseNumber] +}; + +/** + * 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 = {}; + if (props) { + // If only one trace, we are passed an object instead + // of an array + if (props.pv.length > 1) { + props.pv.forEach((trace: any) => { + parsedProps = parseChildProps(trace, PLT_PARSERS); + traces.push( + new Trace({ + ...parsedProps, + yPv: parsedProps.name, + lineWidth: parsedProps.linewidth + }) + ); + }); + } else { + parsedProps = parseChildProps(props.pv, PLT_PARSERS); + traces.push( + new Trace({ + ...parsedProps, + yPv: parsedProps.name, + lineWidth: parsedProps.linewidth + }) + ); + } + } + return traces; +} + +/** + * Parses axes from plt + * @param props + * @returns list of Axis objects + */ +function pltParseAxes(props: ElementCompact) { + const axes: Axis[] = []; + let parsedProps: any = {}; + if (props) { + // If only once axis, we are passed an object instead + // of an array + if (props.axis.length > 1) { + props.axis.forEach((axis: any) => { + parsedProps = parseChildProps(axis, PLT_PARSERS); + axes.push( + new Axis({ + ...parsedProps, + fromOpi: false, + showGrid: parsedProps.grid, + onRight: parsedProps.right, + title: parsedProps.useAxisName ? parsedProps.name : "", + titleFont: parsedProps.labelFont, + minimum: parsedProps.min, + maximum: parsedProps.max + }) + ); + }); + } else { + parsedProps = parseChildProps(props.axis, PLT_PARSERS); + axes.push( + new Axis({ + ...parsedProps, + fromOpi: false, + showGrid: parsedProps.grid, + onRight: parsedProps.right, + title: parsedProps.useAxisName ? parsedProps.name : "", + 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 = pltParsePvlist(databrowser["pvlist"]); + const axes = pltParseAxes(databrowser["axes"]); + 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..0dcf6513 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); break; case "opi": - description = parseOpi(contents, protocol, parentDir); + description = await parseOpi(contents, protocol, parentDir); break; } } diff --git a/src/ui/widgets/StripChart/stripChart.tsx b/src/ui/widgets/StripChart/stripChart.tsx index 39800a6e..a78e2945 100644 --- a/src/ui/widgets/StripChart/stripChart.tsx +++ b/src/ui/widgets/StripChart/stripChart.tsx @@ -9,7 +9,8 @@ import { ColorPropOpt, StringPropOpt, TracesProp, - AxesProp + AxesProp, + ArchivedDataPropOpt } from "../propTypes"; import { registerWidget } from "../register"; import { Box, Typography } from "@mui/material"; @@ -42,7 +43,8 @@ const StripChartProps = { scaleFont: FontPropOpt, showLegend: BoolPropOpt, showToolbar: BoolPropOpt, - visible: BoolPropOpt + visible: BoolPropOpt, + archivedData: ArchivedDataPropOpt }; // Needs to be exported for testing @@ -67,7 +69,8 @@ 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 = { x: [], y: [], min: undefined, max: undefined } } = props; // If we're passed an empty array fill in defaults if (traces.length < 1) traces.push(new Trace()); @@ -75,19 +78,14 @@ export const StripChartComponent = ( // Convert start time into milliseconds period const timePeriod = useMemo(() => convertStringTimePeriod(start), [start]); - const [data, setData] = useState<{ - x: any[]; - y: any[]; - min?: Date; - max?: Date; - }>({ x: [], y: [] }); + const [data, setData] = useState(archivedData); useEffect(() => { if (value) { // rRemove data outside min and max bounds const minimum = new Date(new Date().getTime() - timePeriod); // Check if first data point in array is outside minimum, if so remove - setData(currentData => { + setData((currentData: any) => { const xData = currentData.x; const yData = currentData.y; if (xData.length > 0 && xData[0].getTime() < minimum.getTime()) { @@ -102,7 +100,7 @@ export const StripChartComponent = ( }; }); } - }, [value, timePeriod]); + }, [value, timePeriod, data]); // For some reason the below styling doesn't change axis line and tick // colour so we set it using sx in the Line Chart below by passing this in @@ -123,7 +121,7 @@ export const StripChartComponent = ( fill: item.color.toString() }, scaleType: item.logScale ? "symlog" : "linear", - position: "left", + position: item.onRight ? "right" : "left", min: item.autoscale ? undefined : item.minimum, max: item.autoscale ? undefined : item.maximum }; @@ -149,10 +147,11 @@ export const StripChartComponent = ( } ]; - const series = traces.map(item => { + const series = traces.map((item, idx) => { const trace = { // If axis is set higher than number of axes, default to zero - id: item.axis <= axes.length - 1 ? item.axis : 0, + id: idx, + axisId: item.axis <= axes.length - 1 ? item.axis : 0, data: data.y, label: item.name, color: visible ? item.color.toString() : "transparent", 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 db21f1fd..9d969e0c 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; @@ -59,6 +60,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; @@ -187,3 +191,10 @@ export const ActionsPropType = PropTypes.shape({ executeAsOne: BoolPropOpt, actions: PropTypes.arrayOf(ActionPropType).isRequired }); + +export const ArchivedDataPropOpt = PropTypes.shape({ + x: PropTypes.arrayOf(PropTypes.instanceOf(Date).isRequired), + y: PropTypes.arrayOf(FloatProp).isRequired, + min: PropTypes.instanceOf(Date), + max: PropTypes.instanceOf(Date) +}); diff --git a/src/ui/widgets/utils.ts b/src/ui/widgets/utils.ts index 0d22c080..bf5c1274 100644 --- a/src/ui/widgets/utils.ts +++ b/src/ui/widgets/utils.ts @@ -125,23 +125,37 @@ 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; + } } From 2545c3753000369e512abb871e253173e6cf6d96 Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Wed, 15 Oct 2025 11:18:28 +0100 Subject: [PATCH 02/10] Add tests --- src/types/plt.test.ts | 69 ++++++++ src/types/plt.ts | 6 +- src/types/trace.test.ts | 12 +- src/types/trace.ts | 2 +- .../widgets/DataBrowser/dataBrowser.test.tsx | 153 ++++++++++++++++++ src/ui/widgets/DataBrowser/dataBrowser.tsx | 67 ++++---- .../widgets/EmbeddedDisplay/bobParser.test.ts | 24 +-- .../EmbeddedDisplay/jsonParser.test.ts | 14 +- src/ui/widgets/EmbeddedDisplay/jsonParser.ts | 12 +- .../widgets/EmbeddedDisplay/opiParser.test.ts | 36 ++--- src/ui/widgets/EmbeddedDisplay/opiParser.ts | 2 +- .../widgets/EmbeddedDisplay/pltParser.test.ts | 110 +++++++++++++ src/ui/widgets/EmbeddedDisplay/pltParser.ts | 26 +-- src/ui/widgets/EmbeddedDisplay/useOpiFile.ts | 2 +- 14 files changed, 438 insertions(+), 97 deletions(-) create mode 100644 src/types/plt.test.ts create mode 100644 src/ui/widgets/DataBrowser/dataBrowser.test.tsx create mode 100644 src/ui/widgets/EmbeddedDisplay/pltParser.test.ts diff --git a/src/types/plt.test.ts b/src/types/plt.test.ts new file mode 100644 index 00000000..09fb70a2 --- /dev/null +++ b/src/types/plt.test.ts @@ -0,0 +1,69 @@ +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, + 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, + 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: 1, + 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 index 7a656df8..dc01223d 100644 --- a/src/types/plt.ts +++ b/src/types/plt.ts @@ -1,4 +1,4 @@ -import { Axes } from "./axis"; +import { Axes, Axis } from "./axis"; import { Color } from "./color"; import { Font } from "./font"; import { Trace } from "./trace"; @@ -26,8 +26,8 @@ export class Plt { */ public constructor({ title = "", - axes = [], - pvlist = [], + axes = [new Axis()], + pvlist = [new Trace()], background = Color.WHITE, foreground = Color.BLACK, scroll = true, 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 784e3043..f318070d 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -44,7 +44,7 @@ export class Trace { updateDelay = 100, updateMode = 0, plotMode = 0, - archive = undefined + archive = { name: "", url: "" } } = {}) { // xPV property only exists on XYPlot if (xPv) this.xPv = xPv; diff --git a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx new file mode 100644 index 00000000..c7b0f959 --- /dev/null +++ b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx @@ -0,0 +1,153 @@ +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"; + +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, sx }) => ( +
+ )) +})); + +vi.mock("@mui/material", () => ({ + Box: vi.fn(({ children }) =>
{children}
), + Typography: vi.fn(({ children }) => ( +
{children}
+ )) +})); + +describe("DataBrowserComponent", () => { + // Basic test setup + const defaultProps = { + value: { + getDoubleValue: () => 50, + getTime: () => { + new Date(Date.now()); + } + } as Partial as DType, + connected: true, + readonly: true, + pvName: "TEST:PV", + plt: new Plt({ + pvlist: [ + new Trace({ + archive: { + name: "Primary", + url: "http://archiver.diamond.ac.uk/retrieval" + }, + yPv: "TEST:PV" + }) + ], + axes: [new Axis()] + }) + }; + + const mockSuccessResponse: any = JSON.stringify([ + { + secs: new Date().getTime() - 250000, + val: 52 + }, + { + secs: new Date().getTime() - 200000, + val: 45 + }, + { + secs: new Date().getTime() - 70000, + val: 60 + } + ]); + console.log(mockSuccessResponse); + const mockJsonPromise = Promise.resolve( + JSON.parse(`[{"data": ${mockSuccessResponse}}]`) + ); + const mockFetchPromise = Promise.resolve({ + json: (): Promise => mockJsonPromise + }); + const mockFetch = (): Promise => mockFetchPromise; + vi.spyOn(globalWithFetch, "fetch").mockImplementation(mockFetch); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Rendering", () => { + test("renders with default props", () => { + 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", () => { + const axes = [ + new Axis({ color: Color.RED }), + new Axis({ color: Color.BLUE, onRight: true }) + ]; + const newProps = { + ...defaultProps, + plt: new Plt({ axes: axes }) + }; + 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", () => { + const newProps = { + ...defaultProps, + plt: new Plt({ start: "5 min", end: "now" }) + }; + act(() => { + render(); + }); + const lineChart = screen.getByTestId("line-chart"); + 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(); + + const seriesData = JSON.parse( + lineChart.getAttribute("data-series") ?? "" + ); + + expect(actualDiff).toBe(expectedDiff); + //expect(seriesData[0].data).toBe([52, 45, 60, 50]); + }); + }); +}); diff --git a/src/ui/widgets/DataBrowser/dataBrowser.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx index 10a71f86..b82de85b 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -40,43 +40,42 @@ export const DataBrowserComponent = ( useEffect(() => { // Runs whenever pvlist updated and fetches archiver data const fetchArchivedPVData = async () => { - { - // TO DO - use when multiple PVs enabled - // plt.pvlist.forEach((trace) => { - // //Make call to getPvsData for multiple pvs - // }) - try { - // 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 archiverCall = `${plt.pvlist[0].archive?.url}/data/getData.json?pv=${plt.pvlist[0].yPv}&from=${min.toISOString()}&to=${max.toISOString()}`; - const resp = await fetch(archiverCall); - const json = await resp.json(); + // TO DO - use when multiple PVs enabled + // plt.pvlist.forEach((trace) => { + // //Make call to getPvsData for multiple pvs + // }) + try { + // 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 archiverCall = `${plt.pvlist[0].archive?.url}/data/getData.json?pv=${plt.pvlist[0].yPv}&from=${min.toISOString()}&to=${max.toISOString()}`; + const resp = await fetch(archiverCall); + const json = await resp.json(); - setData({ - x: json[0].data.map((item: any) => { - return new Date(item.secs * 1000); - }), - y: json[0].data.map((item: any) => { - return item.val; - }), - min: min, - max: max - }); - setArchiveDataLoaded(true); - } catch (e) { - log.error( - `Failed to fetch archiver data for PV ${plt.pvlist[0].yPv} from ${plt.pvlist[0].archive?.url}.` - ); - } + setData({ + x: json[0].data.map((item: any) => { + return new Date(item.secs * 1000); + }), + y: json[0].data.map((item: any) => { + return item.val; + }), + min: min, + max: max + }); + setArchiveDataLoaded(true); + } catch (e) { + log.error( + `Failed to fetch archiver data for PV ${plt.pvlist[0].yPv} from ${plt.pvlist[0].archive?.url}.` + ); } }; - fetchArchivedPVData(); - }, []); + // Only fetch onces + if (!archiveDataLoaded) fetchArchivedPVData(); + }, [archiveDataLoaded, plt.start, plt.end, plt.pvlist]); - return archiveDataLoaded ? ( + return ( - ) : ( - <> ); }; diff --git a/src/ui/widgets/EmbeddedDisplay/bobParser.test.ts b/src/ui/widgets/EmbeddedDisplay/bobParser.test.ts index ae65375b..c5ff61c0 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.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/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 85461128..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); } diff --git a/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts b/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts new file mode 100644 index 00000000..3326e34a --- /dev/null +++ b/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts @@ -0,0 +1,110 @@ +import { vi } from "vitest"; +import { Color } from "../../../types"; +import { RelativePosition } from "../../../types/position"; +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 index ab5ee57b..4e282f7e 100644 --- a/src/ui/widgets/EmbeddedDisplay/pltParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/pltParser.ts @@ -1,8 +1,12 @@ import { xml2js, ElementCompact } from "xml-js"; import { Color, Font, FontStyle } from "../../../types"; -import { XmlDescription, opiParseBoolean, opiParseString } from "./opiParser"; +import { + XmlDescription, + opiParseBoolean, + opiParseString, + opiParseNumber +} from "./opiParser"; import { parseChildProps, ParserDict } from "./parser"; -import { bobParseNumber } from "./bobParser"; import { Axis } from "../../../types/axis"; import { Archiver, Trace } from "../../../types/trace"; import { Plt } from "../../../types/plt"; @@ -12,8 +16,8 @@ const PLT_PARSERS: ParserDict = { end: ["end", opiParseString], grid: ["grid", opiParseBoolean], scroll: ["scroll", opiParseBoolean], - scrollStep: ["scroll_step", bobParseNumber], - updatePeriod: ["update_period", bobParseNumber], + scrollStep: ["scroll_step", opiParseNumber], + updatePeriod: ["update_period", opiParseNumber], background: ["background", pltParseColor], foreground: ["foreground", pltParseColor], color: ["color", pltParseColor], @@ -22,21 +26,21 @@ const PLT_PARSERS: ParserDict = { useTraceNames: ["use_trace_names", opiParseBoolean], right: ["right", opiParseBoolean], displayName: ["display_name", opiParseString], - waveformIndex: ["waveform_index", bobParseNumber], - period: ["period", bobParseNumber], - linewidth: ["linewidth", bobParseNumber], + waveformIndex: ["waveform_index", opiParseNumber], + period: ["period", opiParseNumber], + linewidth: ["linewidth", opiParseNumber], pointType: ["point_type", pltParsePointType], name: ["name", opiParseString], - ringSize: ["ring_size", bobParseNumber], + 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", bobParseNumber], - max: ["max", bobParseNumber], - axis: ["axis", bobParseNumber] + min: ["min", opiParseNumber], + max: ["max", opiParseNumber], + axis: ["axis", opiParseNumber] }; /** diff --git a/src/ui/widgets/EmbeddedDisplay/useOpiFile.ts b/src/ui/widgets/EmbeddedDisplay/useOpiFile.ts index 0dcf6513..b64b6041 100644 --- a/src/ui/widgets/EmbeddedDisplay/useOpiFile.ts +++ b/src/ui/widgets/EmbeddedDisplay/useOpiFile.ts @@ -49,7 +49,7 @@ async function fetchAndConvert( description = await parseBob(contents, protocol, parentDir); break; case "json": - description = parseJson(contents, protocol, parentDir); + description = await parseJson(contents, protocol, parentDir); break; case "opi": description = await parseOpi(contents, protocol, parentDir); From 88363a3fc0b8ce059db2fb5085abfdab5f238dea Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Thu, 16 Oct 2025 09:35:00 +0100 Subject: [PATCH 03/10] Fix axis labels --- src/types/axis.test.ts | 4 +-- src/types/axis.ts | 4 +-- src/types/font.ts | 2 +- .../widgets/DataBrowser/dataBrowser.test.tsx | 1 - .../widgets/EmbeddedDisplay/pltParser.test.ts | 1 - src/ui/widgets/StripChart/stripChart.tsx | 25 ++++++++++++++----- 6 files changed, 24 insertions(+), 13 deletions(-) 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/ui/widgets/DataBrowser/dataBrowser.test.tsx b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx index c7b0f959..332ea77a 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx @@ -80,7 +80,6 @@ describe("DataBrowserComponent", () => { val: 60 } ]); - console.log(mockSuccessResponse); const mockJsonPromise = Promise.resolve( JSON.parse(`[{"data": ${mockSuccessResponse}}]`) ); diff --git a/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts b/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts index 3326e34a..ab2476d3 100644 --- a/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts +++ b/src/ui/widgets/EmbeddedDisplay/pltParser.test.ts @@ -1,6 +1,5 @@ import { vi } from "vitest"; import { Color } from "../../../types"; -import { RelativePosition } from "../../../types/position"; import { parsePlt } from "./pltParser"; declare global { diff --git a/src/ui/widgets/StripChart/stripChart.tsx b/src/ui/widgets/StripChart/stripChart.tsx index a78e2945..2bbb8c58 100644 --- a/src/ui/widgets/StripChart/stripChart.tsx +++ b/src/ui/widgets/StripChart/stripChart.tsx @@ -100,7 +100,7 @@ export const StripChartComponent = ( }; }); } - }, [value, timePeriod, data]); + }, [value, timePeriod]); // For some reason the below styling doesn't change axis line and tick // colour so we set it using sx in the Line Chart below by passing this in @@ -109,17 +109,30 @@ export const StripChartComponent = ( const yAxes: ReadonlyArray> = axes.map((item, idx) => { const axis = { width: 45, - id: idx, + 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: -90 }, + valueFormatter: (value: any, context: any) => + context.location === "tooltip" + ? `${value}` + : value.length > 4 + ? `${value.toExponential(3)}` + : value, scaleType: item.logScale ? "symlog" : "linear", position: item.onRight ? "right" : "left", min: item.autoscale ? undefined : item.minimum, @@ -151,7 +164,7 @@ export const StripChartComponent = ( const trace = { // If axis is set higher than number of axes, default to zero id: idx, - axisId: item.axis <= axes.length - 1 ? item.axis : 0, + axisId: `${item.axis <= axes.length - 1 ? item.axis : 0}`, data: data.y, label: item.name, color: visible ? item.color.toString() : "transparent", From 2d66ef7734501768fa62a58b6f01c374844f0155 Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Thu, 16 Oct 2025 15:58:00 +0100 Subject: [PATCH 04/10] Show acrhiver data on plot --- src/types/plt.ts | 5 +- src/ui/widgets/DataBrowser/dataBrowser.tsx | 21 +++---- src/ui/widgets/EmbeddedDisplay/pltParser.ts | 1 + src/ui/widgets/StripChart/stripChart.tsx | 64 ++++++++++++++------- src/ui/widgets/utils.ts | 24 ++++++++ 5 files changed, 82 insertions(+), 33 deletions(-) diff --git a/src/types/plt.ts b/src/types/plt.ts index dc01223d..be22b997 100644 --- a/src/types/plt.ts +++ b/src/types/plt.ts @@ -12,6 +12,7 @@ export class Plt { public scroll: boolean; public scrollStep: number; public updatePeriod: number; + public bufferSize: number; public start: string; public end: string; public showGrid: boolean; @@ -33,7 +34,8 @@ export class Plt { scroll = true, grid = false, scrollStep = 5, - updatePeriod = 1, + updatePeriod = 0, + bufferSize = 5000, titleFont = new Font(), labelFont = new Font(), legendFont = new Font(), @@ -49,6 +51,7 @@ export class Plt { this.axes = axes; this.pvlist = pvlist; this.updatePeriod = updatePeriod; + this.bufferSize = bufferSize; this.showGrid = grid; this.start = start; this.end = end; diff --git a/src/ui/widgets/DataBrowser/dataBrowser.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx index b82de85b..ff3e321f 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -10,7 +10,7 @@ import { } from "../propTypes"; import { registerWidget } from "../register"; import { StripChartComponent } from "../StripChart/stripChart"; -import { convertStringTimePeriod } from "../utils"; +import { convertStringTimePeriod, trimArchiveData } from "../utils"; const DataBrowserProps = { plt: PltProp, @@ -32,9 +32,7 @@ export const DataBrowserComponent = ( const [data, setData] = useState<{ x: Date[]; y: any[]; - min?: Date; - max?: Date; - }>({ x: [], y: [] }); + }>(); const [archiveDataLoaded, setArchiveDataLoaded] = useState(false); useEffect(() => { @@ -48,21 +46,23 @@ export const DataBrowserComponent = ( // Fetch archiver data for period const startTime = convertStringTimePeriod(plt.start); const endTime = convertStringTimePeriod(plt.end); - const min = new Date(new Date().getTime() - startTime); + // Add extra minute as buffer + const min = new Date(new Date().getTime() - startTime - 60000); const max = new Date(new Date().getTime() - endTime); - const archiverCall = `${plt.pvlist[0].archive?.url}/data/getData.json?pv=${plt.pvlist[0].yPv}&from=${min.toISOString()}&to=${max.toISOString()}`; + // TO DO - optimise request based on plt.request + const archiverCall = `${plt.pvlist[0].archive?.url}/data/getData.json?pv=mean_${plt.updatePeriod}(${plt.pvlist[0].yPv})&from=${min.toISOString()}&to=${max.toISOString()}`; const resp = await fetch(archiverCall); const json = await resp.json(); + // Filter data down by update period and buffer size + const trimmedData = trimArchiveData(plt.updatePeriod, plt.bufferSize, json[0].data) setData({ - x: json[0].data.map((item: any) => { + x: trimmedData.map((item: any) => { return new Date(item.secs * 1000); }), - y: json[0].data.map((item: any) => { + y: trimmedData.map((item: any) => { return item.val; }), - min: min, - max: max }); setArchiveDataLoaded(true); } catch (e) { @@ -83,6 +83,7 @@ export const DataBrowserComponent = ( readonly={props.readonly} connected={props.connected} archivedData={data} + archivedDataLoaded={archiveDataLoaded} /> ); }; diff --git a/src/ui/widgets/EmbeddedDisplay/pltParser.ts b/src/ui/widgets/EmbeddedDisplay/pltParser.ts index 4e282f7e..eb34d4ce 100644 --- a/src/ui/widgets/EmbeddedDisplay/pltParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/pltParser.ts @@ -18,6 +18,7 @@ const PLT_PARSERS: ParserDict = { scroll: ["scroll", opiParseBoolean], scrollStep: ["scroll_step", opiParseNumber], updatePeriod: ["update_period", opiParseNumber], + bufferSize: ["buffer_size", opiParseNumber], background: ["background", pltParseColor], foreground: ["foreground", pltParseColor], color: ["color", pltParseColor], diff --git a/src/ui/widgets/StripChart/stripChart.tsx b/src/ui/widgets/StripChart/stripChart.tsx index 2bbb8c58..67da8d63 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"; @@ -44,7 +44,8 @@ const StripChartProps = { showLegend: BoolPropOpt, showToolbar: BoolPropOpt, visible: BoolPropOpt, - archivedData: ArchivedDataPropOpt + archivedData: ArchivedDataPropOpt, + archivedDataLoaded: BoolPropOpt }; // Needs to be exported for testing @@ -70,7 +71,8 @@ export const StripChartComponent = ( backgroundColor = Color.fromRgba(255, 255, 255, 1), start = "1 minute", visible = true, - archivedData = { x: [], y: [], min: undefined, max: undefined } + archivedData = { x: [], y: [], min: undefined, max: undefined }, + archivedDataLoaded = false } = props; // If we're passed an empty array fill in defaults if (traces.length < 1) traces.push(new Trace()); @@ -78,30 +80,48 @@ export const StripChartComponent = ( // Convert start time into milliseconds period const timePeriod = useMemo(() => convertStringTimePeriod(start), [start]); - const [data, setData] = useState(archivedData); + // Use useRef so rerender isn't triggered (overwriting the archivedData) when data updated + const data = useRef<{ + x: Date[]; + y: any[]; + dataLoaded?: boolean, + min?: Date; + max?: Date; + }>({x: [], y: []}); useEffect(() => { + // Only update data once the archiveData has loaded if (value) { - // rRemove data outside min and max bounds + // Remove data outside min and max bounds const minimum = new Date(new Date().getTime() - timePeriod); // Check if first data point in array is outside minimum, if so remove - setData((currentData: any) => { - const xData = currentData.x; - const yData = currentData.y; - if (xData.length > 0 && xData[0].getTime() < minimum.getTime()) { - xData.shift(); - yData.shift(); - } - return { - x: [...xData, value.getTime()?.datetime], + const xData = data.current.x; + const yData = data.current.y; + if (xData.length > 0 && xData[0].getTime() < minimum.getTime()) { + xData.shift(); + yData.shift(); + } + data.current = { + x: [...xData, value.getTime()!.datetime], y: [...yData, value.getDoubleValue()], min: minimum, - max: new Date() - }; - }); + max: new Date(), + } } }, [value, timePeriod]); + useEffect(() => { + // Only update data once the archiveData has loaded + if (archivedDataLoaded) { + const xData = data.current.x; + const yData = data.current.y; + if (!data.current.dataLoaded) { + data.current.x = xData.concat(archivedData.x!) + data.current.y = yData.concat(archivedData.y) + } + } + }, [archivedData, archivedDataLoaded]); + // For some reason the below styling doesn't change axis line and tick // colour so we set it using sx in the Line Chart below by passing this in const yAxesStyle: any = {}; @@ -125,7 +145,7 @@ export const StripChartComponent = ( fontFamily: item.scaleFont.css().fontFamily, fontWeight: item.scaleFont.css().fontWeight, fill: item.color.toString(), - angle: -90 + angle: item.onRight ? 90 : -90 }, valueFormatter: (value: any, context: any) => context.location === "tooltip" @@ -151,11 +171,11 @@ export const StripChartComponent = ( const xAxis: ReadonlyArray> = [ { - data: data.x, + data: data.current.x, color: foregroundColor.toString(), dataKey: "datetime", - min: data.min, - max: data.max, + min: data.current.min, + max: data.current.max, scaleType: "time" } ]; @@ -165,7 +185,7 @@ export const StripChartComponent = ( // If axis is set higher than number of axes, default to zero id: idx, axisId: `${item.axis <= axes.length - 1 ? item.axis : 0}`, - data: data.y, + data: data.current.y, label: item.name, color: visible ? item.color.toString() : "transparent", showMark: item.pointType === 0 ? false : true, diff --git a/src/ui/widgets/utils.ts b/src/ui/widgets/utils.ts index bf5c1274..65ea3653 100644 --- a/src/ui/widgets/utils.ts +++ b/src/ui/widgets/utils.ts @@ -159,3 +159,27 @@ export function convertStringTimePeriod(period: string): number { 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; + let 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; + } + }) + // If dataset is still over buffersize, remove first difference + const sizeDiff = filteredData.length - bufferSize; + if (sizeDiff > 0) filteredData.splice(0, sizeDiff); + return filteredData; +} \ No newline at end of file From d0b89784091834a3fcb349103b57b0be00f0e45d Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Tue, 21 Oct 2025 11:26:09 +0100 Subject: [PATCH 05/10] Fix tests and components affected by async parsing --- src/types/plt.test.ts | 5 +- .../widgets/DataBrowser/dataBrowser.test.tsx | 26 +++--- src/ui/widgets/DataBrowser/dataBrowser.tsx | 17 ++-- src/ui/widgets/Device/device.tsx | 63 +++++++------ src/ui/widgets/EmbeddedDisplay/pltParser.ts | 61 +++++-------- src/ui/widgets/StripChart/stripChart.test.tsx | 11 +-- src/ui/widgets/StripChart/stripChart.tsx | 89 ++++++++++++++----- src/ui/widgets/Tabs/tabContainer.test.tsx | 61 +++++++------ src/ui/widgets/Tabs/tabContainer.tsx | 35 +++++--- src/ui/widgets/utils.ts | 39 ++++---- 10 files changed, 242 insertions(+), 165 deletions(-) diff --git a/src/types/plt.test.ts b/src/types/plt.test.ts index 09fb70a2..07065915 100644 --- a/src/types/plt.test.ts +++ b/src/types/plt.test.ts @@ -16,6 +16,7 @@ describe("Plt", () => { grid: true, scrollStep: 5, updatePeriod: 10, + bufferSize: 2000, titleFont: new Font(14), labelFont: new Font(8), legendFont: new Font(8), @@ -34,6 +35,7 @@ describe("Plt", () => { showGrid: true, scrollStep: 5, updatePeriod: 10, + bufferSize: 2000, titleFont: new Font(14), labelFont: new Font(8), legendFont: new Font(8), @@ -56,7 +58,8 @@ describe("Plt", () => { scroll: true, showGrid: false, scrollStep: 5, - updatePeriod: 1, + updatePeriod: 0, + bufferSize: 5000, titleFont: new Font(), labelFont: new Font(), legendFont: new Font(), diff --git a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx index 332ea77a..6deb7906 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx @@ -46,7 +46,7 @@ describe("DataBrowserComponent", () => { value: { getDoubleValue: () => 50, getTime: () => { - new Date(Date.now()); + return { datetime: new Date(Date.now()) }; } } as Partial as DType, connected: true, @@ -68,15 +68,15 @@ describe("DataBrowserComponent", () => { const mockSuccessResponse: any = JSON.stringify([ { - secs: new Date().getTime() - 250000, + secs: (new Date().getTime() - 250000) / 1000, val: 52 }, { - secs: new Date().getTime() - 200000, + secs: (new Date().getTime() - 200000) / 1000, val: 45 }, { - secs: new Date().getTime() - 70000, + secs: (new Date().getTime() - 70000) / 1000, val: 60 } ]); @@ -125,13 +125,14 @@ describe("DataBrowserComponent", () => { expect(yAxisData[1].position).toBe("right"); }); - test("renders with 5 minute archived data", () => { + test("renders with 5 minute archived data", async () => { const newProps = { ...defaultProps, plt: new Plt({ start: "5 min", end: "now" }) }; - act(() => { - render(); + + const { rerender } = await act(async () => { + return render(); }); const lineChart = screen.getByTestId("line-chart"); const xAxisData = JSON.parse(lineChart.getAttribute("data-xaxis") ?? ""); @@ -141,12 +142,17 @@ describe("DataBrowserComponent", () => { new Date(xAxisData[0].max).getTime() - new Date(xAxisData[0].min).getTime(); + expect(actualDiff).toBe(expectedDiff); + + await act(async () => { + rerender(); + }); + const newLineChart = screen.getByTestId("line-chart"); const seriesData = JSON.parse( - lineChart.getAttribute("data-series") ?? "" + newLineChart.getAttribute("data-series") ?? "" ); - expect(actualDiff).toBe(expectedDiff); - //expect(seriesData[0].data).toBe([52, 45, 60, 50]); + expect(seriesData[0].data).toEqual([50, 52, 45, 60]); }); }); }); diff --git a/src/ui/widgets/DataBrowser/dataBrowser.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx index ff3e321f..637449d9 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -46,23 +46,26 @@ export const DataBrowserComponent = ( // Fetch archiver data for period const startTime = convertStringTimePeriod(plt.start); const endTime = convertStringTimePeriod(plt.end); - // Add extra minute as buffer - const min = new Date(new Date().getTime() - startTime - 60000); + const min = new Date(new Date().getTime() - startTime); const max = new Date(new Date().getTime() - endTime); - // TO DO - optimise request based on plt.request + // TO DO - optimise request based on plt.request. Currently we optimise all requests const archiverCall = `${plt.pvlist[0].archive?.url}/data/getData.json?pv=mean_${plt.updatePeriod}(${plt.pvlist[0].yPv})&from=${min.toISOString()}&to=${max.toISOString()}`; const resp = await fetch(archiverCall); const json = await resp.json(); // Filter data down by update period and buffer size - const trimmedData = trimArchiveData(plt.updatePeriod, plt.bufferSize, json[0].data) + const trimmedData = trimArchiveData( + plt.updatePeriod, + plt.bufferSize, + json[0].data + ); setData({ x: trimmedData.map((item: any) => { return new Date(item.secs * 1000); }), y: trimmedData.map((item: any) => { return item.val; - }), + }) }); setArchiveDataLoaded(true); } catch (e) { @@ -73,7 +76,7 @@ export const DataBrowserComponent = ( }; // Only fetch onces if (!archiveDataLoaded) fetchArchivedPVData(); - }, [archiveDataLoaded, plt.start, plt.end, plt.pvlist]); + }, [archiveDataLoaded, plt]); return ( ); }; 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/pltParser.ts b/src/ui/widgets/EmbeddedDisplay/pltParser.ts index eb34d4ce..75b7a626 100644 --- a/src/ui/widgets/EmbeddedDisplay/pltParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/pltParser.ts @@ -108,22 +108,18 @@ function pltParseArchiver(jsonProp: ElementCompact) { 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 - if (props.pv.length > 1) { - props.pv.forEach((trace: any) => { - parsedProps = parseChildProps(trace, PLT_PARSERS); - traces.push( - new Trace({ - ...parsedProps, - yPv: parsedProps.name, - lineWidth: parsedProps.linewidth - }) - ); - }); - } else { - parsedProps = parseChildProps(props.pv, PLT_PARSERS); + 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, @@ -131,53 +127,40 @@ function pltParsePvlist(props: ElementCompact) { lineWidth: parsedProps.linewidth }) ); - } + }); } - return traces; + return [traces, pvAxes]; } /** - * Parses axes from plt + * 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) { +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 - if (props.axis.length > 1) { - props.axis.forEach((axis: any) => { - parsedProps = parseChildProps(axis, PLT_PARSERS); - axes.push( - new Axis({ - ...parsedProps, - fromOpi: false, - showGrid: parsedProps.grid, - onRight: parsedProps.right, - title: parsedProps.useAxisName ? parsedProps.name : "", - titleFont: parsedProps.labelFont, - minimum: parsedProps.min, - maximum: parsedProps.max - }) - ); - }); - } else { - parsedProps = parseChildProps(props.axis, PLT_PARSERS); + 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 : "", + title: parsedProps.useAxisName + ? parsedProps.name + : pvAxes[idx].join("\n"), titleFont: parsedProps.labelFont, minimum: parsedProps.min, maximum: parsedProps.max }) ); - } + }); } return axes; } @@ -211,8 +194,8 @@ export async function parsePlt( if (widgetType === "databrowser" && typeof file._text === "string") { const databrowser: XmlDescription = await fetchPltFile(file._text); // Parse the simple props - const pvlist = pltParsePvlist(databrowser["pvlist"]); - const axes = pltParseAxes(databrowser["axes"]); + const [pvlist, pvAxes] = pltParsePvlist(databrowser["pvlist"]); + const axes = pltParseAxes(databrowser["axes"], pvAxes); props = new Plt({ ...parseChildProps(databrowser, PLT_PARSERS), pvlist: pvlist, diff --git a/src/ui/widgets/StripChart/stripChart.test.tsx b/src/ui/widgets/StripChart/stripChart.test.tsx index 24fdaf61..6f3e6274 100644 --- a/src/ui/widgets/StripChart/stripChart.test.tsx +++ b/src/ui/widgets/StripChart/stripChart.test.tsx @@ -6,6 +6,7 @@ import { StripChartComponent } from "./stripChart"; import { Trace } from "../../../types/trace"; import { Axis } from "../../../types/axis"; import { convertStringTimePeriod } from "../utils"; +import { dtimeNow } from "../../../types/dtypes"; // Mock the MUI X-Charts components vi.mock("@mui/x-charts", () => ({ @@ -37,9 +38,7 @@ describe("StripChartComponent", () => { const defaultProps = { value: { getDoubleValue: () => 50, - getTime: () => { - new Date(Date.now()); - } + getTime: () => dtimeNow() } as Partial as DType, connected: true, readonly: true, @@ -81,10 +80,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 67da8d63..a088d5fc 100644 --- a/src/ui/widgets/StripChart/stripChart.tsx +++ b/src/ui/widgets/StripChart/stripChart.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef } from "react"; import { Widget } from "../widget"; import { PVComponent, PVWidgetPropType } from "../widgetProps"; @@ -10,7 +10,8 @@ import { StringPropOpt, TracesProp, AxesProp, - ArchivedDataPropOpt + ArchivedDataPropOpt, + IntPropOpt } from "../propTypes"; import { registerWidget } from "../register"; import { Box, Typography } from "@mui/material"; @@ -45,7 +46,9 @@ const StripChartProps = { showToolbar: BoolPropOpt, visible: BoolPropOpt, archivedData: ArchivedDataPropOpt, - archivedDataLoaded: BoolPropOpt + archivedDataLoaded: BoolPropOpt, + bufferSize: IntPropOpt, + updatePeriod: IntPropOpt }; // Needs to be exported for testing @@ -72,7 +75,9 @@ export const StripChartComponent = ( start = "1 minute", visible = true, archivedData = { x: [], y: [], min: undefined, max: undefined }, - archivedDataLoaded = false + archivedDataLoaded = false, + updatePeriod = 0, + bufferSize = 10000 } = props; // If we're passed an empty array fill in defaults if (traces.length < 1) traces.push(new Trace()); @@ -84,40 +89,80 @@ export const StripChartComponent = ( const data = useRef<{ x: Date[]; y: any[]; - dataLoaded?: boolean, + dataLoaded?: boolean; min?: Date; max?: Date; - }>({x: [], y: []}); + }>({ x: [], y: [] }); useEffect(() => { // Only update data once the archiveData has loaded - if (value) { + const time = value?.getTime(); + const val = value?.getDoubleValue(); + if (time && val) { // Remove data outside min and max bounds const minimum = new Date(new Date().getTime() - timePeriod); // Check if first data point in array is outside minimum, if so remove const xData = data.current.x; const yData = data.current.y; - if (xData.length > 0 && xData[0].getTime() < minimum.getTime()) { - xData.shift(); - yData.shift(); - } + if (xData.length === 0) { + // xData.push(value.getTime()!.datetime); + // yData.push(value.getDoubleValue()); data.current = { - x: [...xData, value.getTime()!.datetime], - y: [...yData, value.getDoubleValue()], + ...data.current, + x: [...xData, time.datetime], + y: [...yData, val], min: minimum, - max: new Date(), + max: new Date() + }; + } else { + // If first data point is outside bounds, is out of time series or data larger than buffer, remove + if ( + xData[0].getTime() < minimum.getTime() || + xData.length > bufferSize + ) { + xData.shift(); + yData.shift(); + } + // If value time after update period, update + if ( + time.datetime.getTime() - xData[xData.length - 1].getTime() >= + updatePeriod + ) { + // xData.push(value.getTime()!.datetime) + // yData.push(value.getDoubleValue()) + data.current = { + ...data.current, + x: [...xData, time.datetime], + y: [...yData, val], + min: minimum, + max: new Date() + }; + } else { + data.current = { + ...data.current, + x: [...xData], + y: [...yData], + min: minimum, + max: new Date() + }; } + } } - }, [value, timePeriod]); + }, [value, timePeriod, bufferSize, updatePeriod]); - useEffect(() => { + useEffect(() => { // Only update data once the archiveData has loaded + // This is never triggered for base striptool, but works for + // databrowser if (archivedDataLoaded) { const xData = data.current.x; const yData = data.current.y; - if (!data.current.dataLoaded) { - data.current.x = xData.concat(archivedData.x!) - data.current.y = yData.concat(archivedData.y) + if (!data.current.dataLoaded && archivedData.x) { + data.current = { + x: xData.concat(archivedData.x), + y: yData.concat(archivedData.y), + dataLoaded: true + }; } } }, [archivedData, archivedDataLoaded]); @@ -128,7 +173,7 @@ export const StripChartComponent = ( const yAxes: ReadonlyArray> = axes.map((item, idx) => { const axis = { - width: 45, + width: 55, id: `${idx}`, label: item.title, color: item.color?.toString(), @@ -155,8 +200,8 @@ export const StripChartComponent = ( : value, scaleType: item.logScale ? "symlog" : "linear", position: item.onRight ? "right" : "left", - min: item.autoscale ? undefined : item.minimum, - max: item.autoscale ? undefined : item.maximum + min: item.autoscale ? Math.min(...data.current.y) : item.minimum, + max: item.autoscale ? Math.max(...data.current.y) : item.maximum }; yAxesStyle[`.MuiChartsAxis-id-${idx}`] = { ".MuiChartsAxis-line": { diff --git a/src/ui/widgets/Tabs/tabContainer.test.tsx b/src/ui/widgets/Tabs/tabContainer.test.tsx index 3e86669e..afecb79d 100644 --- a/src/ui/widgets/Tabs/tabContainer.test.tsx +++ b/src/ui/widgets/Tabs/tabContainer.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import log from "loglevel"; -import { render, fireEvent } from "@testing-library/react"; +import { render, fireEvent, act } from "@testing-library/react"; import { TabContainerComponent } from "./tabContainer"; import { Provider } from "react-redux"; import { store } from "../../../redux/store"; @@ -9,36 +9,38 @@ import { ensureWidgetsRegistered } from ".."; ensureWidgetsRegistered(); describe("", (): 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/utils.ts b/src/ui/widgets/utils.ts index 65ea3653..ae77f61c 100644 --- a/src/ui/widgets/utils.ts +++ b/src/ui/widgets/utils.ts @@ -161,25 +161,30 @@ export function convertStringTimePeriod(period: string): number { } /** - * Trims down data collected from the archiver to reflect the + * 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; - let 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; - } - }) - // If dataset is still over buffersize, remove first difference - const sizeDiff = filteredData.length - bufferSize; - if (sizeDiff > 0) filteredData.splice(0, sizeDiff); - return filteredData; -} \ No newline at end of file +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; +} From b7232fe4ce8e65fa7467fdb685d3c551ea3eee6a Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Wed, 22 Oct 2025 15:18:51 +0100 Subject: [PATCH 06/10] Add support for multiple PVs to Databrowser --- .../widgets/DataBrowser/dataBrowser.test.tsx | 50 +++++-- src/ui/widgets/DataBrowser/dataBrowser.tsx | 96 +++++++------ src/ui/widgets/EmbeddedDisplay/parser.ts | 2 +- src/ui/widgets/StripChart/stripChart.tsx | 136 +++++++----------- src/ui/widgets/propTypes.ts | 10 +- 5 files changed, 150 insertions(+), 144 deletions(-) diff --git a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx index 6deb7906..a97f5dc9 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx @@ -6,6 +6,8 @@ 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 @@ -22,12 +24,13 @@ const globalWithFetch = global as GlobalFetch; // Mock the MUI X-Charts components vi.mock("@mui/x-charts", () => ({ - LineChart: vi.fn(({ series, xAxis, yAxis, sx }) => ( + LineChart: vi.fn(({ series, xAxis, yAxis, dataset, sx }) => (
)) @@ -42,16 +45,24 @@ vi.mock("@mui/material", () => ({ 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 = { - value: { - getDoubleValue: () => 50, - getTime: () => { - return { datetime: new Date(Date.now()) }; - } - } as Partial as DType, - connected: true, - readonly: true, - pvName: "TEST:PV", + pvData: [buildPvDatum("TEST:PV", 50)], plt: new Plt({ pvlist: [ new Trace({ @@ -66,6 +77,10 @@ describe("DataBrowserComponent", () => { }) }; + beforeEach(() => { + vi.clearAllMocks(); + }); + const mockSuccessResponse: any = JSON.stringify([ { secs: (new Date().getTime() - 250000) / 1000, @@ -81,7 +96,9 @@ describe("DataBrowserComponent", () => { } ]); const mockJsonPromise = Promise.resolve( - JSON.parse(`[{"data": ${mockSuccessResponse}}]`) + JSON.parse( + `[{"data": ${mockSuccessResponse}, "meta": { "name": "TEST:PV" }}]` + ) ); const mockFetchPromise = Promise.resolve({ json: (): Promise => mockJsonPromise @@ -135,6 +152,7 @@ describe("DataBrowserComponent", () => { 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 @@ -143,15 +161,25 @@ describe("DataBrowserComponent", () => { new Date(xAxisData[0].min).getTime(); expect(actualDiff).toBe(expectedDiff); + const series = JSON.parse(lineChart.getAttribute("data-series") ?? ""); 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(1); + expect(dataset[0]["TEST:PV"]).toBe(2); + expect(seriesData[0].color).toBe(Color.ORANGE.toString()); + expect(seriesData[0].dataKey).toBe("TEST:PV"); + expect(seriesData[0].data).toEqual([50, 52, 45, 60]); }); }); diff --git a/src/ui/widgets/DataBrowser/dataBrowser.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx index 637449d9..7430ada7 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -9,8 +9,9 @@ import { PvPropOpt } from "../propTypes"; import { registerWidget } from "../register"; -import { StripChartComponent } from "../StripChart/stripChart"; +import { StripChartComponent, TimeSeriesPoint } from "../StripChart/stripChart"; import { convertStringTimePeriod, trimArchiveData } from "../utils"; +import { PV } from "../../../types"; const DataBrowserProps = { plt: PltProp, @@ -29,52 +30,63 @@ export const DataBrowserComponent = ( props: DataBrowserComponentProps ): JSX.Element => { const { plt } = props; - const [data, setData] = useState<{ - x: Date[]; - y: any[]; - }>(); + const [data, setData] = useState([]); const [archiveDataLoaded, setArchiveDataLoaded] = useState(false); useEffect(() => { // Runs whenever pvlist updated and fetches archiver data const fetchArchivedPVData = async () => { - // TO DO - use when multiple PVs enabled - // plt.pvlist.forEach((trace) => { - // //Make call to getPvsData for multiple pvs - // }) - try { - // 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); - // TO DO - optimise request based on plt.request. Currently we optimise all requests - const archiverCall = `${plt.pvlist[0].archive?.url}/data/getData.json?pv=mean_${plt.updatePeriod}(${plt.pvlist[0].yPv})&from=${min.toISOString()}&to=${max.toISOString()}`; - const resp = await fetch(archiverCall); - const json = await resp.json(); - - // Filter data down by update period and buffer size - const trimmedData = trimArchiveData( - plt.updatePeriod, - plt.bufferSize, - json[0].data - ); - setData({ - x: trimmedData.map((item: any) => { - return new Date(item.secs * 1000); - }), - y: trimmedData.map((item: any) => { - return item.val; - }) - }); - setArchiveDataLoaded(true); - } catch (e) { - log.error( - `Failed to fetch archiver data for PV ${plt.pvlist[0].yPv} from ${plt.pvlist[0].archive?.url}.` - ); + // 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 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[] = []; + for (const url in Object.values(archivers)) { + try { + const resp = await fetch( + `${url}&from=${min.toISOString()}&to=${max.toISOString()}` + ); + const json = await resp.json(); + json.forEach((data: any, idx: number) => { + // 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 + ); + fetchedData = trimmedData.map((item: any, idx: number) => { + return { + ...fetchedData[idx], + dateTime: new Date(item.secs * 1000), + [pvName]: item.val + }; + }); + }); + } catch (e: any) { + log.error( + `Failed to fetch archiver data for PVs from address ${url}: ${e.error}.` + ); + } } + setArchiveDataLoaded(true); + setData(fetchedData); }; - // Only fetch onces + // Only fetch once if (!archiveDataLoaded) fetchArchivedPVData(); }, [archiveDataLoaded, plt]); @@ -82,9 +94,7 @@ export const DataBrowserComponent = ( ({ diff --git a/src/ui/widgets/StripChart/stripChart.tsx b/src/ui/widgets/StripChart/stripChart.tsx index 1a7b0537..e2f01a5e 100644 --- a/src/ui/widgets/StripChart/stripChart.tsx +++ b/src/ui/widgets/StripChart/stripChart.tsx @@ -57,7 +57,7 @@ export type StripChartComponentProps = InferWidgetProps< > & PVComponent; -interface TimeSeriesPoint { +export interface TimeSeriesPoint { dateTime: Date; [key: string]: Date | number | null; } @@ -79,7 +79,7 @@ export const StripChartComponent = ( backgroundColor = Color.fromRgba(255, 255, 255, 1), start = "1 minute", visible = true, - archivedData = { x: [], y: []}, + archivedData, archivedDataLoaded = false, updatePeriod = 0, bufferSize = 10000 @@ -90,7 +90,7 @@ export const StripChartComponent = ( () => (axes.length > 0 ? [...axes] : [new Axis({ xAxis: false })]), [axes] ); - // Convert start time into milliseconds period + // Convert start time into milliseconds period const timePeriod = useMemo(() => convertStringTimePeriod(start), [start]); const [dateRange, setDateRange] = useState<{ minX: Date; maxX: Date }>({ @@ -99,82 +99,20 @@ export const StripChartComponent = ( }); // 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 - // const time = value?.getTime(); - // const val = value?.getDoubleValue(); - // if (time && val) { - // // Remove data outside min and max bounds - // const minimum = new Date(new Date().getTime() - timePeriod); - // // Check if first data point in array is outside minimum, if so remove - // const xData = data.current.x; - // const yData = data.current.y; - // if (xData.length === 0) { - // // xData.push(value.getTime()!.datetime); - // // yData.push(value.getDoubleValue()); - // data.current = { - // ...data.current, - // x: [...xData, time.datetime], - // y: [...yData, val], - // min: minimum, - // max: new Date() - // }; - // } else { - // // If first data point is outside bounds, is out of time series or data larger than buffer, remove - // if ( - // xData[0].getTime() < minimum.getTime() || - // xData.length > bufferSize - // ) { - // xData.shift(); - // yData.shift(); - // } - // // If value time after update period, update - // if ( - // time.datetime.getTime() - xData[xData.length - 1].getTime() >= - // updatePeriod - // ) { - // // xData.push(value.getTime()!.datetime) - // // yData.push(value.getDoubleValue()) - // data.current = { - // ...data.current, - // x: [...xData, time.datetime], - // y: [...yData, val], - // min: minimum, - // max: new Date() - // }; - // } else { - // data.current = { - // ...data.current, - // x: [...xData], - // y: [...yData], - // min: minimum, - // max: new Date() - // }; - // } - // } - // } - // }, [value, timePeriod, bufferSize, updatePeriod]); - - // useEffect(() => { - // // Only update data once the archiveData has loaded - // // This is never triggered for base striptool, but works for - // // databrowser - // if (archivedDataLoaded) { - // const xData = data.current.x; - // const yData = data.current.y; - // if (!data.current.dataLoaded && archivedData.x) { - // data.current = { - // x: xData.concat(archivedData.x), - // y: yData.concat(archivedData.y), - // dataLoaded: true - // }; - // } - // } - // }, [archivedData, archivedDataLoaded]); - + 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) @@ -189,6 +127,16 @@ export const StripChartComponent = ( (a, b) => (a > b ? a : b), 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 ( @@ -198,8 +146,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 }; @@ -214,7 +169,7 @@ export const StripChartComponent = ( return [...truncatedTimeSeries, newTimeseriesPoint]; } - return timeSeries; + return timeSeries; }; setDateRange({ @@ -222,7 +177,7 @@ export const StripChartComponent = ( maxX: new Date() }); data.current = updateDataMap(data.current); - }, [timePeriod, pvData]); + }, [timePeriod, pvData, bufferSize, updatePeriod]); const { yAxes, yAxesStyle } = useMemo(() => { // For some reason the below styling doesn't change axis line and tick @@ -242,20 +197,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 }) diff --git a/src/ui/widgets/propTypes.ts b/src/ui/widgets/propTypes.ts index ebfaff1b..8a5533d0 100644 --- a/src/ui/widgets/propTypes.ts +++ b/src/ui/widgets/propTypes.ts @@ -196,9 +196,9 @@ export const ActionsPropType = PropTypes.shape({ actions: PropTypes.arrayOf(ActionPropType).isRequired }); -export const ArchivedDataPropOpt = PropTypes.shape({ - x: PropTypes.arrayOf(PropTypes.instanceOf(Date).isRequired), - y: PropTypes.arrayOf(FloatProp).isRequired, - min: PropTypes.instanceOf(Date), - max: PropTypes.instanceOf(Date) +export const TimeSeriesPointPropType = PropTypes.shape({ + dateTime: PropTypes.instanceOf(Date), + pv: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.number]) }); + +export const ArchivedDataPropOpt = PropTypes.arrayOf(TimeSeriesPointPropType); From 19c41d35bc9388426e1c3b20a932701acd27a6c8 Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Thu, 23 Oct 2025 08:55:10 +0100 Subject: [PATCH 07/10] Fix archiver data fetch --- src/ui/widgets/DataBrowser/dataBrowser.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/widgets/DataBrowser/dataBrowser.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx index 7430ada7..6f993c39 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -55,13 +55,13 @@ export const DataBrowserComponent = ( } }); let fetchedData: TimeSeriesPoint[] = []; - for (const url in Object.values(archivers)) { + for (const url of Object.values(archivers)) { try { const resp = await fetch( `${url}&from=${min.toISOString()}&to=${max.toISOString()}` ); const json = await resp.json(); - json.forEach((data: any, idx: number) => { + json.forEach((data: any) => { // Trim each dataset down and push into fetchedData const pvName = new PV(data.meta.name).qualifiedName(); const trimmedData = trimArchiveData( From 9939583f1f06a1625c32e73d0522704df0bf4bdf Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Thu, 23 Oct 2025 11:21:58 +0100 Subject: [PATCH 08/10] Fix function loop calling own property --- .../widgets/DataBrowser/dataBrowser.test.tsx | 48 ++++++++++++------- src/ui/widgets/DataBrowser/dataBrowser.tsx | 6 ++- src/ui/widgets/StripChart/stripChart.tsx | 3 +- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx index a97f5dc9..d4e1e653 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.test.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.test.tsx @@ -106,13 +106,11 @@ describe("DataBrowserComponent", () => { const mockFetch = (): Promise => mockFetchPromise; vi.spyOn(globalWithFetch, "fetch").mockImplementation(mockFetch); - beforeEach(() => { - vi.clearAllMocks(); - }); - describe("Rendering", () => { - test("renders with default props", () => { - render(); + test("renders with default props", async () => { + await act(async () => { + render(); + }); const lineChart = screen.getByTestId("line-chart"); expect(lineChart).toBeDefined(); @@ -123,7 +121,7 @@ describe("DataBrowserComponent", () => { expect(xAxisData[0].scaleType).toBe("time"); }); - test("renders with 1 y axis on either side", () => { + test("renders with 1 y axis on either side", async () => { const axes = [ new Axis({ color: Color.RED }), new Axis({ color: Color.BLUE, onRight: true }) @@ -132,7 +130,9 @@ describe("DataBrowserComponent", () => { ...defaultProps, plt: new Plt({ axes: axes }) }; - render(); + await act(async () => { + render(); + }); const lineChart = screen.getByTestId("line-chart"); const yAxisData = JSON.parse(lineChart.getAttribute("data-yaxis") ?? ""); @@ -145,7 +145,20 @@ describe("DataBrowserComponent", () => { test("renders with 5 minute archived data", async () => { const newProps = { ...defaultProps, - plt: new Plt({ start: "5 min", end: "now" }) + 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 () => { @@ -161,10 +174,15 @@ describe("DataBrowserComponent", () => { new Date(xAxisData[0].min).getTime(); expect(actualDiff).toBe(expectedDiff); - const series = JSON.parse(lineChart.getAttribute("data-series") ?? ""); + // Send a new value to append to archived data await act(async () => { - rerender(); + rerender( + + ); }); const newLineChart = screen.getByTestId("line-chart"); expect(newLineChart).toBeDefined(); @@ -175,12 +193,10 @@ describe("DataBrowserComponent", () => { const dataset = JSON.parse( newLineChart.getAttribute("data-dataset") ?? "" ); - expect(dataset.length).toBe(1); - expect(dataset[0]["TEST:PV"]).toBe(2); - expect(seriesData[0].color).toBe(Color.ORANGE.toString()); - expect(seriesData[0].dataKey).toBe("TEST:PV"); - expect(seriesData[0].data).toEqual([50, 52, 45, 60]); + 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 index 6f993c39..3c8c481d 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -56,6 +56,7 @@ export const DataBrowserComponent = ( }); let fetchedData: TimeSeriesPoint[] = []; for (const url of Object.values(archivers)) { + let tmpData: any[] = []; try { const resp = await fetch( `${url}&from=${min.toISOString()}&to=${max.toISOString()}` @@ -69,14 +70,15 @@ export const DataBrowserComponent = ( plt.bufferSize, data.data ); - fetchedData = trimmedData.map((item: any, idx: number) => { + tmpData = trimmedData.map((item: any, idx: number) => { return { - ...fetchedData[idx], + ...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}.` diff --git a/src/ui/widgets/StripChart/stripChart.tsx b/src/ui/widgets/StripChart/stripChart.tsx index e2f01a5e..dcceacbf 100644 --- a/src/ui/widgets/StripChart/stripChart.tsx +++ b/src/ui/widgets/StripChart/stripChart.tsx @@ -79,7 +79,7 @@ export const StripChartComponent = ( backgroundColor = Color.fromRgba(255, 255, 255, 1), start = "1 minute", visible = true, - archivedData, + archivedData = [], archivedDataLoaded = false, updatePeriod = 0, bufferSize = 10000 @@ -110,6 +110,7 @@ export const StripChartComponent = ( dataLoaded.current = true; } }, [archivedData, archivedDataLoaded]); + useEffect(() => { const updateDataMap = (timeSeries: TimeSeriesPoint[]) => { // Add check for update period here From 7c7fdf450f866ac24d18cba3e90f5ef94995bb91 Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Thu, 23 Oct 2025 16:17:54 +0100 Subject: [PATCH 09/10] Prevent overwriting of archiver data per achiver --- src/ui/widgets/DataBrowser/dataBrowser.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/widgets/DataBrowser/dataBrowser.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx index 3c8c481d..bcb13499 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -56,7 +56,7 @@ export const DataBrowserComponent = ( }); let fetchedData: TimeSeriesPoint[] = []; for (const url of Object.values(archivers)) { - let tmpData: any[] = []; + let tmpData: any[] = fetchedData; try { const resp = await fetch( `${url}&from=${min.toISOString()}&to=${max.toISOString()}` From b3a516a6b2f540e607155f5615e3cbc4bd65819d Mon Sep 17 00:00:00 2001 From: Abigail Alexander Date: Mon, 27 Oct 2025 09:54:27 +0000 Subject: [PATCH 10/10] Move archive fetch into custom hook --- src/ui/hooks/useArchivedData.test.tsx | 85 ++++++++++++++++++++++ src/ui/hooks/useArchivedData.tsx | 76 +++++++++++++++++++ src/ui/widgets/DataBrowser/dataBrowser.tsx | 72 ++---------------- 3 files changed, 166 insertions(+), 67 deletions(-) create mode 100644 src/ui/hooks/useArchivedData.test.tsx create mode 100644 src/ui/hooks/useArchivedData.tsx 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.tsx b/src/ui/widgets/DataBrowser/dataBrowser.tsx index bcb13499..dc481572 100644 --- a/src/ui/widgets/DataBrowser/dataBrowser.tsx +++ b/src/ui/widgets/DataBrowser/dataBrowser.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useState } from "react"; -import log from "loglevel"; +import React from "react"; import { Widget } from "../widget"; import { PVComponent, PVWidgetPropType } from "../widgetProps"; import { @@ -9,9 +8,8 @@ import { PvPropOpt } from "../propTypes"; import { registerWidget } from "../register"; -import { StripChartComponent, TimeSeriesPoint } from "../StripChart/stripChart"; -import { convertStringTimePeriod, trimArchiveData } from "../utils"; -import { PV } from "../../../types"; +import { StripChartComponent } from "../StripChart/stripChart"; +import { useArchivedData } from "../../hooks/useArchivedData"; const DataBrowserProps = { plt: PltProp, @@ -30,67 +28,7 @@ export const DataBrowserComponent = ( props: DataBrowserComponentProps ): JSX.Element => { const { plt } = props; - 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 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[] = []; - for (const url of Object.values(archivers)) { - let tmpData: any[] = fetchedData; - try { - const resp = await fetch( - `${url}&from=${min.toISOString()}&to=${max.toISOString()}` - ); - 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]); + const [data, dataLoaded] = useArchivedData(plt); return (