From 90a0790033c7e7a6e1060c6b91f8480075eb3481 Mon Sep 17 00:00:00 2001 From: db <77755322+diehbria@users.noreply.github.com> Date: Thu, 16 Feb 2023 09:37:58 -0800 Subject: [PATCH] chore(dial): port over DialBase from SynchroCharts (#590) --- package-lock.json | 36 ++++- packages/react-components/jest.config.ts | 2 + packages/react-components/package.json | 3 +- .../react-components/src/common/iconUtils.tsx | 13 +- .../src/components/dial/constants.ts | 21 +++ .../src/components/dial/dial.tsx | 49 +++++++ .../src/components/dial/dialBase.spec.tsx | 127 ++++++++++++++++++ .../src/components/dial/dialBase.tsx | 45 +++++++ .../src/components/dial/dialSvg.tsx | 85 ++++++++++++ .../src/components/dial/types.ts | 16 +++ .../src/components/dial/useArcs.ts | 54 ++++++++ .../stories/dial/dial.stories.tsx | 56 ++++++++ .../stories/dial/dialBase.stories.tsx | 25 ++++ 13 files changed, 525 insertions(+), 7 deletions(-) create mode 100644 packages/react-components/src/components/dial/constants.ts create mode 100644 packages/react-components/src/components/dial/dial.tsx create mode 100644 packages/react-components/src/components/dial/dialBase.spec.tsx create mode 100644 packages/react-components/src/components/dial/dialBase.tsx create mode 100644 packages/react-components/src/components/dial/dialSvg.tsx create mode 100644 packages/react-components/src/components/dial/types.ts create mode 100644 packages/react-components/src/components/dial/useArcs.ts create mode 100644 packages/react-components/stories/dial/dial.stories.tsx create mode 100644 packages/react-components/stories/dial/dialBase.stories.tsx diff --git a/package-lock.json b/package-lock.json index 9b19666a9..deccb635d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76193,6 +76193,7 @@ "@iot-app-kit/core": "*", "@iot-app-kit/source-iottwinmaker": "*", "color": "^4.2.3", + "d3-shape": "^3.2.0", "dompurify": "2.3.4", "parse-duration": "^1.0.2", "uuid": "^8.3.2", @@ -79479,6 +79480,14 @@ "node": ">=12" } }, + "packages/react-components/node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, "packages/react-components/node_modules/d3-scale": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", @@ -79496,6 +79505,17 @@ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" }, + "packages/react-components/node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, "packages/react-components/node_modules/d3-time": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", @@ -98293,7 +98313,7 @@ "sass": "^1.57.1", "sass-loader": "10.1.1", "style-loader": "2.0.0", - "tsconfig-paths-webpack-plugin": "*", + "tsconfig-paths-webpack-plugin": "^4.0.0", "tslib": "^2.4.0", "typescript": "^4.8.3", "webpack": "^5.75.0" @@ -103021,6 +103041,7 @@ "d3-array": "^2.3.2", "d3-scale": "^3.2.0", "d3-selection": "^1.3.1", + "d3-shape": "^3.2.0", "dompurify": "2.3.4", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.6", @@ -105627,6 +105648,11 @@ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==" }, + "d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, "d3-scale": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", @@ -105646,6 +105672,14 @@ } } }, + "d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "requires": { + "d3-path": "^3.1.0" + } + }, "d3-time": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", diff --git a/packages/react-components/jest.config.ts b/packages/react-components/jest.config.ts index f48f90093..39df743fe 100644 --- a/packages/react-components/jest.config.ts +++ b/packages/react-components/jest.config.ts @@ -2,6 +2,7 @@ * For a detailed explanation regarding each configuration property and type check, visit: * https://jestjs.io/docs/configuration */ + export default { preset: 'ts-jest', clearMocks: true, @@ -18,6 +19,7 @@ export default { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleNameMapper: { '\\.(css|less)$': '/config/jest/styleMock.js', + 'd3-shape': '/../../node_modules/d3-shape/dist/d3-shape.min.js', }, testEnvironment: 'jsdom', testPathIgnorePatterns: ['node_modules', 'dist', 'src/stencil-generated'], diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 3619aae9c..f795c749d 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -106,10 +106,11 @@ }, "dependencies": { "@awsui/components-react": "^3.0.0", - "@iot-app-kit/core": "*", "@iot-app-kit/components": "*", + "@iot-app-kit/core": "*", "@iot-app-kit/source-iottwinmaker": "*", "color": "^4.2.3", + "d3-shape": "^3.2.0", "dompurify": "2.3.4", "parse-duration": "^1.0.2", "uuid": "^8.3.2", diff --git a/packages/react-components/src/common/iconUtils.tsx b/packages/react-components/src/common/iconUtils.tsx index 412fc80b2..6506e90b3 100644 --- a/packages/react-components/src/common/iconUtils.tsx +++ b/packages/react-components/src/common/iconUtils.tsx @@ -1,11 +1,9 @@ -/* eslint-disable max-len */ import React from 'react'; import { StatusIconType } from './constants'; -interface Icons { - // eslint-disable-next-line @typescript-eslint/ban-types - [statusIcon: string]: Function; -} +type Icons = { + [statusIcon: string]: (color?: string, size?: number) => JSX.Element; +}; const DEFAULT_SIZE_PX = 16; @@ -35,6 +33,7 @@ export const icons: Icons = { ); }, + acknowledged(color?: string, size: number = DEFAULT_SIZE_PX) { return ( @@ -55,6 +54,7 @@ export const icons: Icons = { ); }, + disabled(color?: string, size: number = DEFAULT_SIZE_PX) { return ( @@ -65,6 +65,7 @@ export const icons: Icons = { ); }, + latched(color?: string, size: number = DEFAULT_SIZE_PX) { return ( @@ -73,6 +74,7 @@ export const icons: Icons = { ); }, + snoozed(color?: string, size: number = DEFAULT_SIZE_PX) { return ( @@ -83,6 +85,7 @@ export const icons: Icons = { ); }, + error(color?: string, size: number = DEFAULT_SIZE_PX) { return ( diff --git a/packages/react-components/src/components/dial/constants.ts b/packages/react-components/src/components/dial/constants.ts new file mode 100644 index 000000000..a3e1c0a8b --- /dev/null +++ b/packages/react-components/src/components/dial/constants.ts @@ -0,0 +1,21 @@ +import { DialSettings } from './types'; + +export enum ColorConfigurations { + BLUE = '#2E72B5', + NORMAL = '#3F7E23', + WARNING = '#F29D38', + CRITICAL = '#C03F25', + GRAY = '#D9D9D9', + PRIMARY_TEXT = '#16191f', + SECONDARY_TEXT = '#687078', + WHITE = '#fff', +} + +export const DEFAULT_DIAL_SETTINGS: DialSettings = { + showName: true, + showUnit: true, + dialThickness: 34, + fontSize: 48, + unitFontSize: 24, + labelFontSize: 24, +}; diff --git a/packages/react-components/src/components/dial/dial.tsx b/packages/react-components/src/components/dial/dial.tsx new file mode 100644 index 000000000..0a51085ab --- /dev/null +++ b/packages/react-components/src/components/dial/dial.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { DialBase } from './dialBase'; +import { TimeQuery, TimeSeriesData, TimeSeriesDataRequest, StyleSettingsMap, Viewport } from '@iot-app-kit/core'; +import { useTimeSeriesData } from '../../hooks/useTimeSeriesData'; +import { widgetPropertiesFromInputs } from '../../common/widgetPropertiesFromInputs'; +import { Annotations } from '../../common/thresholdTypes'; +import { DialSettings } from './types'; +import { useViewport } from '../../hooks/useViewport'; + +export const Dial = ({ + query, + viewport: passedInViewport, + annotations, + styles, + settings, +}: { + query: TimeQuery; + viewport?: Viewport; + annotations?: Annotations; + styles?: StyleSettingsMap; + settings?: DialSettings; +}) => { + const { dataStreams } = useTimeSeriesData({ + viewport: passedInViewport, + query, + settings: { fetchMostRecentBeforeEnd: true }, + styles, + }); + const { viewport } = useViewport(); + + const utilizedViewport = passedInViewport || viewport; // explicitly passed in viewport overrides viewport group + const { propertyPoint, alarmPoint, alarmThreshold, propertyThreshold, alarmStream, propertyStream } = + widgetPropertiesFromInputs({ dataStreams, annotations, viewport: utilizedViewport }); + + const name = propertyStream?.name || alarmStream?.name; + const unit = propertyStream?.unit || alarmStream?.unit; + const color = alarmThreshold?.color || propertyThreshold?.color || propertyStream?.color; + + return ( + + ); +}; diff --git a/packages/react-components/src/components/dial/dialBase.spec.tsx b/packages/react-components/src/components/dial/dialBase.spec.tsx new file mode 100644 index 000000000..1cd46df35 --- /dev/null +++ b/packages/react-components/src/components/dial/dialBase.spec.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DialBase } from './dialBase'; +import { DataPoint } from '../../common/dataTypes'; + +describe('name', () => { + it('renders name when showName is true', () => { + const someName = 'some-name'; + render(); + + expect(screen.queryByText(someName)).not.toBeNull(); + }); + + it('does not render name when showName is false', () => { + const someName = 'some-name'; + render(); + + expect(screen.queryByText(someName)).toBeNull(); + }); +}); + +describe('unit', () => { + const point: DataPoint = { x: 1213, y: 123 }; + + it('renders unit when showUnit is true and provided a property point', () => { + const someUnit = 'some-Unit'; + render(); + + expect(screen.queryByText(someUnit)).not.toBeNull(); + }); + + it('renders unit when showUnit is true and provided a alarm point', () => { + const someUnit = 'some-Unit'; + render(); + + expect(screen.queryByText(someUnit)).not.toBeNull(); + }); + + it('does not render unit when showUnit is true and is not provided a data point', () => { + const someUnit = 'some-Unit'; + render(); + + expect(screen.queryByText(someUnit)).toBeNull(); + }); + + it('does not render unit when showUnit is false', () => { + const someUnit = 'some-Unit'; + render(); + + expect(screen.queryByText(someUnit)).toBeNull(); + }); +}); + +describe('property value', () => { + it('renders property points y value', () => { + const Y_VALUE = 123445; + render(); + expect(screen.queryByText(Y_VALUE)).not.toBeNull(); + }); + + it('renders alarm points y value', () => { + const Y_VALUE = 123445; + render(); + expect(screen.queryByText(Y_VALUE)).not.toBeNull(); + }); + + it('renders property points y value and alarm points y value when provided both an alarm and a property', () => { + const Y_VALUE_PROPERTY = 123445; + const Y_VALUE_ALARM = 'alarm_value'; + render( + + ); + + expect(screen.queryByText(Y_VALUE_PROPERTY)).not.toBeNull(); + expect(screen.queryByText(Y_VALUE_ALARM)).not.toBeNull(); + }); +}); + +describe('error', () => { + it('renders error', () => { + const someError = 'some-error'; + render(); + + expect(screen.queryByText(someError)).not.toBeNull(); + }); +}); + +describe('loading', () => { + it('renders loading spinner while isLoading is true', () => { + render(); + + expect(screen.queryByTestId('loading')).not.toBeNull(); + }); + + it('does not render loading spinner while isLoading is false', () => { + render(); + + expect(screen.queryByTestId('loading')).toBeNull(); + }); + + it('renders error while loading', () => { + const someError = 'some-error'; + render(); + + expect(screen.queryByText(someError)).not.toBeNull(); + }); + + it('does not render data point while isLoading is true', () => { + const point = { x: 12341, y: 123213 }; + render(); + + expect(screen.queryByText(point.y)).toBeNull(); + }); + + it('does not render alarm or property data point while isLoading is true', () => { + const point = { x: 12341, y: 123213 }; + const alarmPoint = { x: 12341, y: 'warning' }; + render(); + + expect(screen.queryByText(point.y)).toBeNull(); + expect(screen.queryByText(alarmPoint.y)).toBeNull(); + }); +}); diff --git a/packages/react-components/src/components/dial/dialBase.tsx b/packages/react-components/src/components/dial/dialBase.tsx new file mode 100644 index 000000000..013d104f0 --- /dev/null +++ b/packages/react-components/src/components/dial/dialBase.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { DialProperties } from './types'; +import { ErrorBadge } from '../shared-components'; +import { DialSvg } from './dialSvg'; +import { DEFAULT_DIAL_SETTINGS } from './constants'; + +export const DialBase: React.FC = ({ + propertyPoint, + alarmPoint, + error, + unit, + name, + isLoading, + color, + settings = {}, +}) => { + const dialSettings = { + ...DEFAULT_DIAL_SETTINGS, + ...settings, + }; + + const { yMin, yMax, showName, showUnit } = dialSettings; + + // Primary point to display. Dial Emphasizes the property point over the alarm point. + const point = propertyPoint || alarmPoint; + const value = point?.y; + const label = (propertyPoint != null && alarmPoint != null && alarmPoint.y) || undefined; + const percent = + yMin != null && yMax != null && typeof value === 'number' ? (value - yMin) / (yMax / yMin) : undefined; + return ( +
+ {showName && name} + + {error && {error}} +
+ ); +}; diff --git a/packages/react-components/src/components/dial/dialSvg.tsx b/packages/react-components/src/components/dial/dialSvg.tsx new file mode 100644 index 000000000..35295eeee --- /dev/null +++ b/packages/react-components/src/components/dial/dialSvg.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { DialSettings } from './types'; +import { ColorConfigurations } from './constants'; +import { useArcs } from './useArcs'; + +const NO_VALUE_PRESENT = '-'; + +const RADIUS = 138; +const UNIT_SPACE = 4; + +export const DialSvg = ({ + percent, + value, + color, + label, + unit, + isLoading, + settings, +}: { + percent: number; + isLoading: boolean; + color: string; // hex color string + value?: string; + label?: string; + unit?: string; + settings?: DialSettings; +}) => { + const { defaultRing, colorRing } = useArcs({ percent, lineThickness: settings.dialThickness, radius: RADIUS }); + const displayLabel = label != null && label != '' && !isLoading; + const displayValue = !isLoading && value != null && value !== ''; + + const valueLength = (value != null ? value.length : 0) + (unit != null ? unit.length : 0); + const valueFontSize = + value != null + ? Math.min(settings.fontSize, Math.floor((2 * (RADIUS - settings.dialThickness)) / valueLength) * 1.6) + : settings.fontSize; + return ( + + + + + + {displayValue && ( + + + {value} + {unit && ( + + {unit} + + )} + + + )} + {isLoading && ( + + Loading + + )} + {!displayValue && !isLoading && ( + + {NO_VALUE_PRESENT} + + )} + {displayLabel && ( + + {label} + + )} + + ); +}; diff --git a/packages/react-components/src/components/dial/types.ts b/packages/react-components/src/components/dial/types.ts new file mode 100644 index 000000000..1f57ce5c0 --- /dev/null +++ b/packages/react-components/src/components/dial/types.ts @@ -0,0 +1,16 @@ +import { WidgetSettings } from '../../common/dataTypes'; + +export type DialProperties = WidgetSettings & { + settings: DialSettings; +}; + +export type DialSettings = { + dialThickness?: number; + showName?: boolean; + showUnit?: boolean; + fontSize?: number; // pixels + labelFontSize?: number; // pixels + unitFontSize?: number; // pixels + yMin?: number; + yMax?: number; +}; diff --git a/packages/react-components/src/components/dial/useArcs.ts b/packages/react-components/src/components/dial/useArcs.ts new file mode 100644 index 000000000..6453f3801 --- /dev/null +++ b/packages/react-components/src/components/dial/useArcs.ts @@ -0,0 +1,54 @@ +import { DefaultArcObject, arc } from 'd3-shape'; +import { useEffect, useState } from 'react'; + +const RADIAN_FULL_CIRCLE = Math.PI * 2; +const RADIAN = RADIAN_FULL_CIRCLE / 360; +const CORNER_RADIUS = 4; + +const getArcs = (percent: number) => { + const flooredPercent = Math.max(0, percent); + const startAngle1 = RADIAN_FULL_CIRCLE * flooredPercent; + const startAngle2 = RADIAN_FULL_CIRCLE * (1 - flooredPercent); + const endAngle1 = startAngle1; + const endAngle2 = endAngle1 + startAngle2; + + // Arc representing the value (i.e. if percent is 25%, this would be the arc going a 25% of the circle + const valueArc = arc().cornerRadius(CORNER_RADIUS).startAngle(0).endAngle(endAngle1); + // The remainder of the arc not representing the value + const remainingArc = arc().cornerRadius(CORNER_RADIUS).startAngle(endAngle2).endAngle(endAngle1); + + return { + valueArc, + remainingArc, + }; +}; + +export const useArcs = ({ + percent, + radius, + lineThickness, +}: { + percent: number; + radius: number; + lineThickness: number; +}) => { + const [colorRing, setColorRing] = useState(''); + const [defaultRing, setDefaultRing] = useState(''); + + /** Compute Arc SVGs */ + useEffect(() => { + const { valueArc, remainingArc } = getArcs(percent || 0); + const ringD: DefaultArcObject = { + innerRadius: radius, + outerRadius: radius - lineThickness, + padAngle: RADIAN / 2, + startAngle: 0, + endAngle: 0, + }; + + setColorRing(valueArc(ringD)); + setDefaultRing(remainingArc(ringD)); + }, [percent]); + + return { colorRing, defaultRing }; +}; diff --git a/packages/react-components/stories/dial/dial.stories.tsx b/packages/react-components/stories/dial/dial.stories.tsx new file mode 100644 index 000000000..f381bf831 --- /dev/null +++ b/packages/react-components/stories/dial/dial.stories.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Dial } from '../../src/components/dial/dial'; +import { initialize } from '@iot-app-kit/source-iotsitewise'; + +const ASSET_ID = '587295b6-e0d0-4862-b7db-b905afd7c514'; +const PROPERTY_ID = '16d45cb7-bb8b-4a1e-8256-55276f261d93'; + +export default { + title: 'Dial', + component: Dial, + argTypes: { + yMin: { control: { type: 'number' }, defaultValue: 0 }, + yMax: { control: { type: 'number' }, defaultValue: 100 }, + assetId: { control: { type: 'string' }, defaultValue: ASSET_ID }, + propertyId: { control: { type: 'string' }, defaultValue: PROPERTY_ID }, + accessKeyId: { control: { type: 'string' } }, + secretAccessKey: { control: { type: 'string' } }, + sessionToken: { control: { type: 'string' } }, + }, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta; + +const { query } = initialize({ + awsCredentials: { + accessKeyId: 'xxxx', + secretAccessKey: 'xxxx', + sessionToken: 'xxxx', + }, +}); + +export const SiteWiseDial: ComponentStory = ({ yMin, yMax }) => ( +
+ +
+); diff --git a/packages/react-components/stories/dial/dialBase.stories.tsx b/packages/react-components/stories/dial/dialBase.stories.tsx new file mode 100644 index 000000000..8c279e170 --- /dev/null +++ b/packages/react-components/stories/dial/dialBase.stories.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { DialBase } from '../../src/components/dial/dialBase'; + +export default { + title: 'Dial base', + component: DialBase, + argTypes: { + propertyPoint: { control: { type: 'object' }, defaultValue: { x: 123123213, y: 100.13 } }, + alarmPoint: { control: { type: 'object' }, defaultValue: { x: 123123213, y: 'Warning' } }, + yMin: { control: { type: 'number' }, defaultValue: 0 }, + yMax: { control: { type: 'number' }, defaultValue: 100 }, + showName: { control: { type: 'boolean' }, defaultValue: true }, + showUnit: { control: { type: 'boolean' }, defaultValue: true }, + }, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta; + +export const Main: ComponentStory = ({ yMin, yMax, showName, showUnit, ...args }) => ( +
+ +
+);