diff --git a/src/chartLibraries/d3pie/getInitialOptions.js b/src/chartLibraries/d3pie/getInitialOptions.js index 2b8d3d01..d50507c2 100644 --- a/src/chartLibraries/d3pie/getInitialOptions.js +++ b/src/chartLibraries/d3pie/getInitialOptions.js @@ -82,12 +82,24 @@ export default (chartUI, dataOptions = {}) => { truncateLength: 30, }, formatter(context) { - if (context.part === "value") - return `${ - context.realLabel === "No data" - ? "-" - : chartUI.chart.getConvertedValue(context.value, { dimensionId: context.id }) - } ${chartUI.chart.getUnitSign({ dimensionId: context.id })}` + if (context.part === "value") { + if (context.realLabel === "No data") return "-" + + const value = context.signedValue ?? context.value + const unitAttributes = chartUI.chart.getUnitAttributesForValue(value, { + dimensionId: context.id, + }) + const convertedValue = chartUI.chart.getConvertedValue(value, { + dimensionId: context.id, + unitAttributes, + }) + const convertedUnit = chartUI.chart.getUnitSign({ + dimensionId: context.id, + unitAttributes, + }) + + return [convertedValue, convertedUnit].filter(Boolean).join(" ") + } if (context.part === "percentage") return `${context.label}%` return context.label diff --git a/src/chartLibraries/d3pie/getInitialOptions.test.js b/src/chartLibraries/d3pie/getInitialOptions.test.js index 8315c02c..8a8a26f0 100644 --- a/src/chartLibraries/d3pie/getInitialOptions.test.js +++ b/src/chartLibraries/d3pie/getInitialOptions.test.js @@ -1,5 +1,5 @@ import React from "react" -import { renderWithChart } from "@jest/testUtilities" +import { makeHeatmapPayload, renderWithChart } from "@jest/testUtilities" import getInitialOptions from "./getInitialOptions" describe("getInitialOptions", () => { @@ -58,4 +58,32 @@ describe("getInitialOptions", () => { expect(typeof options.labels.formatter).toBe("function") }) + + it("formats a signed value with its atomic scale", async () => { + const { chart } = renderWithChart(
, { + chartType: "pie", + }) + const payload = makeHeatmapPayload(["bytes"], [[-1536]]) + payload.view.chart_type = "pie" + payload.view.units = "By" + payload.view.dimensions.units = ["By"] + + chart.doneFetch(payload) + await new Promise(resolve => setTimeout(resolve, 0)) + + const options = getInitialOptions({ + getElement: () => ({ clientWidth: 400, clientHeight: 300 }), + chart, + }) + + expect( + options.labels.formatter({ + part: "value", + realLabel: "bytes", + id: "bytes", + value: 1536, + signedValue: -1536, + }) + ).toBe("-1.5 KiB") + }) }) diff --git a/src/chartLibraries/d3pie/index.js b/src/chartLibraries/d3pie/index.js index 4eabdbd7..f91372d6 100644 --- a/src/chartLibraries/d3pie/index.js +++ b/src/chartLibraries/d3pie/index.js @@ -77,13 +77,18 @@ export default (sdk, chart) => { const dimensionIds = chart.getVisibleDimensionIds() const values = dimensionIds - .map(id => ({ - label: shortForLength(id, 30), - value: chart.getDimensionValue(id, index), - color: chart.selectDimensionColor(id), - caption: id, - id, - })) + .map(id => { + const signedValue = chart.getDimensionValue(id, index, { abs: false }) + + return { + label: shortForLength(id, 30), + value: Math.abs(signedValue), + signedValue, + color: chart.selectDimensionColor(id), + caption: id, + id, + } + }) .filter(v => !!v.value) let [min, max] = getMinMax() diff --git a/src/chartLibraries/d3pie/library.js b/src/chartLibraries/d3pie/library.js index 21f80910..a69d623a 100644 --- a/src/chartLibraries/d3pie/library.js +++ b/src/chartLibraries/d3pie/library.js @@ -964,6 +964,8 @@ import * as d3 from "d3" formatterContext.index = i formatterContext.part = "value" formatterContext.value = d.value + formatterContext.signedValue = d.signedValue + formatterContext.id = d.id formatterContext.label = d.value formatterContext.realLabel = d.label return settings.formatter ? settings.formatter(formatterContext, d.value) : d.value diff --git a/src/components/bars/dimension.js b/src/components/bars/dimension.js index 8a0fe907..dc611904 100644 --- a/src/components/bars/dimension.js +++ b/src/components/bars/dimension.js @@ -1,10 +1,14 @@ import React from "react" import styled from "styled-components" -import { Flex } from "@netdata/netdata-ui" +import { Flex, TextMicro } from "@netdata/netdata-ui" import Color, { ColorBar } from "@/components/line/dimensions/color" import Name from "@/components/line/dimensions/name" import Value, { Value as ValuePart } from "@/components/line/dimensions/value" -import { useChart, useVisibleDimensionId } from "@/components/provider" +import { + useLatestDisplayValue, + useValueWithUnit, + useVisibleDimensionId, +} from "@/components/provider" import { labels as annotationLabels } from "@/helpers/annotations" import Tooltip from "@/components/tooltip" import { rowFlavours } from "./dimensions" @@ -30,22 +34,29 @@ const rowValueKeys = { default: "value", } -const ValueOnDot = ({ children, fractionDigits = 0, ...rest }) => { - const [first, last] = children.toString().split(".") +const DisplayValue = ({ id, strong, visible, color }) => { + const value = useLatestDisplayValue(id, { allowNull: true }) + const { convertedValue, convertedUnit } = useValueWithUnit(value, { + dimensionId: id, + scaleByValue: true, + }) return ( - - - {first} - - {typeof last !== "undefined" && .} - - {last} + + + {visible ? convertedValue : null} + {visible && !!convertedUnit && ( + + {convertedUnit} + + )} ) } +const PlainValue = props => + const AnnotationsValue = ({ children: annotations, ...rest }) => ( {Object.keys(annotations).map(ann => ( @@ -68,9 +79,7 @@ const AnnotationsValue = ({ children: annotations, ...rest }) => ( const Dimension = ({ id, strong, rowFlavour, fullCols }) => { const visible = useVisibleDimensionId(id) - - const chart = useChart() - const fractionDigits = chart.getAttribute("unitsConversionFractionDigits") + const color = rowFlavour === rowFlavours.default ? "text" : "textLite" return ( @@ -84,14 +93,11 @@ const Dimension = ({ id, strong, rowFlavour, fullCols }) => { - {fullCols && ( <> @@ -100,7 +106,7 @@ const Dimension = ({ id, strong, rowFlavour, fullCols }) => { strong={strong} visible={visible} valueKey="arp" - Component={ValueOnDot} + Component={PlainValue} fractionDigits={2} color={rowFlavour === rowFlavours.ANOMALY_RATE ? "anomalyTextFocus" : "anomalyText"} fontSize="1.1em" diff --git a/src/components/bars/dimensions.js b/src/components/bars/dimensions.js index 4932e8de..4b793a35 100644 --- a/src/components/bars/dimensions.js +++ b/src/components/bars/dimensions.js @@ -2,7 +2,6 @@ import React, { useMemo, memo } from "react" import styled from "styled-components" import { useChart, useAttributeValue, usePayload } from "@/components/provider" import { TextMicro, TextNano } from "@netdata/netdata-ui" -import Units from "@/components/line/dimensions/units" import Dimension from "./dimension" const Grid = styled.div` @@ -108,13 +107,7 @@ const Dimensions = ({ height }) => { color={rowFlavour === rowFlavours.default ? "text" : "textLite"} textAlign="right" > - Value{" "} - + Value {cols === "full" && ( <> diff --git a/src/components/easyPie/index.js b/src/components/easyPie/index.js index 886b53f1..38040ed0 100644 --- a/src/components/easyPie/index.js +++ b/src/components/easyPie/index.js @@ -3,11 +3,10 @@ import styled, { keyframes } from "styled-components" import { Flex, Text } from "@netdata/netdata-ui" import ChartContainer from "@/components/chartContainer" import { - useUnitSign, useAttributeValue, - useLatestConvertedValue, + useLatestDisplayValueWithUnit, useOnResize, - useDimensionIds, + useVisibleDimensionIds, } from "@/components/provider" import withChart from "@/components/hocs/withChart" import { ChartWrapper } from "@/components/hocs/withTile" @@ -20,7 +19,8 @@ export const Label = styled(Text)` ` export const Value = () => { - const value = useLatestConvertedValue("selected") + const [dimensionId] = useVisibleDimensionIds() + const { convertedValue: value } = useLatestDisplayValueWithUnit(dimensionId) return ( diff --git a/src/components/headlessChart/useGroupedChart.js b/src/components/headlessChart/useGroupedChart.js index 605bd161..08991802 100644 --- a/src/components/headlessChart/useGroupedChart.js +++ b/src/components/headlessChart/useGroupedChart.js @@ -37,7 +37,7 @@ const useGroupedChart = ({ sharedMinMax = false } = {}) => { const groupDimensionIds = flattenTree(subTree) const dimensions = groupDimensionIds.map(id => { - const value = chart.getDimensionValue(id, rowIndex) + const value = chart.getDimensionValue(id, rowIndex, { abs: false }) return { id, value, diff --git a/src/components/headlessChart/useGroupedChart.test.js b/src/components/headlessChart/useGroupedChart.test.js index 6311b39e..31a37207 100644 --- a/src/components/headlessChart/useGroupedChart.test.js +++ b/src/components/headlessChart/useGroupedChart.test.js @@ -11,6 +11,18 @@ import useGroupedChart from "./useGroupedChart" const TestWrapper = ({ children }) => {children} +const signedGroupedGauge = { + ...groupedGauge[0], + result: { + ...groupedGauge[0].result, + data: groupedGauge[0].result.data.map(([timestamp, first, ...rest]) => [ + timestamp, + [-Math.abs(first[0]), ...first.slice(1)], + ...rest, + ]), + }, +} + const TestGroupedComponent = ({ sharedMinMax }) => { const { groups, chart, helpers, state } = useGroupedChart({ sharedMinMax }) @@ -26,6 +38,7 @@ const TestGroupedComponent = ({ sharedMinMax }) => {
{groups.map(g => g.value).join(",")}
+
{groups[0]?.dimensions[0]?.value}
{groups.map(g => g.dimensionIds.length).join(",")}
@@ -151,4 +164,21 @@ describe("useGroupedChart", () => { expect(screen.getByTestId("shared-min-max")).toHaveTextContent("yes") }) + + it("preserves signs in render-ready grouped values", async () => { + render( + + + , + { wrapper: TestWrapper } + ) + + await waitFor(() => { + expect(screen.getByTestId("first-dimension-value")).toHaveTextContent(/^-/) + }) + }) }) diff --git a/src/components/headlessChart/useHeadlessChart.js b/src/components/headlessChart/useHeadlessChart.js index 57399a54..c7721d57 100644 --- a/src/components/headlessChart/useHeadlessChart.js +++ b/src/components/headlessChart/useHeadlessChart.js @@ -53,7 +53,7 @@ export const useHeadlessChart = () => { const isCurrentRow = rowIndex === currentRowIndex const dimensions = visibleDimensionIds.map(dimensionId => { - const value = chart.getDimensionValue(dimensionId, rowIndex) + const value = chart.getDimensionValue(dimensionId, rowIndex, { abs: false }) return { id: dimensionId, value, diff --git a/src/components/headlessChart/useHeadlessChart.test.js b/src/components/headlessChart/useHeadlessChart.test.js index 93a9ae0d..49e5b76c 100644 --- a/src/components/headlessChart/useHeadlessChart.test.js +++ b/src/components/headlessChart/useHeadlessChart.test.js @@ -1,9 +1,24 @@ import React from "react" -import { screen } from "@testing-library/react" +import { screen, waitFor } from "@testing-library/react" import "@testing-library/jest-dom" -import { renderWithChart, makeTestChart } from "@jest/testUtilities" +import { renderWithChart, makeTestChart, renderWithProviders } from "@jest/testUtilities" +import makeMockPayload from "@/helpers/makeMockPayload" +import systemLoadLine from "../../../fixtures/systemLoadLine" +import HeadlessChart from "." import useHeadlessChart from "./useHeadlessChart" +const signedSystemLoad = { + ...systemLoadLine[0], + result: { + ...systemLoadLine[0].result, + data: systemLoadLine[0].result.data.map(([timestamp, first, ...rest]) => [ + timestamp, + [-Math.abs(first[0]), ...first.slice(1)], + ...rest, + ]), + }, +} + const TestComponent = () => { const { chart, data, dimensionIds, helpers, state } = useHeadlessChart() @@ -78,4 +93,24 @@ describe("useHeadlessChart", () => { expect(screen.getByTestId("data-available")).toHaveTextContent(/^(has data|no data)$/) expect(screen.getByTestId("dimensions-count")).toHaveTextContent(/^\d+$/) }) -}) \ No newline at end of file + + it("preserves signs in render-ready dimension values", async () => { + const SignedValue = () => { + const { currentRow } = useHeadlessChart() + return
{currentRow?.dimensions[0]?.value}
+ } + + renderWithProviders( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId("signed-value")).toHaveTextContent(/^-/) + }) + }) +}) diff --git a/src/components/hocs/withTile.js b/src/components/hocs/withTile.js index 9efcb780..22ab6641 100644 --- a/src/components/hocs/withTile.js +++ b/src/components/hocs/withTile.js @@ -10,6 +10,8 @@ import { useChart, useAttributeValue, useTitle, + useUnitSign, + useIsMinimal, useOnResize, useDimensionIds, useColor, @@ -40,6 +42,9 @@ const ChartHeadWrapper = styled(Flex).attrs(({ size, ...rest }) => ({ export const Title = () => { const chart = useChart() const title = useTitle() + const units = useUnitSign({ withoutConversion: true, long: true }) + const hideUnits = useAttributeValue("hideUnits") + const isMinimal = useIsMinimal() const onClick = event => { event.preventDefault() @@ -47,17 +52,27 @@ export const Title = () => { } return ( - + + {!!units && !hideUnits && !isMinimal && ( + + + + )} + ) } diff --git a/src/components/hocs/withTile.test.js b/src/components/hocs/withTile.test.js new file mode 100644 index 00000000..7144bca9 --- /dev/null +++ b/src/components/hocs/withTile.test.js @@ -0,0 +1,24 @@ +import React from "react" +import { screen } from "@testing-library/react" +import "@testing-library/jest-dom" +import { makeHeatmapPayload, renderWithChart } from "@jest/testUtilities" +import { Title } from "./withTile" + +describe("tile title", () => { + it("shows the normalized source unit", async () => { + const { chart } = renderWithChart(
) + const payload = makeHeatmapPayload(["storage"], [[1024]]) + payload.view.title = "Storage change" + payload.view.chart_type = "number" + payload.view.units = "KiB" + payload.view.dimensions.units = ["KiB"] + + chart.doneFetch(payload) + await new Promise(resolve => setTimeout(resolve, 0)) + + renderWithChart(, { chart }) + + expect(screen.getByText("Storage change")).toBeInTheDocument() + expect(screen.getByText("• [bytes]")).toBeInTheDocument() + }) +}) diff --git a/src/components/line/dimensions/value.js b/src/components/line/dimensions/value.js index 552ad3d5..dc85463e 100644 --- a/src/components/line/dimensions/value.js +++ b/src/components/line/dimensions/value.js @@ -1,6 +1,6 @@ import React from "react" import { TextSmall } from "@netdata/netdata-ui" -import { useConvertedValue } from "@/components/provider" +import { useDisplayConvertedValue } from "@/components/provider" export const Value = props => ( <TextSmall color="text" data-testid="chartDimensions-value" {...props} /> @@ -17,7 +17,7 @@ const ValueComponent = ({ scaleByValue, ...rest }) => { - const value = useConvertedValue(id, period, { + const value = useDisplayConvertedValue(id, period, { valueKey, objKey, allowNull: true, diff --git a/src/components/line/legend/dimension.js b/src/components/line/legend/dimension.js index 38adad1f..2d82bc87 100644 --- a/src/components/line/legend/dimension.js +++ b/src/components/line/legend/dimension.js @@ -9,6 +9,7 @@ import Units from "@/components/line/dimensions/units" import { useVisibleDimensionId, useChart, + useLatestDisplayValue, useLatestValue, useUnitSign, useIsMinimal, @@ -76,7 +77,7 @@ const TooltipContent = props => <Flex {...tooltipStyleProps} {...props} column g const TooltipValue = ({ id, name }) => { const units = useUnitSign({ long: true, dimensionId: id, withoutConversion: true }) - const value = useLatestValue(id) + const value = useLatestDisplayValue(id) return ( <> @@ -93,7 +94,7 @@ const TooltipValue = ({ id, name }) => { const Dimension = ({ id, ref }) => { const visible = useVisibleDimensionId(id) const chart = useChart() - const value = useLatestValue(id, { allowNull: true }) + const value = useLatestDisplayValue(id, { allowNull: true }) const name = chart.getDimensionName(id) diff --git a/src/components/line/overlays/latestValue.js b/src/components/line/overlays/latestValue.js index 57c304c2..183d0dc2 100644 --- a/src/components/line/overlays/latestValue.js +++ b/src/components/line/overlays/latestValue.js @@ -1,7 +1,7 @@ import React from "react" import styled from "styled-components" import { Flex, Text, getColor } from "@netdata/netdata-ui" -import { useLatestConvertedValue, useUnitSign, useOnResize } from "@/components/provider" +import { useLatestDisplayValueWithUnit, useOnResize } from "@/components/provider" import FontSizer from "@/components/helpers/fontSizer" const StrokeLabel = styled(Text)` @@ -22,8 +22,8 @@ const defaultTextProps = { } const LatestValue = ({ dimensionId, textProps, ...rest }) => { - const unit = useUnitSign({ dimensionId }) - const value = useLatestConvertedValue(dimensionId) + const { convertedValue: value, convertedUnit: unit } = + useLatestDisplayValueWithUnit(dimensionId) const { width, height } = useOnResize() if (!value || value === "-") diff --git a/src/components/line/popover/dimension.js b/src/components/line/popover/dimension.js index 77eadc7a..b54a4a00 100644 --- a/src/components/line/popover/dimension.js +++ b/src/components/line/popover/dimension.js @@ -6,7 +6,7 @@ import Name from "@/components/line/dimensions/name" import Units from "@/components/line/dimensions/units" import Value, { Value as ValuePart } from "@/components/line/dimensions/value" import { - useLatestValue, + useLatestDisplayValue, useValueUnitAttributes, useVisibleDimensionId, } from "@/components/provider" @@ -66,7 +66,7 @@ const UnitCell = styled(Flex).attrs({ const ValueWithUnits = ({ id, visible, children, ...rest }) => { const isHeatmap = useIsHeatmap() - const value = useLatestValue(id, { allowNull: true }) + const value = useLatestDisplayValue(id, { allowNull: true }) const unitAttributes = useValueUnitAttributes(value, { dimensionId: id, scaleByValue: true, diff --git a/src/components/line/popover/dimensions.js b/src/components/line/popover/dimensions.js index b7808a7c..15b29022 100644 --- a/src/components/line/popover/dimensions.js +++ b/src/components/line/popover/dimensions.js @@ -37,12 +37,6 @@ const GridHeader = styled.div` display: contents; ` -const UnitHeader = styled(TextMicro).attrs({ - padding: [0, 0, 0, 2], -})` - box-sizing: border-box; -` - const emptyArray = [null, null] const getMaxDimensions = () => { @@ -144,7 +138,7 @@ const Dimensions = () => { > Value </TextMicro> - <UnitHeader color="textLite">{!isHeatmap && "Unit"}</UnitHeader> + <span aria-hidden="true" /> <TextMicro strong={rowFlavour === rowFlavours.ANOMALY_RATE} color={rowFlavour === rowFlavours.ANOMALY_RATE ? "text" : "textLite"} diff --git a/src/components/line/popover/dimensions.test.js b/src/components/line/popover/dimensions.test.js index ba91f0c2..58ede342 100644 --- a/src/components/line/popover/dimensions.test.js +++ b/src/components/line/popover/dimensions.test.js @@ -138,6 +138,27 @@ describe("line popover Dimensions", () => { expect(valueCell).toHaveTextContent("1") expect(unit).toHaveTextContent("KiB") expect(unitCell).toContainElement(unit) + expect(screen.queryByText("Unit", { exact: true })).not.toBeInTheDocument() + }) + + it("preserves the sign while scaling a popover value by magnitude", async () => { + const ids = ["bytes"] + const { chart } = makeTestChart({ + attributes: { + chartType: "line", + groupBy: [], + selectedDimensions: [], + selectedLegendDimensions: [], + }, + }) + + await loadLinePayload(chart, ids, [[-1536]], { units: "By" }) + chart.updateAttribute("hoverX", [1000, ids[0]]) + + renderWithChart(<Dimensions />, { chart }) + + expect(screen.getAllByTestId("chartDimensions-value")[0]).toHaveTextContent("-1.5") + expect(screen.getByTestId("chartDimensions-units")).toHaveTextContent("KiB") }) it("renders context, source units, timestamp, and both granularities in the header", async () => { diff --git a/src/components/number/index.js b/src/components/number/index.js index 9985f788..d4e8776a 100644 --- a/src/components/number/index.js +++ b/src/components/number/index.js @@ -2,10 +2,9 @@ import React from "react" import { Text } from "@netdata/netdata-ui" import ChartContainer from "@/components/chartContainer" import { - useUnitSign, - useLatestConvertedValue, + useLatestDisplayValueWithUnit, useOnResize, - useDimensionIds, + useVisibleDimensionIds, } from "@/components/provider" import withChart from "@/components/hocs/withChart" import { ChartWrapper } from "@/components/hocs/withTile" @@ -13,7 +12,8 @@ import FontSizer from "@/components/helpers/fontSizer" export const Value = props => { const { width, height } = useOnResize() - const value = useLatestConvertedValue("selected") + const [dimensionId] = useVisibleDimensionIds() + const { convertedValue: value } = useLatestDisplayValueWithUnit(dimensionId) return ( <FontSizer @@ -31,8 +31,8 @@ export const Value = props => { export const Unit = props => { const { width, height } = useOnResize() - const [firstDimId] = useDimensionIds() - const unit = useUnitSign({ dimensionId: firstDimId }) + const [dimensionId] = useVisibleDimensionIds() + const { convertedUnit: unit } = useLatestDisplayValueWithUnit(dimensionId) if (!unit) return null diff --git a/src/components/provider/selectors.js b/src/components/provider/selectors.js index ce27b319..1e233833 100644 --- a/src/components/provider/selectors.js +++ b/src/components/provider/selectors.js @@ -260,6 +260,22 @@ export const useDimensionIds = () => { return chart.getDimensionIds() } +export const useVisibleDimensionIds = () => { + const chart = useChart() + const forceUpdate = useForceUpdate() + + useImmediateListener( + () => + unregister( + chart.on("dimensionChanged", forceUpdate), + chart.on("visibleDimensionsChanged", forceUpdate) + ), + [chart] + ) + + return chart.getVisibleDimensionIds() +} + export const useUnitSign = ({ key = "units", ...options } = {}) => { const chart = useChart() @@ -607,22 +623,48 @@ export const useValue = ( chart.onAttributeChange(`${unitsKey}Conversion`, () => setState(getValue())), chart.on("render", () => setState(getValue())) ) - }, [chart, id, valueKey, period, unitsKey]) + }, [chart, id, valueKey, period, unitsKey, abs, allowNull]) return value } export const useLatestValue = (id, options = {}) => useValue(id, "latest", options) +export const useDisplayValue = (id, period = "latest", options = {}) => + useValue(id, period, { ...options, abs: false }) + +export const useLatestDisplayValue = (id, options = {}) => + useDisplayValue(id, "latest", options) + export const useConvertedValue = (id, period = "latest", options = {}) => { const value = useValue(id, period, options) return useConverted(value, { ...options, dimensionId: id }) } +export const useDisplayConvertedValue = (id, period = "latest", options = {}) => { + const value = useDisplayValue(id, period, options) + + return useConverted(value, { ...options, dimensionId: id }) +} + export const useLatestConvertedValue = (id, options = {}) => useConvertedValue(id, "latest", { allowNull: true, dimensionId: id, ...options }) +export const useLatestDisplayConvertedValue = (id, options = {}) => + useDisplayConvertedValue(id, "latest", { allowNull: true, dimensionId: id, ...options }) + +export const useLatestDisplayValueWithUnit = (id, options = {}) => { + const value = useLatestDisplayValue(id, { allowNull: true, ...options }) + const converted = useValueWithUnit(value, { + ...options, + dimensionId: id, + scaleByValue: true, + }) + + return { value, ...converted } +} + export const useIsMinimal = () => useAttributeValue("designFlavour") === "minimal" export const usePlotArea = (uiName = "default") => { diff --git a/src/components/provider/selectors.test.js b/src/components/provider/selectors.test.js index ae04ae69..3a6ae7b8 100644 --- a/src/components/provider/selectors.test.js +++ b/src/components/provider/selectors.test.js @@ -1,5 +1,5 @@ import { renderHook, act } from "@testing-library/react" -import { renderHookWithChart, makeTestChart } from "@jest/testUtilities" +import { makeHeatmapPayload, renderHookWithChart, makeTestChart } from "@jest/testUtilities" import { useChart, useAttributeValue, @@ -8,6 +8,9 @@ import { useTitle, useName, useIsMinimal, + useLatestDisplayValue, + useLatestDisplayValueWithUnit, + useLatestValue, getValueByPeriod, } from "./selectors" @@ -304,4 +307,39 @@ describe("Chart Provider Selectors", () => { }) }) }) + + describe("display values", () => { + it("keeps magnitude retrieval separate from signed atomic presentation", async () => { + const payload = makeHeatmapPayload(["bytes"], [[-1536]]) + payload.view.chart_type = "line" + payload.view.units = "By" + payload.view.dimensions.grouped_by = [] + payload.view.dimensions.units = ["By"] + + const { chart } = makeTestChart({ + attributes: { + groupBy: [], + selectedDimensions: [], + selectedLegendDimensions: [], + }, + }) + + chart.doneFetch(payload) + await new Promise(resolve => setTimeout(resolve, 0)) + + const { result } = renderHookWithChart( + () => ({ + magnitude: useLatestValue("bytes", { allowNull: true }), + display: useLatestDisplayValue("bytes", { allowNull: true }), + formatted: useLatestDisplayValueWithUnit("bytes"), + }), + { chart } + ) + + expect(result.current.magnitude).toBe(1536) + expect(result.current.display).toBe(-1536) + expect(result.current.formatted.convertedValue).toBe("-1.5") + expect(result.current.formatted.convertedUnit).toBe("KiB") + }) + }) }) diff --git a/src/components/table/columns.js b/src/components/table/columns.js index f1cd1174..96dea9bd 100644 --- a/src/components/table/columns.js +++ b/src/components/table/columns.js @@ -1,16 +1,16 @@ import React from "react" -import { Flex, TextSmall } from "@netdata/netdata-ui" +import styled from "styled-components" +import { Flex, TextMicro, TextSmall, getSizeBy } from "@netdata/netdata-ui" import Color from "@/components/line/dimensions/color" import Name from "@/components/line/dimensions/name" -import Units from "@/components/line/dimensions/units" -import Value, { Value as ValuePart } from "@/components/line/dimensions/value" import { getValueByPeriod, useChart, useAttributeValue, useVisibleDimensionId, - useLatestValue, + useLatestDisplayValue, useUnitSign, + useValueWithUnit, } from "@/components/provider" import Tooltip from "@/components/tooltip" import sanitizeId from "@/helpers/sanitizeId" @@ -94,26 +94,51 @@ export const labelColumn = (chart, { fallbackExpandKey, partIndex, header = "Nam const compareBasic = (a, b) => (a === b ? 0 : a > b ? 1 : -1) -const ValueOnDot = ({ children, fractionDigits = 0, ref, ...rest }) => { - const [first, last] = children.toString().split(".") - fractionDigits = fractionDigits === -1 ? 4 : fractionDigits +export const findDimensionId = (value, key) => { + const ids = Array.isArray(value) ? value : value == null ? [] : [value] + + return ids.find(id => typeof id === "string" && id.includes(key)) +} + +const getRowDimensionId = (keysStr, { key, ids, contextGroups }) => + findDimensionId(getValue(keysStr, ids, contextGroups, "|"), key) + +const ValueCell = styled(Flex).attrs({ + alignItems: "center", + width: "100%", +})` + display: grid; + grid-template-columns: minmax(0, 1fr) ${getSizeBy(5.5)}; +` + +const DisplayValue = ({ id, visible }) => { + const value = useLatestDisplayValue(id, { allowNull: true }) + const { convertedValue, convertedUnit } = useValueWithUnit(value, { + dimensionId: id, + scaleByValue: true, + }) + + if (!visible) return null return ( - <Flex alignItems="center" justifyContent="start" ref={ref}> - <ValuePart {...rest} flex={false} basis={3 * 1.6} textAlign="right"> - {first} - </ValuePart> - {typeof last !== "undefined" && <ValuePart {...rest}>.</ValuePart>} - <ValuePart as={Flex} flex={false} width={fractionDigits * 1.6} {...rest} textAlign="left"> - {last} - </ValuePart> - </Flex> + <ValueCell> + <TextSmall color="text" textAlign="right" whiteSpace="nowrap"> + {convertedValue} + </TextSmall> + <Flex alignItems="center" overflow="hidden" padding={[0, 0, 0, 2]} width={{ min: "0px" }}> + {!!convertedUnit && ( + <TextMicro color="textDescription" whiteSpace="nowrap" truncate> + {convertedUnit} + </TextMicro> + )} + </Flex> + </ValueCell> ) } const TooltipValue = ({ id }) => { const units = useUnitSign({ long: true, dimensionId: id, withoutConversion: true }) - const value = useLatestValue(id) + const value = useLatestDisplayValue(id) return `${value} ${units}` } @@ -124,27 +149,16 @@ export const valueColumn = (chart, { dimensionLabel = "Value", dimensionId, keys return { id: sanitizeId(`value${keysStr}`), name: dimensionLabel, - header: () => { - return ( - <Flex column> - <TextSmall>{dimensionLabel}</TextSmall> - <Units visible dimensionId={dimensionId} /> - </Flex> - ) - }, + header: () => <TextSmall>{dimensionLabel}</TextSmall>, sortingFn: (rowA, rowB) => { return compareBasic( getValueByPeriod.latest({ chart, - id: (getValue(keysStr, null, rowA.original.contextGroups, "|") || rowA.original.ids).find( - id => id.includes(rowA.original.key) - ), + id: getRowDimensionId(keysStr, rowA.original), }), getValueByPeriod.latest({ chart, - id: (getValue(keysStr, null, rowB.original.contextGroups, "|") || rowB.original.ids).find( - id => id.includes(rowB.original.key) - ), + id: getRowDimensionId(keysStr, rowB.original), }) ) }, @@ -166,22 +180,12 @@ export const valueColumn = (chart, { dimensionLabel = "Value", dimensionId, keys original: { key, ids, contextGroups }, }, }) => { - const id = (getValue(keysStr, null, contextGroups, "|") || ids).find(id => id.includes(key)) + const id = getRowDimensionId(keysStr, { key, ids, contextGroups }) const visible = useVisibleDimensionId(id) - const chart = useChart() - const fractionDigits = chart.getAttribute("unitsConversionFractionDigits") - return ( <Tooltip content={visible ? <TooltipValue id={id} /> : null}> - <Value - period="latest" - id={id} - visible={visible} - Component={ValueOnDot} - fractionDigits={fractionDigits} - color="text" - /> + <DisplayValue id={id} visible={visible} /> </Tooltip> ) }, diff --git a/src/components/table/columns.test.js b/src/components/table/columns.test.js new file mode 100644 index 00000000..da999f3d --- /dev/null +++ b/src/components/table/columns.test.js @@ -0,0 +1,15 @@ +import { findDimensionId } from "./columns" + +describe("table value columns", () => { + it("finds a dimension in grouped arrays", () => { + expect(findDimensionId(["ctx,read", "ctx,write"], "write")).toBe("ctx,write") + }) + + it("accepts a grouped leaf containing one dimension", () => { + expect(findDimensionId("ctx,read", "read")).toBe("ctx,read") + }) + + it("returns undefined for missing grouped dimensions", () => { + expect(findDimensionId(null, "read")).toBeUndefined() + }) +}) diff --git a/src/helpers/formatNumber/index.js b/src/helpers/formatNumber/index.js new file mode 100644 index 00000000..96f0eed5 --- /dev/null +++ b/src/helpers/formatNumber/index.js @@ -0,0 +1,55 @@ +const maxRegularNumberLength = 18 +const numberFormatters = new Map() + +const formatExponential = value => + value + .toExponential(3) + .replace(/(\.\d*?)0+e/, "$1e") + .replace(/\.e/, "e") + +const getNumberFormatter = (locale, minimumFractionDigits, maximumFractionDigits) => { + const key = JSON.stringify([locale, minimumFractionDigits, maximumFractionDigits], (_, value) => + typeof value === "undefined" ? "__undefined__" : value + ) + + if (numberFormatters.has(key)) return numberFormatters.get(key) + + const formatter = Intl.NumberFormat(locale || undefined, { + useGrouping: true, + minimumFractionDigits, + maximumFractionDigits, + }) + + numberFormatters.set(key, formatter) + + return formatter +} + +export const formatRegularNumber = (node, value, fractionDigits, unitsConversionFractionDigits) => { + const minimumFractionDigits = isNaN(fractionDigits) || fractionDigits < 0 ? 0 : fractionDigits + const maximumFractionDigits = + fractionDigits === null || isNaN(fractionDigits) || fractionDigits < 0 + ? unitsConversionFractionDigits === -1 + ? 4 + : unitsConversionFractionDigits + : fractionDigits + + return getNumberFormatter( + node.getAttribute("locale"), + minimumFractionDigits, + maximumFractionDigits + ).format(value) +} + +export const shouldUseExponential = (node, value, fractionDigits, unitsConversionFractionDigits) => + Number.isFinite(value) && + formatRegularNumber(node, value, fractionDigits, unitsConversionFractionDigits).length > + maxRegularNumberLength + +export default (node, value, fractionDigits, unitsConversionFractionDigits) => { + const formatted = formatRegularNumber(node, value, fractionDigits, unitsConversionFractionDigits) + + if (!Number.isFinite(value) || formatted.length <= maxRegularNumberLength) return formatted + + return formatExponential(value) +} diff --git a/src/helpers/heatmap.js b/src/helpers/heatmap.js index 33af92e1..665a3214 100644 --- a/src/helpers/heatmap.js +++ b/src/helpers/heatmap.js @@ -16,7 +16,7 @@ export const useIsHeatmap = () => useAttributeValue("chartType") === "heatmap" export const isIncremental = chart => isHeatmap(chart) && chart.getAttribute("heatmapType") === heatmapTypes.incremental -const regex = /(.+)_(\d+?\.?(\d+)?|\+[Ii]nf)$/ +const regex = /(.+)_(-?\d+?\.?(\d+)?|\+[Ii]nf)$/ export const heatmapOrChartType = (ids, chartType) => Array.isArray(ids) && ids.length && ids.every(id => id.match(regex)) ? "heatmap" : chartType @@ -55,7 +55,7 @@ export const useGetColor = (opacity = 1) => { } export const withoutPrefix = label => - label ? label.replace(/.+_(\d+?\.?(\d+)?|\+[Ii]nf)$/, "$1") : label + label ? label.replace(/.+_(-?\d+?\.?(\d+)?|\+[Ii]nf)$/, "$1") : label export const cropHeatmapZeroEdges = (ids, isZeroOnly, minVisible = 5) => { if (!Array.isArray(ids) || typeof isZeroOnly !== "function" || ids.length <= minVisible) diff --git a/src/helpers/heatmapScale.js b/src/helpers/heatmapScale.js index a73f3656..0bff2e8a 100644 --- a/src/helpers/heatmapScale.js +++ b/src/helpers/heatmapScale.js @@ -1,8 +1,8 @@ import scalableUnits, { keys as scalableUnitKeys } from "@/helpers/units/scalableUnits" -const heatmapValueRegex = /^\d*\.?\d+$/ +const heatmapValueRegex = /^-?\d*\.?\d+$/ const heatmapInfRegex = /^\+inf$/i -const heatmapPrefixRegex = /^.+_(\d*\.?\d+|\+[Ii]nf)$/ +const heatmapPrefixRegex = /^.+_(-?\d*\.?\d+|\+[Ii]nf)$/ const getHeatmapValueLabel = value => { if (typeof value !== "string") return value @@ -59,7 +59,9 @@ const getScaleEntries = scale => })) const formatNumber = value => { - if (value > 0 && value < 0.01) return parseFloat(value.toPrecision(3)).toString() + const magnitude = Math.abs(value) + + if (magnitude > 0 && magnitude < 0.01) return parseFloat(value.toPrecision(3)).toString() return parseFloat(value.toFixed(2)).toString() } @@ -68,9 +70,10 @@ export const formatScaledValue = (value, scale) => { if (!value) return "0" const entries = getScaleEntries(scale) + const magnitude = Math.abs(value) const entry = entries.reduce( (selected, candidate) => - candidate.divider <= value && candidate.divider > selected.divider ? candidate : selected, + candidate.divider <= magnitude && candidate.divider > selected.divider ? candidate : selected, { prefix: "", divider: 0 } ) diff --git a/src/helpers/heatmapScale.test.js b/src/helpers/heatmapScale.test.js index 9ccbea59..a4a4f15d 100644 --- a/src/helpers/heatmapScale.test.js +++ b/src/helpers/heatmapScale.test.js @@ -17,12 +17,15 @@ describe("heatmapScale", () => { expect(parseHeatmapValue("0.005")).toBe(0.005) expect(parseHeatmapValue("2.5")).toBe(2.5) expect(parseHeatmapValue(".5")).toBe(0.5) + expect(parseHeatmapValue("-0.005")).toBe(-0.005) + expect(parseHeatmapValue("-1024")).toBe(-1024) }) it("parses prefixed bucket labels", () => { expect(parseHeatmapValue("bucket_1")).toBe(1) expect(parseHeatmapValue("latency_0.005")).toBe(0.005) expect(parseHeatmapValue("bucket_+Inf")).toBe(Infinity) + expect(parseHeatmapValue("latency_-0.005")).toBe(-0.005) }) it("parses positive infinity variants", () => { @@ -99,6 +102,16 @@ describe("heatmapScale", () => { ]) }) + it("sorts signed bucket boundaries numerically", () => { + expect(sortHeatmapValues(["1024", "-1", "0", "-1024", "+Inf"])).toEqual([ + "-1024", + "-1", + "0", + "1024", + "+Inf", + ]) + }) + it("sorts the live Prometheus bucket shape", () => { expect(sortHeatmapValues(["+Inf", "0.3", "10", "120", "15", "2", "2.5"])).toEqual([ "0.3", @@ -140,6 +153,8 @@ describe("heatmapScale", () => { expect(formatScaledValue(1250, "num")).toBe("1.25k") expect(formatScaledValue(1000000, "num")).toBe("1M") expect(formatScaledValue(0, "num")).toBe("0") + expect(formatScaledValue(-0.005, "num")).toBe("-5m") + expect(formatScaledValue(-1500, "num")).toBe("-1.5k") }) it("formats binary values compactly", () => { @@ -149,6 +164,7 @@ describe("heatmapScale", () => { expect(formatScaledValue(1536, "binary")).toBe("1.5Ki") expect(formatScaledValue(1048576, "binary")).toBe("1Mi") expect(formatScaledValue(0, "binary")).toBe("0") + expect(formatScaledValue(-1536, "binary")).toBe("-1.5Ki") }) }) @@ -164,6 +180,7 @@ describe("heatmapScale", () => { expect(formatHeatmapLabel("0.005", "num")).toBe("5m") expect(formatHeatmapLabel("1024", "binary")).toBe("1Ki") expect(formatHeatmapLabel("bucket_1024", "binary")).toBe("1Ki") + expect(formatHeatmapLabel("bucket_-1024", "binary")).toBe("-1Ki") }) it("passes through fallback labels", () => { diff --git a/src/helpers/unitConversion/getConversionUnits.js b/src/helpers/unitConversion/getConversionUnits.js index 09497418..f0f0a481 100644 --- a/src/helpers/unitConversion/getConversionUnits.js +++ b/src/helpers/unitConversion/getConversionUnits.js @@ -2,6 +2,7 @@ import conversableUnits, { makeConversableKey, keys as conversableKeys, } from "@/helpers/units/conversableUnits" +import { shouldUseExponential } from "@/helpers/formatNumber" import convert, { getScales, getUnitConfig, isScalable, getExponent } from "@/helpers/units" const selfOrExponent = (u, scaleByKey) => { @@ -138,14 +139,11 @@ const getMethod = (chart, units, min, max, maxDecimals) => { return scalable(chart, units, min, max, desiredUnits, maxDecimals) } -export const getConversionAttributes = (chart, unit, { min, max, maxDecimals = precisionHardMax }) => { - const [method, divider, prefix = "", base = ""] = getMethod(chart, unit, min, max, maxDecimals) - +const makeConversionAttributes = (chart, unit, candidate, min, max, maxDecimals) => { + const [method, divider, prefix = "", base = ""] = candidate const cMin = convert(chart, method, min, divider) const cMax = convert(chart, method, max, divider) - const delta = Math.abs(cMin === cMax ? cMin : cMax - cMin) - const fractionDigits = isNaN(delta) || delta === 0 ? -1 : getFractionDigits(delta) return { @@ -158,6 +156,43 @@ export const getConversionAttributes = (chart, unit, { min, max, maxDecimals = p } } +const usesExponentialNotation = (chart, { method, divider, fractionDigits }, min, max) => { + const staticFractionDigits = chart.getAttribute("staticFractionDigits") + + return [min, max].some(value => + shouldUseExponential( + chart, + convert(chart, method, value, divider), + staticFractionDigits, + fractionDigits + ) + ) +} + +const getUnprefixedCandidate = unit => { + const [, scaleByKey] = getScales(unit) + const config = getUnitConfig(unit) + const base = config.base_unit ?? config.print_symbol ?? unit + const sourceScale = selfOrExponent(config.prefix_symbol, scaleByKey) + + return makeScalableCandidate("1", scaleByKey, sourceScale, base) +} + +export const getConversionAttributes = ( + chart, + unit, + { min, max, maxDecimals = precisionHardMax } +) => { + const candidate = getMethod(chart, unit, min, max, maxDecimals) + const attributes = makeConversionAttributes(chart, unit, candidate, min, max, maxDecimals) + + if (!attributes.prefix || !usesExponentialNotation(chart, attributes, min, max)) { + return attributes + } + + return makeConversionAttributes(chart, unit, getUnprefixedCandidate(unit), min, max, maxDecimals) +} + const getConversionUnits = (chart, unitsKey, options = {}) => { const units = chart.getAttribute(unitsKey) diff --git a/src/helpers/unitConversion/getConversionUnits.test.js b/src/helpers/unitConversion/getConversionUnits.test.js index f183dcc8..85aae9a2 100644 --- a/src/helpers/unitConversion/getConversionUnits.test.js +++ b/src/helpers/unitConversion/getConversionUnits.test.js @@ -44,6 +44,17 @@ describe("getConversionUnits", () => { expect(typeof result.divider).toBe("function") }) + it("uses the normalized base unit when a scaled value needs exponential notation", () => { + chart.updateAttributes({ units: ["KiBy"], desiredUnits: ["auto"] }) + + const result = getConversionAttributes(chart, "KiBy", { min: 6.999e99, max: 6.999e99 }) + + expect(result).toMatchObject({ base: "By", prefix: "" }) + expect(chart.getConvertedValueWithUnit(6.999e99, { unitAttributes: result })).toBe( + "7.167e+102 B" + ) + }) + it("handles zero range", () => { const result = getConversionAttributes(chart, "By", { min: 100, max: 100 }) diff --git a/src/sdk/makeChart/index.js b/src/sdk/makeChart/index.js index 11444d0f..a82fa712 100644 --- a/src/sdk/makeChart/index.js +++ b/src/sdk/makeChart/index.js @@ -1,5 +1,6 @@ import makeKeyboardListener from "@/helpers/makeKeyboardListener" import makeExecuteLatest from "@/helpers/makeExecuteLatest" +import formatNumber from "@/helpers/formatNumber" import convert from "@/helpers/units" import unitConversion from "@/helpers/unitConversion" import makeNode from "../makeNode" @@ -17,31 +18,6 @@ const themeIndex = { } const maxBackoffMs = 30 * 1000 -const maxRegularNumberLength = 18 - -const formatExponential = value => - value - .toExponential(3) - .replace(/(\.\d*?)0+e/, "$1e") - .replace(/\.e/, "e") - -const formatNumber = (node, converted, fractionDigits, unitsConversionFractionDigits) => { - const formatted = Intl.NumberFormat(node.getAttribute("locale") || undefined, { - useGrouping: true, - minimumFractionDigits: isNaN(fractionDigits) || fractionDigits < 0 ? 0 : fractionDigits, - maximumFractionDigits: - fractionDigits === null || isNaN(fractionDigits) || fractionDigits < 0 - ? unitsConversionFractionDigits === -1 - ? 4 - : unitsConversionFractionDigits - : fractionDigits, - }).format(converted) - - if (!Number.isFinite(converted) || formatted.length <= maxRegularNumberLength) return formatted - - return formatExponential(converted) -} - const defaultMakeTrack = () => value => value export default ({ diff --git a/src/sdk/makeChart/index.test.js b/src/sdk/makeChart/index.test.js index c08f8705..23000534 100644 --- a/src/sdk/makeChart/index.test.js +++ b/src/sdk/makeChart/index.test.js @@ -166,10 +166,20 @@ describe("makeChart", () => { }) it("uses exponential notation for unreadably large raw values", () => { - chart.updateAttribute("units", ["raw-events"]) + chart.updateAttributes({ units: ["raw-events"], desiredUnits: ["auto"] }) + const unitAttributes = chart.getUnitAttributesForValue(6.999e99) + + expect(unitAttributes.prefix).toBe("") + expect(chart.getConvertedValue(6.999e99, { unitAttributes })).toBe("6.999e+99") + expect(chart.getConvertedValueWithUnit(6.999e99, { unitAttributes })).toBe("6.999e+99") + }) + + it("normalizes pre-scaled units without adding a prefix to exponential values", () => { + chart.updateAttributes({ units: ["KiBy"], desiredUnits: ["auto"] }) + const unitAttributes = chart.getUnitAttributesForValue(6.999e99) - expect(chart.getConvertedValue(6.999e99)).toBe("6.999e+99") - expect(chart.getConvertedValueWithUnit(6.999e99)).toBe("6.999e+99") + expect(unitAttributes).toMatchObject({ base: "By", prefix: "" }) + expect(chart.getConvertedValueWithUnit(6.999e99, { unitAttributes })).toBe("7.167e+102 B") }) it("formats seconds below one microsecond as nanoseconds", () => { diff --git a/src/unitsScalingChartTypes.stories.js b/src/unitsScalingChartTypes.stories.js new file mode 100644 index 00000000..e130ab4b --- /dev/null +++ b/src/unitsScalingChartTypes.stories.js @@ -0,0 +1,462 @@ +import React, { useEffect, useMemo } from "react" +import styled from "styled-components" +import { Flex, TextMicro, TextSmall, getSizeBy } from "@netdata/netdata-ui" +import Bars from "@/components/bars" +import D3pie from "@/components/d3pie" +import EasyPie from "@/components/easyPie" +import Gauge from "@/components/gauge" +import GroupBoxes from "@/components/groupBoxes" +import Line from "@/components/line" +import NumberChart from "@/components/number" +import Status from "@/components/status" +import Table from "@/components/table" +import Fullscreen from "@/components/toolbox/fullscreen" +import Settings from "@/components/toolbox/settings" +import makeMockPayload from "@/helpers/makeMockPayload" +import makeDefaultSDK from "./makeDefaultSDK" + +const now = 1729763970000 +const points = 97 +const updateEvery = 5 + +const Page = styled(Flex).attrs({ + column: true, + gap: 6, + padding: [4], + width: { min: "0px", max: 360, base: "100%" }, +})` + box-sizing: border-box; +` + +const Matrix = styled(Flex).attrs({ + gap: 4, + width: "100%", +})` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(${getSizeBy(72)}, 1fr)); +` + +const ChartSurface = styled(Flex).attrs(({ height }) => ({ + column: true, + width: { min: "0px", base: "100%" }, + height, + background: "tableRowBg", + position: "relative", + round: true, +}))` + box-sizing: border-box; +` + +const range = values => ({ + min: Math.min(...values), + max: Math.max(...values), + avg: values.reduce((sum, value) => sum + value, 0) / values.length, +}) + +const makeWave = ({ center, amplitude, cycles = 2.5, phase = 0, ripple = 0.08 }) => + Array.from({ length: points }, (_, index) => { + const progress = index / (points - 1) + const primary = Math.sin(progress * Math.PI * 2 * cycles + phase) + const secondary = Math.sin(progress * Math.PI * 2 * cycles * 0.37 + phase * 0.5) + + return center + amplitude * primary + amplitude * ripple * secondary + }) + +const makeAnomalyRates = seed => + Array.from({ length: points }, (_, index) => + (index + seed * 7) % 47 === 0 ? 35 + ((index * 17 + seed * 11) % 65) : 0 + ) + +const makeDimensionStats = dimensions => + dimensions.reduce( + (stats, dimension) => { + const values = range(dimension.values) + const anomalyRates = dimension.anomalyRates || [] + + stats.min.push(values.min) + stats.max.push(values.max) + stats.avg.push(values.avg) + stats.arp.push(anomalyRates.length ? range(anomalyRates).avg : 0) + stats.con.push(0) + return stats + }, + { min: [], max: [], avg: [], arp: [], con: [] } + ) + +const makePayload = ({ title, unit, dimensions, chartType = "line" }) => { + const normalizedDimensions = dimensions.map((dimension, index) => ({ + ...dimension, + anomalyRates: dimension.anomalyRates || makeAnomalyRates(index + 1), + })) + const allValues = normalizedDimensions.flatMap(dimension => dimension.values) + const values = range(allValues) + const dimensionStats = makeDimensionStats(normalizedDimensions) + const ids = normalizedDimensions.map(dimension => dimension.id) + const names = normalizedDimensions.map(dimension => dimension.name || dimension.id) + const dimensionUnits = normalizedDimensions.map(dimension => dimension.unit || unit) + const units = [...new Set(dimensionUnits)] + const rows = Array.from({ length: points }, (_, index) => [ + now - (points - index - 1) * updateEvery * 1000, + ...normalizedDimensions.map(dimension => [ + dimension.values[index], + dimension.anomalyRates[index], + 0, + ]), + ]) + + return { + api: 2, + versions: {}, + summary: { + nodes: [], + contexts: [ + { + id: "storybook.units_scaling.chart_types", + sts: { ...values, con: 100 }, + }, + ], + instances: [], + dimensions: normalizedDimensions.map((dimension, index) => ({ + id: dimension.id, + pri: index, + sts: { + min: dimensionStats.min[index], + max: dimensionStats.max[index], + avg: dimensionStats.avg[index], + con: 0, + }, + })), + labels: [], + alerts: [], + }, + totals: {}, + functions: [], + result: { + labels: ["time", ...ids], + point: { value: 0, arp: 1, pa: 2 }, + data: rows, + }, + db: { + tiers: 1, + update_every: updateEvery, + first_entry: Math.floor(rows[0][0] / 1000), + last_entry: Math.floor(rows[rows.length - 1][0] / 1000), + units, + dimensions: { + ids, + units: dimensionUnits, + sts: dimensionStats, + }, + per_tier: [], + }, + view: { + title, + update_every: updateEvery, + after: Math.floor(rows[0][0] / 1000), + before: Math.floor(rows[rows.length - 1][0] / 1000), + units, + chart_type: chartType, + dimensions: { + grouped_by: ["dimension"], + ids, + names, + units: dimensionUnits, + priorities: normalizedDimensions.map((_, index) => index), + aggregated: normalizedDimensions.map(() => 1), + sts: dimensionStats, + }, + ...values, + }, + } +} + +const makeSignedRateDimensions = () => [ + { + id: "positive traffic", + name: "positive traffic", + values: makeWave({ center: 1.8e6, amplitude: 650000, phase: 0.2 }), + }, + { + id: "negative traffic", + name: "negative traffic", + values: makeWave({ center: -1.5e6, amplitude: 720000, phase: 1.4 }), + }, + { + id: "crossing zero", + name: "crossing zero", + values: makeWave({ center: 0, amplitude: 480000, cycles: 3.4, phase: 0.7 }), + }, +] + +const makeSignedRatePayload = chartType => + makePayload({ + title: `Signed byte rate, ${chartType}`, + unit: "By/s", + dimensions: makeSignedRateDimensions(), + chartType, + }) + +const heatmapIds = [ + "bucket_-1048576", + "bucket_-1024", + "bucket_-1", + "bucket_0", + "bucket_1", + "bucket_1024", + "bucket_1048576", + "bucket_+Inf", +] + +const heatmapPayload = makePayload({ + title: "Signed heatmap bucket boundaries", + unit: "events/s", + chartType: "heatmap", + dimensions: heatmapIds.map((id, bucketIndex) => ({ + id, + name: id, + values: Array.from({ length: points }, (_, index) => { + const movingCenter = 3.5 + Math.sin((index / points) * Math.PI * 4) * 2.2 + const distance = bucketIndex - movingCenter + return Math.max(0, Math.round(80 * Math.exp(-(distance * distance) / 2.5))) + }), + })), +}) + +const singleMetricPayload = ({ title, unit, center, amplitude, phase = -Math.PI / 2 }) => + makePayload({ + title, + unit, + dimensions: [ + { + id: title.toLowerCase().replaceAll(" ", "-"), + name: title, + values: makeWave({ center, amplitude, cycles: 2, phase }), + }, + ], + }) + +const chartTypeCases = [ + { id: "line", title: "Line", chartType: "line" }, + { id: "area", title: "Area", chartType: "area" }, + { id: "stacked", title: "Stacked area", chartType: "stacked" }, + { id: "stacked-bar", title: "Stacked bar", chartType: "stackedBar" }, + { id: "multi-bar", title: "Multi bar", chartType: "multiBar" }, +].map(item => ({ + ...item, + chartLibrary: "dygraph", + Component: Line, + payload: makeSignedRatePayload(item.chartType), + purpose: "Positive, negative, and crossing dimensions share one byte-rate scale.", + height: "320px", +})) + +const tileCases = [ + { + id: "gauge", + title: "Gauge", + purpose: "A request rate crossing zero; bounds and the current value preserve their signs.", + chartLibrary: "gauge", + Component: Gauge, + payload: singleMetricPayload({ + title: "Signed request rate", + unit: "requests/s", + center: 0, + amplitude: 2.2e6, + }), + }, + { + id: "easy-pie", + title: "Easy pie", + purpose: "A percentage moving across zero while the ring tracks its range position.", + chartLibrary: "easypiechart", + Component: EasyPie, + payload: singleMetricPayload({ + title: "Signed percentage", + unit: "%", + center: 0, + amplitude: 72, + }), + }, + { + id: "number", + title: "Number", + purpose: "A pre-scaled KiB source shown as a signed, atomically scaled value.", + chartLibrary: "number", + Component: NumberChart, + payload: singleMetricPayload({ + title: "Signed storage delta", + unit: "KiB", + center: -1800, + amplitude: 700, + }), + }, + { + id: "d3pie", + title: "D3 pie", + purpose: "Slice area uses magnitude; every label preserves the source sign.", + chartLibrary: "d3pie", + Component: D3pie, + payload: makePayload({ + title: "Signed operation contributors", + unit: "operations/s", + dimensions: [ + { + id: "positive contributor", + values: makeWave({ center: 1.8e6, amplitude: 300000, phase: 0.4 }), + }, + { + id: "negative contributor", + values: makeWave({ center: -850000, amplitude: 180000, phase: 1.2 }), + }, + { + id: "small contributor", + values: makeWave({ center: 420, amplitude: 120, phase: 2.1 }), + }, + ], + }), + }, + { + id: "bars", + title: "Bars", + purpose: "Magnitude sorting and bar extent with signed atomic labels.", + chartLibrary: "bars", + Component: Bars, + payload: makeSignedRatePayload("line"), + }, + { + id: "table", + title: "Table", + purpose: "Signed values and units occupy separate visual subcolumns.", + chartLibrary: "table", + Component: Table, + payload: makeSignedRatePayload("line"), + attributes: { tableColumns: ["dimension"] }, + }, + { + id: "group-boxes", + title: "Group boxes", + purpose: "Magnitude controls color intensity while the popover preserves the sign.", + chartLibrary: "groupBoxes", + Component: GroupBoxes, + payload: makeSignedRatePayload("line"), + }, +] + +const toolboxElements = [Settings, Fullscreen] + +const createChart = ({ id, payload, chartLibrary, attributes = {} }) => { + const sdk = makeDefaultSDK({ + attributes: { + contextScope: ["storybook.units_scaling.chart_types"], + containerWidth: 1180, + theme: "dark", + designFlavour: "default", + filterToolboxMode: "fixed", + navigation: "pan", + expandable: false, + }, + }) + const chart = sdk.makeChart({ + getChart: makeMockPayload(payload), + attributes: { + id: `units-chart-types-${id}`, + chartLibrary, + contextScope: ["storybook.units_scaling.chart_types"], + leftHeaderElements: [Status], + toolboxElements, + ...attributes, + }, + }) + + sdk.appendChild(chart) + return chart +} + +const ChartCase = ({ id, title, purpose, payload, chartLibrary, Component, attributes, height }) => { + const chart = useMemo( + () => createChart({ id, payload, chartLibrary, attributes }), + [id, payload, chartLibrary, attributes] + ) + + useEffect(() => () => chart.destroy(), [chart]) + + return ( + <Flex column gap={2} width={{ min: "0px", base: "100%" }}> + <Flex column gap={1}> + <TextSmall color="text" strong> + {title} + </TextSmall> + <TextMicro color="textDescription">{purpose}</TextMicro> + </Flex> + <ChartSurface height={height || "280px"}> + <Component chart={chart} height="100%" width="100%" /> + </ChartSurface> + </Flex> + ) +} + +const Section = ({ title, description, children }) => ( + <Flex column gap={3}> + <Flex column gap={1}> + <TextSmall color="text" strong> + {title} + </TextSmall> + <TextMicro color="textDescription">{description}</TextMicro> + </Flex> + {children} + </Flex> +) + +export const SignedTimeSeries = () => ( + <Page> + <Section + title="Signed time-series chart types" + description="The same production-shaped payload is rendered through every Dygraph mode." + > + {chartTypeCases.map(item => ( + <ChartCase key={item.id} {...item} /> + ))} + </Section> + <Section + title="Signed heatmap boundaries" + description="Bucket boundaries span negative and positive binary magnitudes. Cell values remain counts." + > + <ChartCase + id="heatmap" + title="Heatmap" + purpose="Signed bucket labels are sorted numerically and scaled from their magnitude." + chartLibrary="dygraph" + Component={Line} + payload={heatmapPayload} + height="340px" + /> + </Section> + </Page> +) + +SignedTimeSeries.parameters = { + netdataTheme: "dark", +} + +export const SignedTilesAndGauges = () => ( + <Page> + <Section + title="Signed tiles, gauges, and pies" + description="These are the non-Dygraph renderers selected by Cloud for chart-library payloads." + > + <Matrix> + {tileCases.map(item => ( + <ChartCase key={item.id} {...item} /> + ))} + </Matrix> + </Section> + </Page> +) + +SignedTilesAndGauges.parameters = { + netdataTheme: "dark", +} + +export default { + title: "Charts/Units scaling/Chart types", +}