diff --git a/chronograf-ui b/chronograf-ui new file mode 160000 index 00000000..e1de5936 --- /dev/null +++ b/chronograf-ui @@ -0,0 +1 @@ +Subproject commit e1de5936e5ba9f1c347b5f8d5c59e6d31d41e617 diff --git a/giraffe/README.md b/giraffe/README.md index 81c1df1b..e29bd8ff 100644 --- a/giraffe/README.md +++ b/giraffe/README.md @@ -698,6 +698,191 @@ TableGraphLayerConfig uses the `fluxResponse` property from `config` as the data - **overflowDelta**: _number. Optional. Defaults to 0.03 when excluded._ This constant expresses how far past the gauge min or gauge max the needle should be drawn if the value for the needle is less than gauge min or greater than the gauge max. It is expressed as a fraction of the circumference of a circle, e.g. 0.5 means draw halfway around the gauge from the min or max value. + +- **GaugeMiniLayerConfig**: _Object._ Maximum one per ``. Properties are: + + All values (excluding **type**) are Optional and their defaults is defined by theme `GAUGE_MINI_THEME_BULLET_DARK` + + gauge mini creates one bar per unique __field_ value + + - **type**: _'gauge mini'. **Required**._ Specifies that this LayerConfig is a gauge mini layer. + + - **mode** _'progress' | 'bullet'._ + - `'bullet'` backgroud bar is colored and value bar has always secondary color + - `'progress'` value bar is colored and backgroud bar has always secondary color + + - **textMode** _'follow' | 'left'_ + - `'left'` text value will stay on _left_ side of bar + - `'follow'` text value will _follow_ width of value bar + + - **valueHeight** _number_ height of value bar + + - **gaugeHeight** _number_ hegiht of backgroud bar + + - **valueRounding** _number_ rounding of value bar corners + + - **gaugeRounding** _number_ rounding of backgroud bar corners + + - **barPaddings** _number_ vertical distance between bars, axes and label + + - **sidePaddings** _number_ empty space on left/right of the bar + + - **oveflowFraction** _number_ fraction number defining how much can value bar go outside of background bar. e.g. with `oveflowFraction: .1` will value bar have max length 110% of background bar for max value and will have 10% length to the left from background bar for minimal value. + + - **gaugeColors** _types based on used style_ + - giraffe style _Color[]_ An array of objects that defines the colors of the Gauge. Each object has the following properties. + + - **id**: _string. **Required**._ The id for this color. Should be unique within the **gaugeColors** array. + + - **type**: _'min' | 'max' | 'threshold'. **Required**._ The type of value associated with this color. _'min'_ type comes first, _'max'_ type comes last, and _'threshold'_ types are in between _'min'_ and _'max'_. **gaugeColors** must contain at least one _'min'_ type and one _'max'_ type for the Gauge-mini to have color. Only the first _'min'_ and first _'max'_ in the array are recognized for each of their respective types. The color will change as a gradient if the **gaugeColors** array contains no 'threshold' that means gradient background bar for 'bullet' mode and continuous value bar color change for 'progress' mode. The color will be segmented if any _'threshold'_ types are included in the **gaugeColors** array that means step color change background bar for 'bullet' mode and fixed color of value bar based on value change for 'progress' mode. + + - **hex**: _string. **Required**._ The [_color hex_](https://www.color-hex.com/) string for this color. + + - **name**: _string. **Required**._ For descriptive purposes only. The name given to this color. + + - **value**: _number. **Required**._ The starting gauge value associated with this color. + - mini gauge style: _{min; max; thresholds; }_ + - _ColorHexValue_ is _{ value: number; hex: string; }_ where hex is [_color hex_](https://www.color-hex.com/) adn value is where color is applied. + - **min** _ColorHexValue_ **Required** is minimaln value of gauge + - **max** _ColorHexValue_ **Required** is maximaln value of gauge + - **thresholds** _ColorHexValue[]_ is thresholds of gauge. The color will change as a gradient if no thresholds present, that means gradient background bar for 'bullet' mode and continuous value bar color change for 'progress' mode. The color will be segmented one or more thresholds present, that means step color change background bar for 'bullet' mode and fixed color of value bar based on value change for 'progress' mode. + + - **colorSecondary** _string_ Secondary color used for value bar in 'bullet' mode or for background bar in 'progress' mode + + Main label + - **labelMain** _string_ Main label text. + + - **labelMainFontSize** _number_ Main label font size. + + - **labelMainFontColor** _string_ Main label color. + + Bar labels + - **labelBarsEnabled** _boolean_ Bar labels shown if true + + - **labelBarsFontSize** _number_ Bar labels font size + + - **labelBarsFontColor** _string_ Bar labels font color + + Text value + - **valuePadding** _number_ Padding on sides of text (distance from value bar start/end) + + - **valueFontSize** _number_ Text value font size + + - **valueFontColorInside** _string_ Text value color when value bar is behind the text + + - **valueFontColorOutside** _string_ Text value color when value bar is not behind the text + + Axes + - **axesSteps** _number | 'thresholds' | undefined | number[]_ Defines where to show axes: + - _number_ number of how many evenly distributed axes values will be shown between min and max value. Only min and max value will be shown when `axesSteps: 0` + - _'thresholds'_ axes values will be shown at threshold values. + - _undefined_ axes will not show. + - _number[]_ shows axes at given fixed values + + - **axesFontSize** _number_ Axes values font size + + - **axesFontColor** _string_ Color of axes values and axes lines + + - Formaters **valueFormater** and **axesFormater** _(value: number) => string_ or _FormatStatValueOptions_ + - How will be text value on bar and axes values formated. + - _FormatStatValueOptions_ + - **prefix**: _string. Optional._ The text that appears before the gauge value. Use an empty string if no text is preferred. + + - **suffix**: _string. Optional._ The text that appears after the gauge value. Use an empty string if no text is preferred. + + - **decimalPlaces**: _Object. Optional._ + + - **isEnforced**: _boolean. Optional. Defaults to false when not included._ Indicates whether the number of decimal places ("**digits**") will be enforced. When **isEnforced** is falsy or omitted, **digits** will be locked to 2 for stat values with a decimal and 0 for stat values that are integers, and the **digits** option will be ignored. + - **digits**: _number. Optional. Defaults to 0 when not included. Maximum 10._ When **digits** is a non-integer number, the decimal portion is ignored. Represents the number of decimal places to display in the stat value. Displayed stat value is subject to rounding. + - example ```valueFormater: (num: number) => `${((num || 0) * 100).toFixed(0)}%` ``` for _value=0.23213_ will show text value _23%_. + + **Precreated themes** + - `GAUGE_MINI_THEME_BULLET_DARK` + ``` + { + type: 'gauge mini', + mode: 'bullet', + textMode: 'follow', + + valueHeight: 18, + gaugeHeight: 25, + valueRounding: 2, + gaugeRounding: 3, + barPaddings: 5, + sidePaddings: 20, + oveflowFraction: .03, + + gaugeMiniColors: [ + {value: 0, type: 'min', hex: InfluxColors.Krypton}, + {value: 50, type: 'threshold', hex: InfluxColors.Sulfur}, + {value: 75, type: 'threshold', hex: InfluxColors.Topaz}, + {value: 100, type: 'max', hex: InfluxColors.Topaz}, + ] as Color[], + colorSecondary: InfluxColors.Kevlar, + + labelMain: '', + labelMainFontSize: 13, + labelMainFontColor: InfluxColors.Ghost, + + labelBarsEnabled: false, + labelBarsFontSize: 11, + labelBarsFontColor: InfluxColors.Forge, + + valuePadding: 5, + valueFontSize: 12, + valueFontColorOutside: InfluxColors.Raven, + valueFontColorInside: InfluxColors.Cloud, + valueFormater: {}, + + axesSteps: 'thresholds', + axesFontSize: 11, + axesFontColor: InfluxColors.Forge, + axesFormater: {}, + } + ``` + - `GAUGE_MINI_THEME_PROGRESS_DARK` + ``` + { + type: 'gauge mini', + mode: 'progress', + textMode: 'follow', + + valueHeight: 20, + gaugeHeight: 20, + valueRounding: 3, + gaugeRounding: 3, + barPaddings: 5, + sidePaddings: 20, + oveflowFraction: .03, + + gaugeMiniColors: [ + {value: 0, type: 'min', hex: InfluxColors.Krypton}, + {value: 100, type: 'max', hex: InfluxColors.Topaz}, + ] as Color[], + colorSecondary: InfluxColors.Kevlar, + + labelMain: '', + labelMainFontSize: 13, + labelMainFontColor: InfluxColors.Ghost, + + labelBarsEnabled: false, + labelBarsFontSize: 11, + labelBarsFontColor: InfluxColors.Forge, + + valuePadding: 5, + valueFontSize: 18, + valueFontColorInside: InfluxColors.Raven, + valueFontColorOutside: InfluxColors.Cloud, + valueFormater: {}, + + axesSteps: undefined as any, + axesFontSize: 11, + axesFontColor: InfluxColors.Forge, + axesFormater: {}, + } + ``` + + - **SingleStatLayerConfig**: _Object._ No limit but generally one per ``. Using more than one requires additional styling through configuration and is not recommended.
A Single Stat layer is a pre-defined custom layer that displays a single value on top of any other plot type, or by itself, but usually displayed on top of (single) line graphs. The displayed value is the latest value by timestamp. If more than one value has the latest timestamp, then the first value in the [table](#data-properties) with the latest timestamp will be displayed. Currently, there is no guarantee which value will be considered the first value when there are multiple values with the same timestamp. diff --git a/giraffe/src/components/GaugeMini.tsx b/giraffe/src/components/GaugeMini.tsx new file mode 100644 index 00000000..843d9cc5 --- /dev/null +++ b/giraffe/src/components/GaugeMini.tsx @@ -0,0 +1,552 @@ +// Libraries +import React, {FunctionComponent, useRef, useEffect, useState} from 'react' +import {color as d3Color} from 'd3-color' +import {scaleLinear} from 'd3-scale' + +// Types +import {GaugeMiniColors, GaugeMiniLayerConfig} from '../types' +import {GroupedData} from './LatestMultipleValueTransform' + +import { + gaugeMiniNormalizeThemeMemoized, + GaugeMiniThemeNormalized, +} from '../utils/gaugeMiniThemeNormalize' + +interface Props { + width: number + height: number + values: GroupedData + theme: Required +} + +const barCssClass = 'gauge-mini-bar' + +//#region svg helpers + +type TSvgTextRectProps = { + onRectChanged?: (rect: DOMRect) => void +} & React.SVGProps + +/** + * Helper component that returns rect when children changes. Usefull for calculating text box size. + * !onRectChanged called only when children changes! + */ +export const SvgTextRect: React.FC = props => { + const {onRectChanged = () => {}} = props + + const textRef = useRef(null) + + useEffect(() => { + const rect = textRef.current?.getBBox() + if (!rect) { + return + } + + onRectChanged(rect) + }, [props.children, onRectChanged]) + + return ( + <> + + + ) +} + +/** + * Helper component for centering content. + * !Doesn't react on content size changed. Recententering is done manualy by changing refreshToken! + */ +const AutoCenterGroup: FunctionComponent<{ + enabled?: boolean + refreshToken?: number | string +} & React.SVGProps> = props => { + const {children, enabled = true, refreshToken = 0} = props + const ref = useRef(null) + + const [x, setX] = useState(0) + const [y, setY] = useState(0) + + useEffect(() => { + if (!enabled) { + setX(0) + setY(0) + return + } + + const g = ref.current + // we use this function because we want to know how much we are in negative direction + const box = g?.getBBox() + // we use this function because we want to have fixed parent width/height + const boxParent = ((g?.parentElement as any) as + | SVGGraphicsElement + | undefined)?.getBoundingClientRect() + + if (!box || !boxParent) { + return + } + + setX((boxParent.width - box.width) / 2 - box.x) + setY((boxParent.height - box.height) / 2 - box.y) + }, [refreshToken, enabled]) + + return ( + + {children} + + ) +} + +//#endregion svg helpers + +//#region subcomponents + +//#region types + +type BarBackgroundProps = { + theme: Required + colors: GaugeMiniColors + barWidth: number + getFrac: (x: number) => number + barCenter: number +} + +type BarValueProps = { + theme: Required + barValueWidth: number + colors: GaugeMiniColors + value: number + valueFracFixed: number + barCenter: number +} + +type TextProps = { + theme: Required + barValueWidth: number + colors: GaugeMiniColors + value: number +} + +type BarProps = { + value: number + theme: Required + barWidth: number + y: number + getFrac: (x: number) => number +} + +type AxesProps = { + theme: Required + barWidth: number + y: number + getFrac: (x: number) => number +} + +type BarSegment = { + start: number + end: number + hex: string +} + +//#endregion types + +const BarBackground: FunctionComponent = ({ + theme, + barWidth, + getFrac, + barCenter, +}) => { + const {gaugeHeight, mode, gaugeRounding, colors, colorSecondary} = theme + const {max, min, thresholds = []} = colors + + const y = barCenter - gaugeHeight / 2 + // todo: invalid HTML -> multiple same ID attribute possible + const roundingDefId = `rounded-bar-w-${barWidth}-h-${gaugeHeight}-r-${gaugeRounding}` + const gradientDefId = `gradient-${min.hex}-${max.hex}` + + const segments: BarSegment[] = [] + if (mode === 'bullet') { + // thresholds are already sorted by getColors + const allColors = [min, ...thresholds, max] + + for ( + let i = 0, start = 0, end = 0; + i + 1 < allColors.length; + i++, start = end + ) { + const {hex} = allColors[i] + const next = allColors[i + 1].value + + end = getFrac(next) + segments.push({start, end, hex}) + } + } else { + segments.push({start: 0, end: 1, hex: colorSecondary}) + } + + // todo: dont't render def linear gradient when is not used + return ( + <> + + + + + + + + + + {thresholds.length === 0 && mode === 'bullet' ? ( + + ) : ( + segments + .reverse() + .map(({hex: col, end, start}, i) => ( + + )) + )} + + ) +} + +const BarValue: FunctionComponent = ({ + colors, + barValueWidth, + value, + theme, + valueFracFixed, + barCenter, +}) => { + const {valueHeight, mode, valueRounding, colorSecondary} = theme + const {min, max, thresholds = []} = colors + const colorModeGradient = thresholds.length === 0 + + const x = Math.sign(valueFracFixed) === -1 ? barValueWidth : 0 + const y = barCenter - valueHeight / 2 + + const className = 'value-rect' + + const colorValue = + mode === 'bullet' + ? colorSecondary + : d3Color( + (() => { + if (colorModeGradient) { + return scaleLinear() + .range([min.hex, max.hex] as any) + .domain([min.value, max.value])(value) as any + } else { + const sortedColors = [min, ...thresholds, max] + let i = 0 + while ( + i < sortedColors.length && + value >= sortedColors[i].value + ) { + i++ + } + return sortedColors[ + Math.max(Math.min(i - 1, sortedColors.length - 1), 0) + ].hex + } + })() + ) + ?.brighter(1) + .hex() + + // todo: move styling out -> styling is now multiple times inserted + return ( + <> + + + + ) +} + +const Text: FunctionComponent = ({value, barValueWidth, theme}) => { + const { + valueFontColorInside, + valueFontColorOutside, + textMode, + valueFormater, + valueFontSize: fontSize, + valuePadding, + } = theme + const textValue = valueFormater(value) + const follow = textMode === 'follow' + + const [textBBox, setTextBBox] = useState(null) + + const textWidth = textBBox?.width ? textBBox?.width + valuePadding * 2 : 0 + const textInside = textWidth < barValueWidth + + const textColor = textInside ? valueFontColorInside : valueFontColorOutside + + const textAnchor = textInside && follow ? 'end' : 'start' + + const x = follow + ? Math.max( + barValueWidth + (textInside ? -1 : +1) * valuePadding, + valuePadding + ) + : valuePadding + + return ( + <> + + {textValue} + + + ) +} + +const Bar: FunctionComponent = ({ + value, + theme, + y, + barWidth, + getFrac, +}) => { + const {gaugeHeight, valueHeight, oveflowFraction, colors} = theme + + const valueFracFixed = Math.max( + -oveflowFraction, + Math.min(oveflowFraction + 1, getFrac(value)) + ) + + const barValueWidth = barWidth * valueFracFixed + const maxBarHeight = Math.max(gaugeHeight, valueHeight) + const barCenter = maxBarHeight / 2 + + return ( + + + + + + + + + + + ) +} + +const Axes: FunctionComponent = ({theme, barWidth, y, getFrac}) => { + const {axesSteps, axesFormater, axesFontColor, axesFontSize, colors} = theme + + if (axesSteps === undefined) { + return <> + } + + const {min, max} = colors + const axesLineStyle: React.CSSProperties = { + stroke: axesFontColor, + strokeWidth: 2, + strokeLinecap: 'round', + } + + const points: { + anchor: string + value: number + lineLength: number + text: string + posX: number + }[] = axesSteps + .map(value => ({ + value, + anchor: 'middle', + lineLength: 5, + })) + .concat([ + { + value: min.value, + anchor: 'start', + lineLength: 3, + }, + { + value: max.value, + anchor: 'end', + lineLength: 3, + }, + ]) + .map(x => ({ + ...x, + posX: getFrac(x.value) * barWidth, + text: axesFormater(x.value), + })) + + return ( + <> + + + {points.map(({posX, lineLength, anchor, text}, i) => ( + + + + {text} + + + ))} + + + ) +} + +//#endregion subcomponents + +export const GaugeMini: FunctionComponent = ({ + values, + theme: _theme, + width, + height, +}) => { + const theme = gaugeMiniNormalizeThemeMemoized(_theme) + + const { + gaugeHeight, + sidePaddings, + valueHeight, + barPaddings, + labelMain, + labelMainFontSize, + labelMainFontColor, + labelBarsEnabled, + labelBarsFontColor, + labelBarsFontSize, + colors, + axesSteps, + axesFontSize, + } = theme + const [barLabelsWidth] = useState([]) + + const colorLen = colors.max.value - colors.min.value + const barLabelWidth = Math.max(...barLabelsWidth) || 0 + const barWidth = width - sidePaddings * 2 - barLabelWidth + const maxBarHeight = Math.max(gaugeHeight, valueHeight) + const allBarsHeight = + Object.keys(values).length * (maxBarHeight + barPaddings) + + // create unified barsDefinition + + const [autocenterToken, setAutocenterToken] = useState(Date.now()) + useEffect(() => { + setAutocenterToken(Date.now()) + }, [ + width, + height, + barLabelWidth, + valueHeight, + gaugeHeight, + barPaddings, + sidePaddings, + labelMainFontSize, + axesSteps, + axesFontSize, + ]) + + /** return value as fraction 0->min 1->max */ + const getFrac = (val: number): number => (val - colors.min.value) / colorLen + + return ( + + + {labelMain && ( + + {labelMain} + + )} + {Object.entries(values).map(([group, value], i) => { + const y = 0 + i * (maxBarHeight + barPaddings) + const textCenter = y + maxBarHeight / 2 + const label = labelBarsEnabled ? group : '' + + // todo: no rerender ? + const onRectChanged = (r: DOMRect) => { + barLabelsWidth[i] = r.width + } + + return ( + <> + + + {label} + + + ) + })} + + + + ) +} diff --git a/giraffe/src/components/GaugeMiniLayer.tsx b/giraffe/src/components/GaugeMiniLayer.tsx new file mode 100644 index 00000000..d88f9d47 --- /dev/null +++ b/giraffe/src/components/GaugeMiniLayer.tsx @@ -0,0 +1,42 @@ +// Libraries +import React, {FunctionComponent} from 'react' +import AutoSizer from 'react-virtualized-auto-sizer' + +// Types +import {GaugeMiniLayerConfig} from '../types' +import {GaugeMini} from './GaugeMini' + +import {GAUGE_MINI_THEME_BULLET_DARK} from '../constants/gaugeMiniStyles' +import {GroupedData} from './LatestMultipleValueTransform' + +interface Props { + values: GroupedData + theme: GaugeMiniLayerConfig +} + +// todo: move gauge mini here +export const GaugeMiniLayer: FunctionComponent = (props: Props) => { + const {theme, values} = props + const themeOrDefault: Required = { + ...GAUGE_MINI_THEME_BULLET_DARK, + ...theme, + } + + return ( + + {({width, height}) => ( +
+ +
+ )} +
+ ) +} diff --git a/giraffe/src/components/LatestMultipleValueTransform.tsx b/giraffe/src/components/LatestMultipleValueTransform.tsx new file mode 100644 index 00000000..a5ce197a --- /dev/null +++ b/giraffe/src/components/LatestMultipleValueTransform.tsx @@ -0,0 +1,77 @@ +// Libraries +import React, {useMemo, FunctionComponent} from 'react' +import {Table} from '../types' + +export type GroupedData = {[key: string]: number} + +const getTimeColumn = (table: Table) => { + // Fallback to `_stop` column if `_time` column missing otherwise return empty array. + let timeColData: number[] = [] + + if (table.columnKeys.includes('_time')) { + timeColData = table.getColumn('_time', 'number') as number[] + } else if (table.columnKeys.includes('_stop')) { + timeColData = table.getColumn('_stop', 'number') as number[] + } + if (!timeColData && table.length !== 1) { + return [] + } + + return timeColData +} + +export const getLatestValuesGrouped = ( + table: Table, + groupColumnKey: string +): GroupedData => { + const valueCol = table.getColumn('_value', 'number') + const groupCol = table.getColumn(groupColumnKey) + + if (!valueCol.length) { + return {} + } + + return Object.fromEntries( + getTimeColumn(table) + // merge time with it's index + .map((time, index) => ({time, index})) + // remove entries without time + .filter(({time}) => time) + // todo: sort time complexity too high ... replace with linear solution + // from low time to high time (last is last) + .sort(({time: t1}, {time: t2}) => t1 - t2) + // get relevant data from index (we don't need time anymore) + .map(({index}) => [groupCol?.[index] ?? '', valueCol[index]] as const) + // remove invalid data + .filter( + ([_, value]) => typeof value === 'number' && Number.isFinite(value) + ) + ) +} + +interface Props { + table: Table + children: (latestValue: GroupedData) => JSX.Element + quiet?: boolean +} + +// todo: can return string ? +export const LatestMultipleValueTransform: FunctionComponent = ({ + table, + quiet = false, + children, +}) => { + const latestValues = useMemo(() => getLatestValuesGrouped(table, '_field'), [ + table, + ]) + + if (Object.keys(latestValues).length === 0) { + return quiet ? null : ( +
+

