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 (
-
- {title}
-
+
+ {title}
+
+ {!!units && !hideUnits && !isMinimal && (
+
+
+ • [{units}]
+
+
+ )}
+
)
}
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 => (
@@ -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 => {
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
- {!isHeatmap && "Unit"}
+
{
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(, { 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 (
{
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 (
-
-
- {first}
-
- {typeof last !== "undefined" && .}
-
- {last}
-
-
+
+
+ {convertedValue}
+
+
+ {!!convertedUnit && (
+
+ {convertedUnit}
+
+ )}
+
+
)
}
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 (
-
- {dimensionLabel}
-
-
- )
- },
+ header: () => {dimensionLabel},
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 (
: null}>
-
+
)
},
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 (
+
+
+
+ {title}
+
+ {purpose}
+
+
+
+
+
+ )
+}
+
+const Section = ({ title, description, children }) => (
+
+
+
+ {title}
+
+ {description}
+
+ {children}
+
+)
+
+export const SignedTimeSeries = () => (
+
+
+ {chartTypeCases.map(item => (
+
+ ))}
+
+
+
+)
+
+SignedTimeSeries.parameters = {
+ netdataTheme: "dark",
+}
+
+export const SignedTilesAndGauges = () => (
+
+
+
+ {tileCases.map(item => (
+
+ ))}
+
+
+
+)
+
+SignedTilesAndGauges.parameters = {
+ netdataTheme: "dark",
+}
+
+export default {
+ title: "Charts/Units scaling/Chart types",
+}