Skip to content

Commit e765742

Browse files
committed
Adding meter component using react-gauge-component. #125
1 parent 5dcdc8d commit e765742

File tree

9 files changed

+1776
-15
lines changed

9 files changed

+1776
-15
lines changed

package-lock.json

Lines changed: 805 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"jsdom": "^25.0.1",
6060
"loglevel": "^1.9.2",
6161
"prettier": "^3.3.3",
62+
"react-gauge-component": "^1.2.64",
6263
"react-id-generator": "^3.0.2",
6364
"react-test-renderer": "^18.3.1",
6465
"react-tiny-popover": "^8.1.2",

src/ui/widgets/EmbeddedDisplay/bobParser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const BOB_WIDGET_MAPPING: { [key: string]: any } = {
5656
rectangle: "shape",
5757
tank: "tank",
5858
thermometer: "thermometer",
59+
meter: "meter",
5960
choice: "choicebutton",
6061
scaledslider: "slidecontrol",
6162
symbol: "symbol"
@@ -85,6 +86,7 @@ export const WIDGET_DEFAULT_SIZES: { [key: string]: [number, number] } = {
8586
rectangle: [100, 20],
8687
tank: [150, 200],
8788
thermometer: [40, 160],
89+
meter: [240, 120],
8890
scaledslider: [400, 55],
8991
symbol: [100, 100]
9092
};
@@ -381,6 +383,7 @@ export function parseBob(
381383
symbols: ["symbols", bobParseSymbols],
382384
initialIndex: ["initial_index", bobParseNumber],
383385
showIndex: ["show_index", opiParseBoolean],
386+
showValue: ["show_value", opiParseBoolean],
384387
fallbackSymbol: ["fallback_symbol", opiParseString],
385388
rotation: ["rotation", bobParseNumber],
386389
styleOpt: ["style", bobParseNumber],
@@ -401,7 +404,9 @@ export function parseBob(
401404
majorTickStepHint: ["major_tick_step_hint", bobParseNumber],
402405
maximum: ["maximum", bobParseNumber],
403406
minimum: ["minimum", bobParseNumber],
404-
emptyColor: ["empty_color", opiParseColor]
407+
format: ["format", bobParseNumber],
408+
emptyColor: ["empty_color", opiParseColor],
409+
needleColor: ["needle_color", opiParseColor]
405410
};
406411

407412
const complexParsers = {

src/ui/widgets/EmbeddedDisplay/opiParser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const OPI_WIDGET_MAPPING: { [key: string]: any } = {
7373
"org.csstudio.opibuilder.widgets.progressbar": "progressbar",
7474
"org.csstudio.opibuilder.widgets.tank": "tank",
7575
"org.csstudio.opibuilder.widgets.thermometer": "thermometer",
76+
"org.csstudio.opibuilder.widgets.tmeter": "meter",
7677
"org.csstudio.opibuilder.widgets.LED": "led",
7778
"org.csstudio.opibuilder.widgets.Image": "image",
7879
"org.csstudio.opibuilder.widgets.edm.symbolwidget": "pngsymbol",
@@ -642,11 +643,13 @@ export const OPI_SIMPLE_PARSERS: ParserDict = {
642643
onColor: ["on_color", opiParseColor],
643644
offColor: ["off_color", opiParseColor],
644645
fillColor: ["fill_color", opiParseColor],
646+
needleColor: ["needle_color", opiParseColor],
645647
precision: ["precision", opiParseNumber],
646648
formatType: ["format_type", opiParseFormatType],
647649
precisionFromPv: ["precision_from_pv", opiParseBoolean],
648650
visible: ["visible", opiParseBoolean],
649651
showUnits: ["show_units", opiParseBoolean],
652+
showValue: ["show_value_label", opiParseBoolean],
650653
scaleVisible: ["scale_visible", opiParseBoolean],
651654
transparent: ["transparent", opiParseBoolean],
652655
horizontal: ["horizontal", opiParseBoolean],
@@ -713,7 +716,9 @@ export const OPI_SIMPLE_PARSERS: ParserDict = {
713716
selectedColor: ["selected_color", opiParseColor],
714717
enabled: ["enabled", opiParseBoolean],
715718
resize: ["resize_behaviour", opiParseResizing],
716-
labelsFromPv: ["labels_from_pv", opiParseBoolean]
719+
labelsFromPv: ["labels_from_pv", opiParseBoolean],
720+
limitsFromPv: ["limits_from_pv", opiParseBoolean],
721+
format: ["format_type", opiParseNumber]
717722
};
718723

719724
/**
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import React from "react";
3+
import { render, screen } from "@testing-library/react";
4+
import { MeterComponent } from "./meter";
5+
import { Color } from "../../../types/color";
6+
import { NumberFormatEnum } from "./meterUtilities";
7+
import * as meterUtilities from "./meterUtilities";
8+
import { DType, Font } from "../../../types";
9+
10+
vi.mock("react-gauge-component", () => ({
11+
GaugeComponent: vi.fn(
12+
({ value, minValue, maxValue, pointer, arc, labels }) => (
13+
<div
14+
data-testid="gauge-component"
15+
data-value={value}
16+
data-min={minValue}
17+
data-max={maxValue}
18+
>
19+
<div data-testid="pointer" data-color={pointer.color}></div>
20+
<div data-testid="arc" data-subarcs={JSON.stringify(arc.subArcs)}></div>
21+
<div
22+
data-testid="value-label"
23+
data-hide={labels.valueLabel.hide}
24+
data-fontFamily={labels.valueLabel.style.fontFamily}
25+
></div>
26+
</div>
27+
)
28+
)
29+
}));
30+
31+
vi.mock("./meterUtilities", async () => {
32+
const actual = await vi.importActual("./meterUtilities");
33+
return {
34+
...actual,
35+
formatValue: vi.fn(
36+
(value, format, precision, units, showUnits) => () => `${value}`
37+
),
38+
buildSubArcs: vi.fn(() => [{ color: "red", start: 0, end: 100 }]),
39+
createIntervals: vi.fn(() => [0, 25, 50, 75, 100]),
40+
convertInfAndNanToUndefined: vi.fn(val => val)
41+
};
42+
});
43+
44+
describe("MeterComponent", () => {
45+
const defaultProps = {
46+
connected: false,
47+
readonly: true,
48+
pvName: "PV:Test",
49+
value: {
50+
getDoubleValue: () => 50,
51+
display: {
52+
units: "kW",
53+
controlRange: { min: 0, max: 100 },
54+
alarmRange: { min: 80, max: 100 },
55+
warningRange: { min: 60, max: 80 }
56+
}
57+
} as Partial<DType> as DType,
58+
foregroundColor: Color.fromRgba(0, 0, 0, 1),
59+
needleColor: Color.fromRgba(255, 5, 7, 1),
60+
backgroundColor: Color.fromRgba(250, 250, 250, 1)
61+
};
62+
63+
beforeEach(() => {
64+
vi.clearAllMocks();
65+
});
66+
67+
it("renders with default props", () => {
68+
render(<MeterComponent {...defaultProps} />);
69+
70+
const gaugeComponent = screen.getByTestId("gauge-component");
71+
expect(gaugeComponent).toBeInTheDocument();
72+
expect(gaugeComponent.getAttribute("data-value")).toBe("50");
73+
expect(gaugeComponent.getAttribute("data-min")).toBe("0");
74+
expect(gaugeComponent.getAttribute("data-max")).toBe("100");
75+
});
76+
77+
it("uses custom min/max values when limitsFromPv is false", () => {
78+
render(
79+
<MeterComponent
80+
{...defaultProps}
81+
minimum={-50}
82+
maximum={150}
83+
limitsFromPv={false}
84+
/>
85+
);
86+
87+
const gaugeComponent = screen.getByTestId("gauge-component");
88+
expect(gaugeComponent.getAttribute("data-min")).toBe("-50");
89+
expect(gaugeComponent.getAttribute("data-max")).toBe("150");
90+
});
91+
92+
it("uses PV limits when limitsFromPv is true", () => {
93+
render(
94+
<MeterComponent
95+
{...defaultProps}
96+
minimum={-50}
97+
maximum={150}
98+
limitsFromPv={true}
99+
/>
100+
);
101+
102+
const gaugeComponent = screen.getByTestId("gauge-component");
103+
expect(gaugeComponent.getAttribute("data-min")).toBe("0"); // From controlRange.min
104+
expect(gaugeComponent.getAttribute("data-max")).toBe("100"); // From controlRange.max
105+
});
106+
107+
it("uses transparent background when transparent is true", () => {
108+
render(<MeterComponent {...defaultProps} transparent={true} />);
109+
110+
const box = screen.getByTestId("gauge-component").parentElement;
111+
expect(box).toHaveStyle("background-color: rgba(0, 0, 0, 0)");
112+
});
113+
114+
it("hides value when showValue is false", () => {
115+
render(<MeterComponent {...defaultProps} showValue={false} />);
116+
117+
const valueLabel = screen.getByTestId("value-label");
118+
expect(valueLabel.getAttribute("data-hide")).toBe("true");
119+
});
120+
121+
it("calls formatValue with correct parameters", () => {
122+
render(
123+
<MeterComponent
124+
{...defaultProps}
125+
format={NumberFormatEnum.Exponential}
126+
precision={2}
127+
showUnits={true}
128+
/>
129+
);
130+
131+
expect(meterUtilities.formatValue).toHaveBeenCalledWith(
132+
50,
133+
NumberFormatEnum.Exponential,
134+
2,
135+
"kW",
136+
true
137+
);
138+
});
139+
140+
it("calls buildSubArcs with correct parameters", () => {
141+
render(<MeterComponent {...defaultProps} />);
142+
143+
expect(meterUtilities.buildSubArcs).toHaveBeenCalledWith(
144+
"rgba(0,0,0,1)",
145+
0,
146+
100,
147+
80,
148+
60,
149+
80,
150+
100
151+
);
152+
});
153+
154+
it("handles missing PV value gracefully", () => {
155+
render(<MeterComponent {...defaultProps} value={undefined} />);
156+
157+
const gaugeComponent = screen.getByTestId("gauge-component");
158+
expect(gaugeComponent.getAttribute("data-value")).toBe("0");
159+
});
160+
161+
it("scales width correctly based on height/width ratio", () => {
162+
render(<MeterComponent {...defaultProps} height={100} width={300} />);
163+
164+
const box = screen.getByTestId("gauge-component").parentElement;
165+
expect(box).toHaveStyle("width: 190px"); // 1.9 * height
166+
});
167+
168+
it("uses default width when width/height ratio is less than 1.9", () => {
169+
render(<MeterComponent {...defaultProps} height={100} width={150} />);
170+
171+
const box = screen.getByTestId("gauge-component").parentElement;
172+
expect(box).toHaveStyle("width: 150px"); // original width
173+
});
174+
175+
it("applies font styles correctly", () => {
176+
const font = {
177+
css: () => ({ fontFamily: "Arial" })
178+
} as Partial<Font> as Font;
179+
180+
render(<MeterComponent {...defaultProps} font={font} />);
181+
182+
const valueLabel = screen.getByTestId("value-label");
183+
184+
const labelStyle = valueLabel.getAttribute("data-fontFamily");
185+
expect(labelStyle).toBe("Arial");
186+
});
187+
});

0 commit comments

Comments
 (0)