No latest value found

+
+ ) + } + + return children(latestValues) +} diff --git a/giraffe/src/components/Plot.tsx b/giraffe/src/components/Plot.tsx index 51b5e5c2..747a65f9 100644 --- a/giraffe/src/components/Plot.tsx +++ b/giraffe/src/components/Plot.tsx @@ -43,7 +43,8 @@ export const Plot: FunctionComponent = ({ if ( graphType === LayerTypes.Table || graphType === LayerTypes.RawFluxDataTable || - graphType === LayerTypes.Gauge + graphType === LayerTypes.Gauge || + graphType === LayerTypes.GaugeMini ) { return ( diff --git a/giraffe/src/components/SizedTable.tsx b/giraffe/src/components/SizedTable.tsx index 6b1f9dd6..7beddfd8 100644 --- a/giraffe/src/components/SizedTable.tsx +++ b/giraffe/src/components/SizedTable.tsx @@ -8,6 +8,7 @@ import { } from '../types' import {GaugeLayer} from './GaugeLayer' +import {GaugeMiniLayer} from './GaugeMiniLayer' import {LatestValueTransform} from './LatestValueTransform' import {newTableFromConfig} from '../utils/newTable' import {RawFluxDataTable} from './RawFluxDataTable' @@ -15,6 +16,7 @@ import {FluxTablesTransform} from './FluxTablesTransform' import {TableGraphLayer} from './TableGraphLayer' import {usePlotEnv} from '../utils/usePlotEnv' +import {LatestMultipleValueTransform} from './LatestMultipleValueTransform' interface Props { config: SizedConfig @@ -79,6 +81,20 @@ export const SizedTable: FunctionComponent = ({ )} ) + case LayerTypes.GaugeMini: + return ( + + {latestValues => ( + + )} + + ) case LayerTypes.RawFluxDataTable: return ( = { + type: 'gauge mini', + mode: 'bullet', + textMode: 'follow', + + valueHeight: 18, + gaugeHeight: 25, + valueRounding: 2, + gaugeRounding: 3, + barPaddings: 5, + sidePaddings: 20, + oveflowFraction: 0.03, + + gaugeMiniColors: [ + {value: 0, type: 'min', hex: InfluxColors.Krypton}, + {value: 50, type: 'threshold', hex: InfluxColors.Sulfur}, + {value: 75, type: 'threshold', hex: InfluxColors.Topaz}, + {value: 100, type: 'max', hex: InfluxColors.Topaz}, + ] as Color[], + colorSecondary: InfluxColors.Kevlar, + + labelMain: '', + labelMainFontSize: 13, + labelMainFontColor: InfluxColors.Ghost, + + labelBarsEnabled: false, + labelBarsFontSize: 11, + labelBarsFontColor: InfluxColors.Forge, + + valuePadding: 5, + valueFontSize: 12, + valueFontColorOutside: InfluxColors.Raven, + valueFontColorInside: InfluxColors.Cloud, + valueFormater: {}, + + axesSteps: 'thresholds', + axesFontSize: 11, + axesFontColor: InfluxColors.Forge, + axesFormater: {}, +} + +export const GAUGE_MINI_THEME_PROGRESS_DARK: Required = { + type: 'gauge mini', + mode: 'progress', + textMode: 'follow', + + valueHeight: 20, + gaugeHeight: 20, + valueRounding: 3, + gaugeRounding: 3, + barPaddings: 5, + sidePaddings: 20, + oveflowFraction: 0.03, + + gaugeMiniColors: [ + {value: 0, type: 'min', hex: InfluxColors.Krypton}, + {value: 100, type: 'max', hex: InfluxColors.Topaz}, + ] as Color[], + colorSecondary: InfluxColors.Kevlar, + + labelMain: '', + labelMainFontSize: 13, + labelMainFontColor: InfluxColors.Ghost, + + labelBarsEnabled: false, + labelBarsFontSize: 11, + labelBarsFontColor: InfluxColors.Forge, + + valuePadding: 5, + valueFontSize: 18, + valueFontColorInside: InfluxColors.Raven, + valueFontColorOutside: InfluxColors.Cloud, + valueFormater: {}, + + axesSteps: undefined, + axesFontSize: 11, + axesFontColor: InfluxColors.Forge, + axesFormater: {}, +} diff --git a/giraffe/src/index.ts b/giraffe/src/index.ts index fd2f378f..3d6d9a99 100644 --- a/giraffe/src/index.ts +++ b/giraffe/src/index.ts @@ -36,6 +36,7 @@ export { Formatter, GaugeLayerConfig, GaugeTheme, + GaugeMiniLayerConfig, GetColumn, HistogramLayerConfig, HistogramPosition, diff --git a/giraffe/src/types/index.ts b/giraffe/src/types/index.ts index 7403cbe6..4142aa51 100644 --- a/giraffe/src/types/index.ts +++ b/giraffe/src/types/index.ts @@ -2,6 +2,7 @@ import CSS from 'csstype' import {CSSProperties, ReactNode} from 'react' import {TimeZone} from './timeZones' import {GeoLayerConfig} from './geo' +import {FormatStatValueOptions} from '../utils/formatStatValue' export type SizedConfig = Config & {width: number; height: number} export interface Config { @@ -175,6 +176,7 @@ export type FluxDataType = export enum LayerTypes { RawFluxDataTable = 'flux data table', Gauge = 'gauge', + GaugeMini = 'gauge mini', Custom = 'custom', Annotation = 'annotation', SingleStat = 'single stat', @@ -193,6 +195,7 @@ export type LayerConfig = | AnnotationLayerConfig | RawFluxDataTableLayerConfig | GaugeLayerConfig + | GaugeMiniLayerConfig | SingleStatLayerConfig | HeatmapLayerConfig | HistogramLayerConfig @@ -276,6 +279,53 @@ export interface GaugeTheme { overflowDelta: number } +export type ColorHexValue = { + value: number + hex: string +} + +export type GaugeMiniColors = { + min: ColorHexValue + max: ColorHexValue + thresholds?: ColorHexValue[] +} + +export interface GaugeMiniLayerConfig { + type: 'gauge mini' + mode?: 'progress' | 'bullet' + textMode?: 'follow' | 'left' + + valueHeight?: number + gaugeHeight?: number + valueRounding?: number + gaugeRounding?: number + barPaddings?: number + sidePaddings?: number + oveflowFraction?: number + + gaugeMiniColors?: Color[] | GaugeMiniColors + colorSecondary?: string + + labelMain?: string + labelMainFontSize?: number + labelMainFontColor?: string + + labelBarsEnabled?: boolean + labelBarsFontSize?: number + labelBarsFontColor?: string + + valuePadding?: number + valueFontSize?: number + valueFontColorInside?: string + valueFontColorOutside?: string + valueFormater?: ((value: number) => string) | FormatStatValueOptions + + axesSteps?: number | 'thresholds' | undefined | number[] + axesFontSize?: number + axesFontColor?: string + axesFormater?: ((value: number) => string) | FormatStatValueOptions +} + export interface SingleStatLayerConfig { type: 'single stat' // do not refactor or restrict to LayerTypes.SingleStat prefix: string diff --git a/giraffe/src/utils/PlotEnv.ts b/giraffe/src/utils/PlotEnv.ts index a04bf549..30e9aaff 100644 --- a/giraffe/src/utils/PlotEnv.ts +++ b/giraffe/src/utils/PlotEnv.ts @@ -375,6 +375,7 @@ export class PlotEnv { case LayerTypes.RawFluxDataTable: case LayerTypes.Gauge: + case LayerTypes.GaugeMini: case LayerTypes.Geo: case LayerTypes.Custom: case LayerTypes.SingleStat: diff --git a/giraffe/src/utils/formatStatValue.ts b/giraffe/src/utils/formatStatValue.ts index bc56768e..32fda6d8 100644 --- a/giraffe/src/utils/formatStatValue.ts +++ b/giraffe/src/utils/formatStatValue.ts @@ -6,7 +6,7 @@ import {DecimalPlaces} from '../types' export const MAX_DECIMAL_PLACES = 10 -interface FormatStatValueOptions { +export interface FormatStatValueOptions { decimalPlaces?: DecimalPlaces prefix?: string suffix?: string diff --git a/giraffe/src/utils/gaugeMiniThemeNormalize.ts b/giraffe/src/utils/gaugeMiniThemeNormalize.ts new file mode 100644 index 00000000..3b6572cb --- /dev/null +++ b/giraffe/src/utils/gaugeMiniThemeNormalize.ts @@ -0,0 +1,124 @@ +import {useMemo} from 'react' +import {FormatStatValueOptions, formatStatValue} from './formatStatValue' +import {GaugeMiniColors} from '../types' +import {GaugeMiniLayerConfig} from '..' +import {color as d3Color} from 'd3-color' +import {range} from 'd3-array' + +export const throwReturn = (msg: string): T => { + throw new Error(msg) +} + +type RestrictedTypesProperties = { + colors?: GaugeMiniColors + gaugeMiniColors?: GaugeMiniColors + valueFormater?: (value: number) => string + + axesSteps: undefined | number[] + axesFormater?: (value: number) => string +} + +export type GaugeMiniThemeNormalized = Omit< + GaugeMiniLayerConfig, + keyof RestrictedTypesProperties +> & + RestrictedTypesProperties + +const getFormater = ( + formater: ((value: number) => string) | FormatStatValueOptions +): ((value: number) => string) => + typeof formater === 'function' + ? formater + : (value: number) => formatStatValue(value, formater) + +const getAxesSteps = ( + axesSteps: number | 'thresholds' | undefined | number[], + colors: GaugeMiniColors +): number[] | undefined => { + if (axesSteps === undefined || axesSteps === null) { + return undefined + } + const { + max: {value: max}, + min: {value: min}, + } = colors + + if (Array.isArray(axesSteps)) { + const steps = axesSteps.filter(x => x > min || x < max) + if (axesSteps.length !== steps.length) { + console.error(`All axes values must be inside range of colors!`) + } + return steps + } + + if (axesSteps === 'thresholds') { + return (colors.thresholds ?? []).map(x => x.value) + } + + if (Number.isInteger(axesSteps)) { + const colorLen = max - min + + return range(axesSteps).map( + x => ((x + 1) * colorLen) / (axesSteps + 1) + min + ) + } + + throw new Error( + `AxesSteps must be number | "thresholds" | number[]` + + ` | undefined. But it's value is ${JSON.stringify(axesSteps)}` + ) +} + +const getColors = ( + colors: Required['gaugeMiniColors'] +): GaugeMiniColors => { + if (!Array.isArray(colors)) { + return { + ...colors, + thresholds: (colors.thresholds ?? []).sort( + ({value: a}, {value: b}) => a - b + ), + } + } + + colors.forEach( + ({hex, name}) => + d3Color(hex) ?? + throwReturn(`Object "${hex}" isn"t valid color for name:${name}`) + ) + + return { + min: + colors.find(x => x.type === 'min') ?? + throwReturn('color of type min must be defined'), + max: + colors.find(x => x.type === 'max') ?? + throwReturn('color of type max must be defined'), + thresholds: colors + .filter(({type}) => type === 'threshold') + .sort(({value: a}, {value: b}) => a - b), + } +} + +export const gaugeMiniNormalizeThemeMemoized = ( + theme: Required +): Required => { + const colors = useMemo(() => getColors(theme.gaugeMiniColors), [ + theme.gaugeMiniColors, + ]) + + const axesSteps = useMemo(() => getAxesSteps(theme.axesSteps, colors), [ + theme.axesSteps, + colors, + ]) + + return { + ...theme, + colors, + gaugeMiniColors: colors, + valueFormater: getFormater(theme.valueFormater), + + axesSteps, + axesFormater: getFormater(theme.axesFormater), + } +} diff --git a/stories/src/data/gaugeMiniLayer.ts b/stories/src/data/gaugeMiniLayer.ts new file mode 100644 index 00000000..6ed66132 --- /dev/null +++ b/stories/src/data/gaugeMiniLayer.ts @@ -0,0 +1,37 @@ +import {newTable, Table} from '../../../giraffe/src' +import memoizeOne from 'memoize-one' + +const now = Date.now() +const numberOfRecords = 20 +const recordsPerLine = 20 + +let TIME_COL: Array +let VALUE_COL: Array +let FIELD_COL: Array + +function getRandomNumber(min: number, max: number) { + return Math.random() * (max - min) + min +} +const createColumns = (minValue: number, maxValue: number, fields: number) => { + TIME_COL = [] + VALUE_COL = [] + FIELD_COL = [] + for (let i = 0; i < numberOfRecords * fields; i += 1) { + VALUE_COL.push(getRandomNumber(minValue, maxValue)) + TIME_COL.push(now + ((i % recordsPerLine) % fields) * 1000 * 60) + FIELD_COL.push(gaugeMiniTableGetField(Math.floor(i / numberOfRecords))) + } +} + +/** return field name for given index */ +export const gaugeMiniTableGetField = (i: number) => `_field_${i}` + +export const gaugeMiniTable = memoizeOne( + (minValue: number, maxValue: number, fields: number): Table => { + createColumns(minValue, maxValue, fields) + return newTable(numberOfRecords * fields) + .addColumn('_time', 'dateTime:RFC3339', 'time', TIME_COL) + .addColumn('_value', 'system', 'number', VALUE_COL) + .addColumn('_field', 'string', 'string', FIELD_COL) + } +) diff --git a/stories/src/gaugeMini.stories.tsx b/stories/src/gaugeMini.stories.tsx new file mode 100644 index 00000000..d81e284d --- /dev/null +++ b/stories/src/gaugeMini.stories.tsx @@ -0,0 +1,308 @@ +import * as React from 'react' +import {storiesOf} from '@storybook/react' +import {withKnobs, number, select, boolean, text} from '@storybook/addon-knobs' +import { + Config, + GaugeMiniLayerConfig, + InfluxColors, + Plot, +} from '../../giraffe/src' + +import {PlotContainer} from './helpers' +import { + GAUGE_MINI_THEME_BULLET_DARK, + GAUGE_MINI_THEME_PROGRESS_DARK, +} from '../../giraffe/src/constants/gaugeMiniStyles' +import {gaugeMiniTable} from './data/gaugeMiniLayer' +import { + gaugeMiniNormalizeThemeMemoized, + GaugeMiniThemeNormalized, +} from '../../giraffe/src/utils/gaugeMiniThemeNormalize' +import {range} from 'd3-array' +import {FormatStatValueOptions} from '../../giraffe/src/utils/formatStatValue' + +type Theme = Required +type ThemeNormalized = Required +type Color = ThemeNormalized['gaugeMiniColors']['min'] + +const color = (() => { + const colors = (() => { + const obj = {} + Object.keys(InfluxColors).forEach(x => (obj[x] = InfluxColors[x])) + return obj + })() + + return (label: string, ...rest: any[]) => select(label, colors, ...rest) +})() + +const randInt: { + (max: number): number + (min: number, max: number): number +} = (min: number, max?: number) => { + if (max === undefined) { + return Math.floor(Math.random() * min) + } + return min + Math.floor(Math.random() * (max - min)) +} + +const colorRandom = () => { + const colors = Object.values(InfluxColors) + return colors[randInt(colors.length)] +} + +const colorHexValue = ( + label: string, + colorDefault: Color, + groupID: string, + numberOptions: any = {} +): Color => { + return { + hex: color(label + ' - hex', colorDefault?.hex ?? '#888888', groupID), + value: number( + label + ' - value', + colorDefault?.value ?? 50, + numberOptions, + groupID + ), + } +} + +const formatStatValueOptions = ( + label: string, + formDefault: FormatStatValueOptions, + groupID: string +): FormatStatValueOptions => { + const l = (sublabel: string) => label + ' - ' + sublabel + return { + decimalPlaces: { + isEnforced: boolean( + l('decimalPlaces - isEnforced'), + formDefault?.decimalPlaces?.isEnforced ?? false, + groupID + ), + digits: number( + l('decimalPlaces - digits'), + formDefault?.decimalPlaces?.digits ?? 2, + {}, + groupID + ), + }, + prefix: text(l('prefix'), formDefault?.prefix, groupID), + suffix: text(l('suffix'), formDefault?.suffix, groupID), + } +} + +const knobGroups = { + basics: 'basics', + sizing: 'sizing', + colors: 'colors', + labels: 'labels', +} + +const editableLayer = ( + theme: ThemeNormalized +): Theme & {numberOfBars: number} => ({ + type: theme.type, + mode: select( + 'Mode', + { + bullet: 'bullet', + progress: 'progress', + }, + theme.mode, + knobGroups.basics + ), + textMode: select( + 'textMode', + { + follow: 'follow', + left: 'left', + }, + theme.textMode, + knobGroups.basics + ), + numberOfBars: number('number of bars', 1, {}, knobGroups.basics), + + valueHeight: number('valueHeight', theme.valueHeight, {}, knobGroups.sizing), + gaugeHeight: number('gaugeHeight', theme.gaugeHeight, {}, knobGroups.sizing), + valueRounding: number( + 'valueRounding', + theme.valueRounding, + {}, + knobGroups.sizing + ), + gaugeRounding: number( + 'gaugeRounding', + theme.gaugeRounding, + {}, + knobGroups.sizing + ), + barPaddings: number('barPaddings', theme.barPaddings, {}, knobGroups.sizing), + sidePaddings: number( + 'gaugePaddingSides', + theme.sidePaddings, + {}, + knobGroups.sizing + ), + oveflowFraction: number( + 'oveflowFraction', + theme.oveflowFraction, + {}, + knobGroups.sizing + ), + + colorSecondary: color( + 'colorSecondary', + theme.colorSecondary, + knobGroups.colors + ), + gaugeMiniColors: (() => { + const {thresholds} = theme.gaugeMiniColors + const min = colorHexValue( + 'colorMin', + theme.gaugeMiniColors.min, + knobGroups.colors + ) + const max = colorHexValue( + 'colorMax', + theme.gaugeMiniColors.max, + knobGroups.colors + ) + + const colors: Theme['gaugeMiniColors'] = { + min, + max, + thresholds: (() => { + const length = number( + 'thresholds number', + thresholds.length, + {}, + knobGroups.colors + ) + + return range(length).map(x => + colorHexValue( + `threshold ${x}`, + thresholds[x] || { + hex: colorRandom(), + value: randInt(min.value + 1, max.value), + }, + knobGroups.colors, + { + range: true, + min: min.value, + max: max.value, + step: 1, + } + ) + ) + })(), + } + return colors + })(), + + labelMain: text('labelMain', 'Gauge-mini example', knobGroups.labels), + labelMainFontSize: number( + 'labelMainFontSize', + theme.labelMainFontSize, + {}, + knobGroups.labels + ), + labelMainFontColor: color( + 'labelMainFontColor', + theme.labelMainFontColor, + knobGroups.labels + ), + + labelBarsEnabled: boolean( + 'labelBarsEnabled', + theme.labelBarsEnabled, + knobGroups.labels + ), + labelBarsFontSize: number( + 'labelBarsFontSize', + theme.labelBarsFontSize, + {}, + knobGroups.labels + ), + labelBarsFontColor: color( + 'labelBarsFontColor', + theme.labelBarsFontColor, + knobGroups.labels + ), + + valuePadding: number( + 'valuePadding', + theme.valuePadding, + {}, + knobGroups.labels + ), + valueFontSize: number( + 'valueFontSize', + theme.valueFontSize, + {}, + knobGroups.labels + ), + valueFontColorInside: color( + 'valueFontColorInside', + theme.valueFontColorInside, + knobGroups.labels + ), + valueFontColorOutside: color( + 'valueFontColorOutside', + theme.valueFontColorOutside, + knobGroups.labels + ), + valueFormater: formatStatValueOptions( + 'valueFormater', + theme.valueFormater as any, + knobGroups.labels + ), + + axesSteps: select( + 'axesSteps', + { + thresholds: 'thresholds', + 0: 0, + 1: 1, + 2: 2, + undefined: null, + }, + 'thresholds', + knobGroups.basics + ), + axesFontSize: number( + 'axesFontSize', + theme.axesFontSize, + {}, + knobGroups.labels + ), + axesFontColor: color('axesFontColor', theme.axesFontColor, knobGroups.labels), + axesFormater: formatStatValueOptions( + 'axesFormater', + theme.axesFormater as any, + knobGroups.labels + ), +}) + +const Story: React.FC<{theme: Theme}> = ({theme}) => { + const normalized = gaugeMiniNormalizeThemeMemoized(theme) + const layer = editableLayer(normalized) + + const config: Config = { + table: gaugeMiniTable(0, 100, layer.numberOfBars), + layers: [layer], + } + return ( + <> + + + + + ) +} + +storiesOf('Gauge mini', module) + .addDecorator(withKnobs) + .add('Bullet', () => ) + .add('Progress', () => )