From 8427b73caaa97edc3f779b7f38d9b452cb3a6036 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 11:48:37 +0300 Subject: [PATCH 01/14] Fix popover value unit remounts --- src/components/line/popover/dimension.js | 31 +++++++----------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/components/line/popover/dimension.js b/src/components/line/popover/dimension.js index b54a4a00..3726c823 100644 --- a/src/components/line/popover/dimension.js +++ b/src/components/line/popover/dimension.js @@ -3,11 +3,10 @@ import styled from "styled-components" import { Flex } from "@netdata/netdata-ui" import Color, { ColorBar } from "@/components/line/dimensions/color" import Name from "@/components/line/dimensions/name" -import Units from "@/components/line/dimensions/units" +import { Value as UnitValue } from "@/components/line/dimensions/units" import Value, { Value as ValuePart } from "@/components/line/dimensions/value" import { - useLatestDisplayValue, - useValueUnitAttributes, + useLatestDisplayValueWithUnit, useVisibleDimensionId, } from "@/components/provider" import { labels as annotationLabels } from "@/helpers/annotations" @@ -64,27 +63,17 @@ const UnitCell = styled(Flex).attrs({ box-sizing: border-box; ` -const ValueWithUnits = ({ id, visible, children, ...rest }) => { +const ValueWithUnits = ({ id, visible, ...rest }) => { const isHeatmap = useIsHeatmap() - const value = useLatestDisplayValue(id, { allowNull: true }) - const unitAttributes = useValueUnitAttributes(value, { - dimensionId: id, - scaleByValue: true, - }) + const { convertedValue, convertedUnit } = useLatestDisplayValueWithUnit(id) + + if (!visible) return null return ( <> - {children} + {convertedValue} - {!isHeatmap && ( - - )} + {!isHeatmap && convertedUnit && {convertedUnit}} ) @@ -131,12 +120,10 @@ const Dimension = ({ id, strong, rowFlavour }) => { color={strong ? "textFocus" : "text"} /> - } - scaleByValue color={rowFlavour === rowFlavours.default ? (strong ? "textFocus" : "text") : "textLite"} /> Date: Mon, 13 Jul 2026 11:49:38 +0300 Subject: [PATCH 02/14] Remove dead makeWeights factory --- src/sdk/makeChart/index.js | 2 - src/sdk/makeChart/makeWeights.js | 133 -------------------------- src/sdk/makeChart/makeWeights.test.js | 59 ------------ 3 files changed, 194 deletions(-) delete mode 100644 src/sdk/makeChart/makeWeights.js delete mode 100644 src/sdk/makeChart/makeWeights.test.js diff --git a/src/sdk/makeChart/index.js b/src/sdk/makeChart/index.js index a82fa712..c49e1d42 100644 --- a/src/sdk/makeChart/index.js +++ b/src/sdk/makeChart/index.js @@ -9,7 +9,6 @@ import makeDimensions from "./makeDimensions" import makeFilterControllers from "./filters/makeControllers" import makeDataFetch from "./makeDataFetch" import makeGetUnitSign, { makeGetUnitAttributesForValue } from "./makeGetUnitSign" -import makeWeights from "./makeWeights" import getAggregateMethod from "./filters/getAggregateMethod" const themeIndex = { @@ -252,7 +251,6 @@ export default ({ makeDimensions(node, sdk) makeDataFetch(node, sdk) - makeWeights(node, sdk) node.type = "chart" makeGetUnitAttributesForValue(node) diff --git a/src/sdk/makeChart/makeWeights.js b/src/sdk/makeChart/makeWeights.js deleted file mode 100644 index b21c52a2..00000000 --- a/src/sdk/makeChart/makeWeights.js +++ /dev/null @@ -1,133 +0,0 @@ -import dimensionColors from "./theme/dimensionColors" -import deepEqual, { setsAreEqual } from "@/helpers/deepEqual" -import { fetchChartWeights } from "./api" - -const transformRow = (row, point) => - row.reduce((h, dim, i) => { - h.push( - Object.keys(point).reduce((p, k) => { - p[k] = dim[point[k]] - return p - }, {}) - ) - - return h - }, []) - -const transformObject = (obj, point) => { - const enhancedData = result.data.reduce( - (h, row) => { - const enhancedRow = transformDataRow(row, result.point, stats) - - h.data.push(enhancedRow.values) - h.all.push(enhancedRow.all) - - return h - }, - { data: [], all: [] } - ) - - const tree = result.labels.reduce((h, id, i) => { - if (i === 0) return h - - const keys = id.split(",") - - return buildTree(h, keys, id) - }, {}) - - return { - labels: [...result.labels, "ANOMALY_RATE", "ANNOTATIONS"], - ...enhancedData, - tree, - } -} - -const camelizePayload = ({ nodes, point }) => { - return Object.keys(nodes).reduce((h, row) => { - const enhancedRow = transformDataRow(row, result.point, stats) - - h.push(enhancedRow) - - return h - }, {}) -} - -export default (chart, sdk) => { - let abortController = null - - let weights = {} - - const cancelFetch = () => abortController && abortController.abort() - - const doneFetch = (nextRawWeights, tab) => { - const { result, ...restPayload } = camelizePayload(nextRawWeights) - - debugger - - chart.updateAttributes({ - weightsLoading: false, - error: null, - }) - - chart.trigger("weights:finishFetch") - } - - const failFetch = (error, tab) => { - if (!chart) return - - if (error?.name === "AbortError") { - chart.updateAttribute("weightsLoading", false) - return - } - - chart.updateAttributes({ - weightsLoading: false, - weightsError: error?.errorMessage || error?.message || "Something went wrong", - }) - - chart.trigger("weights:finishFetch") - } - - const fetchWeights = tab => { - if (!chart) return - - const dataFetch = () => { - abortController = new AbortController() - const options = { - signal: abortController.signal, - ...((chart.getAttribute("bearer") || chart.getAttribute("xNetdataBearer")) && { - headers: { - ...(chart.getAttribute("bearer") - ? { - Authorization: `Bearer ${chart.getAttribute("bearer")}`, - } - : { - "X-Netdata-Auth": `Bearer ${chart.getAttribute("xNetdataBearer")}`, - }), - }, - }), - } - return fetchChartWeights(chart, options) - .then(data => { - debugger - // if (data?.errorMsgKey) return failFetch(data) - // if (!(Array.isArray(data?.result) || Array.isArray(data?.result?.data))) - // return failFetch() - - return doneFetch(data, tab) - }) - .catch(e => failFetch(e, tab)) - } - - cancelFetch() - chart.trigger("weights:startFetch") - chart.updateAttributes({ weightsLoading: true }) - - return dataFetch() - } - - return { - weights, - fetchWeights, - } -} diff --git a/src/sdk/makeChart/makeWeights.test.js b/src/sdk/makeChart/makeWeights.test.js deleted file mode 100644 index 2554d13f..00000000 --- a/src/sdk/makeChart/makeWeights.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import makeWeights from "./makeWeights" -import { makeTestChart } from "@jest/testUtilities" - -describe("makeWeights", () => { - let chart - let sdk - let weightsModule - - beforeEach(() => { - const testChart = makeTestChart() - chart = testChart.chart - sdk = testChart.sdk - - global.AbortController = jest.fn(() => ({ - abort: jest.fn(), - signal: {}, - })) - - global.fetch = jest.fn(() => - Promise.resolve({ - json: () => Promise.resolve({ weights: {} }), - }) - ) - - weightsModule = makeWeights(chart, sdk) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it("returns weights object and fetchWeights function", () => { - expect(weightsModule).toHaveProperty("weights") - expect(weightsModule).toHaveProperty("fetchWeights") - expect(typeof weightsModule.fetchWeights).toBe("function") - }) - - it("initializes with empty weights object", () => { - expect(weightsModule.weights).toEqual({}) - }) - - it("fetchWeights updates loading state", () => { - const spy = jest.spyOn(chart, "updateAttributes") - const triggerSpy = jest.spyOn(chart, "trigger") - - weightsModule.fetchWeights("test-tab") - - expect(spy).toHaveBeenCalledWith({ - weightsLoading: true, - }) - expect(triggerSpy).toHaveBeenCalledWith("weights:startFetch") - }) - - it("creates abort controller when fetching", () => { - weightsModule.fetchWeights("test-tab") - - expect(global.AbortController).toHaveBeenCalled() - }) -}) From 2feae8f2df150b6fbe2063732ad087864045ea37 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 11:49:54 +0300 Subject: [PATCH 03/14] Move value unit attributes into dimensions --- src/helpers/unitConversion/index.js | 3 +-- src/sdk/makeChart/index.js | 3 +-- src/sdk/makeChart/makeDimensions.js | 15 +++++++++++++++ src/sdk/makeChart/makeGetUnitSign.js | 16 ---------------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/helpers/unitConversion/index.js b/src/helpers/unitConversion/index.js index 512c11e6..672d8c83 100644 --- a/src/helpers/unitConversion/index.js +++ b/src/helpers/unitConversion/index.js @@ -83,8 +83,7 @@ export default chart => { const dimMinMax = result?.byDimension ? chart.getVisibleDimensionIds().reduce( (h, d) => { - const dname = chart.getDimensionName(d) - const dimSts = result.byDimension[d] || result.byDimension[dname] + const dimSts = getDimensionStats(chart, result, d) if (dimSts && dimSts.min <= h.min) h.min = dimSts.min if (dimSts && dimSts.max >= h.max) h.max = dimSts.max diff --git a/src/sdk/makeChart/index.js b/src/sdk/makeChart/index.js index c49e1d42..1658b9e2 100644 --- a/src/sdk/makeChart/index.js +++ b/src/sdk/makeChart/index.js @@ -8,7 +8,7 @@ import { fetchChartData } from "./api" import makeDimensions from "./makeDimensions" import makeFilterControllers from "./filters/makeControllers" import makeDataFetch from "./makeDataFetch" -import makeGetUnitSign, { makeGetUnitAttributesForValue } from "./makeGetUnitSign" +import makeGetUnitSign from "./makeGetUnitSign" import getAggregateMethod from "./filters/getAggregateMethod" const themeIndex = { @@ -253,7 +253,6 @@ export default ({ makeDataFetch(node, sdk) node.type = "chart" - makeGetUnitAttributesForValue(node) makeGetUnitSign(node) node.track = makeTrack(node) diff --git a/src/sdk/makeChart/makeDimensions.js b/src/sdk/makeChart/makeDimensions.js index 26c6f498..6d10abae 100644 --- a/src/sdk/makeChart/makeDimensions.js +++ b/src/sdk/makeChart/makeDimensions.js @@ -8,6 +8,7 @@ import { withoutPrefix, } from "@/helpers/heatmap" import { detectHeatmapScale, formatHeatmapLabel, sortHeatmapValues } from "@/helpers/heatmapScale" +import { getConversionAttributes } from "@/helpers/unitConversion/getConversionUnits" import groupBy from "lodash/groupBy" import isEmpty from "lodash/isEmpty" import { getPointValue } from "./getPointValue" @@ -404,6 +405,20 @@ export default (chart, sdk) => { return { method, fractionDigits, base, prefix, divider, unit } } + chart.getUnitAttributesForValue = ( + value, + { dimensionId, key = "units", min = value, max = value } = {} + ) => { + const units = chart.getAttribute(key) + const unit = dimensionId + ? chart.getDimensionUnit(dimensionId) + : Array.isArray(units) + ? units[0] + : units + + return getConversionAttributes(chart, unit, { min, max }) + } + chart.getDimensionGroups = () => { const viewDimensions = chart.getAttribute("viewDimensions") diff --git a/src/sdk/makeChart/makeGetUnitSign.js b/src/sdk/makeChart/makeGetUnitSign.js index c7ae5da6..a3d2f806 100644 --- a/src/sdk/makeChart/makeGetUnitSign.js +++ b/src/sdk/makeChart/makeGetUnitSign.js @@ -1,5 +1,4 @@ import { getNormalizedUnitConfig, getUnitsString } from "@/helpers/units" -import { getConversionAttributes } from "@/helpers/unitConversion/getConversionUnits" export default chart => (chart.getUnitSign = ({ @@ -20,18 +19,3 @@ export default chart => return getUnitsString(unit, prefix, base, long) }) - -export const makeGetUnitAttributesForValue = chart => - (chart.getUnitAttributesForValue = ( - value, - { dimensionId, key = "units", min = value, max = value } = {} - ) => { - const units = chart.getAttribute(key) - const unit = dimensionId - ? chart.getDimensionUnit(dimensionId) - : Array.isArray(units) - ? units[0] - : units - - return getConversionAttributes(chart, unit, { min, max }) - }) From d8dfadf0239c547092f1fcddb15b9c209af04f58 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 11:50:14 +0300 Subject: [PATCH 04/14] Share value unit grid across drawer and table --- src/components/drawer/correlate/columns.js | 2 +- src/components/drawer/correlate/sparkline.js | 2 +- src/components/drawer/correlate/table.test.js | 2 +- src/components/drawer/dimensions/columns.js | 2 +- src/components/drawer/drillDown/columns.js | 2 +- .../dimensions}/valueWithUnit.js | 15 +++++--- .../dimensions}/valueWithUnit.test.js | 12 +++---- src/components/table/columns.js | 36 +++---------------- 8 files changed, 26 insertions(+), 47 deletions(-) rename src/components/{drawer => line/dimensions}/valueWithUnit.js (86%) rename src/components/{drawer => line/dimensions}/valueWithUnit.test.js (83%) diff --git a/src/components/drawer/correlate/columns.js b/src/components/drawer/correlate/columns.js index 736f670a..6f969828 100644 --- a/src/components/drawer/correlate/columns.js +++ b/src/components/drawer/correlate/columns.js @@ -1,7 +1,7 @@ import React from "react" import { Flex, Icon, TextMicro, TextSmall } from "@netdata/netdata-ui" import Sparkline from "./sparkline" -import { ValueUnitGrid, ValueUnitHeader } from "@/components/drawer/valueWithUnit" +import { ValueUnitGrid, ValueUnitHeader } from "@/components/line/dimensions/valueWithUnit" const valueColumnSize = 144 const valueColumnMinSize = 120 diff --git a/src/components/drawer/correlate/sparkline.js b/src/components/drawer/correlate/sparkline.js index 5e0dbf9b..36e48ce1 100644 --- a/src/components/drawer/correlate/sparkline.js +++ b/src/components/drawer/correlate/sparkline.js @@ -3,7 +3,7 @@ import { Flex } from "@netdata/netdata-ui" import { useAttributeValue, useChart } from "@/components/provider" import { getConversionAttributes } from "@/helpers/unitConversion/getConversionUnits" import dimensionColors from "@/sdk/makeChart/theme/dimensionColors" -import { ValueUnitGrid } from "@/components/drawer/valueWithUnit" +import { ValueUnitGrid } from "@/components/line/dimensions/valueWithUnit" import SparklineCanvas from "./sparklineCanvas" import { getSparklineBatchAttributes, diff --git a/src/components/drawer/correlate/table.test.js b/src/components/drawer/correlate/table.test.js index f01fc33e..650bc20b 100644 --- a/src/components/drawer/correlate/table.test.js +++ b/src/components/drawer/correlate/table.test.js @@ -172,7 +172,7 @@ describe("CorrelationTable", () => { await user.type(search, "Target node") await waitFor(() => expect(screen.getByText("Target metric")).toBeInTheDocument()) - expect(screen.getByTestId("drawer-value-unit-detail")).toHaveTextContent("Strong") + expect(screen.getByTestId("value-unit-detail")).toHaveTextContent("Strong") expect(screen.queryByText("Other metric")).not.toBeInTheDocument() expect(chart.getAttribute("correlate.expanded")).toEqual(originalExpanded) diff --git a/src/components/drawer/dimensions/columns.js b/src/components/drawer/dimensions/columns.js index cb7b4182..b80c9f3e 100644 --- a/src/components/drawer/dimensions/columns.js +++ b/src/components/drawer/dimensions/columns.js @@ -11,7 +11,7 @@ import { } from "@/components/provider" import Label from "@/components/filterToolbox/label" import { rowFlavours } from "@/components/line/popover/dimensions" -import ValueWithUnit, { ValueUnitHeader } from "@/components/drawer/valueWithUnit" +import ValueWithUnit, { ValueUnitHeader } from "@/components/line/dimensions/valueWithUnit" const valueColumnSize = 144 const valueColumnMinSize = 120 diff --git a/src/components/drawer/drillDown/columns.js b/src/components/drawer/drillDown/columns.js index 2140faf1..8fb94ff4 100644 --- a/src/components/drawer/drillDown/columns.js +++ b/src/components/drawer/drillDown/columns.js @@ -3,7 +3,7 @@ import { Flex, ProgressBar, TextSmall } from "@netdata/netdata-ui" import Color from "@/components/line/dimensions/color" import { useChart } from "@/components/provider" import Label from "@/components/filterToolbox/label" -import ValueWithUnit, { ValueUnitHeader } from "@/components/drawer/valueWithUnit" +import ValueWithUnit, { ValueUnitHeader } from "@/components/line/dimensions/valueWithUnit" const valueColumnSize = 144 const valueColumnMinSize = 120 diff --git a/src/components/drawer/valueWithUnit.js b/src/components/line/dimensions/valueWithUnit.js similarity index 86% rename from src/components/drawer/valueWithUnit.js rename to src/components/line/dimensions/valueWithUnit.js index 7a0067dc..bc0905c2 100644 --- a/src/components/drawer/valueWithUnit.js +++ b/src/components/line/dimensions/valueWithUnit.js @@ -34,12 +34,19 @@ const ValueDetail = styled(TextMicro).attrs({ grid-row: 2; ` -export const ValueUnitGrid = ({ value, unit, detail, color = "text", strong }) => ( - +export const ValueUnitGrid = ({ + value, + unit, + detail, + color = "text", + strong, + testIdPrefix = "value-unit", +}) => ( + {value} - + {unit && ( {unit} @@ -47,7 +54,7 @@ export const ValueUnitGrid = ({ value, unit, detail, color = "text", strong }) = )} {detail && ( - + {detail} )} diff --git a/src/components/drawer/valueWithUnit.test.js b/src/components/line/dimensions/valueWithUnit.test.js similarity index 83% rename from src/components/drawer/valueWithUnit.test.js rename to src/components/line/dimensions/valueWithUnit.test.js index e417d6c4..e74f0897 100644 --- a/src/components/drawer/valueWithUnit.test.js +++ b/src/components/line/dimensions/valueWithUnit.test.js @@ -37,8 +37,8 @@ describe("ValueWithUnit", () => { { chart } ) - const grids = screen.getAllByTestId("drawer-value-unit-grid") - const units = screen.getAllByTestId("drawer-value-unit-cell") + const grids = screen.getAllByTestId("value-unit-grid") + const units = screen.getAllByTestId("value-unit-cell") expect(grids[0]).toHaveTextContent("1B") expect(units[0]).toHaveTextContent("B") @@ -54,15 +54,15 @@ describe("ValueWithUnit", () => { it("supports units that belong to a derived value instead of the chart", () => { renderWithChart() - expect(screen.getByTestId("drawer-value-unit-grid")).toHaveTextContent("25.40%") - expect(screen.getByTestId("drawer-value-unit-cell")).toHaveTextContent("%") + expect(screen.getByTestId("value-unit-grid")).toHaveTextContent("25.40%") + expect(screen.getByTestId("value-unit-cell")).toHaveTextContent("%") }) it("renders details beneath the value subcolumn", () => { renderWithChart() - const grid = screen.getByTestId("drawer-value-unit-grid") - const detail = screen.getByTestId("drawer-value-unit-detail") + const grid = screen.getByTestId("value-unit-grid") + const detail = screen.getByTestId("value-unit-detail") expect(grid).toContainElement(detail) expect(grid).toHaveStyle("grid-template-columns: minmax(0,1fr) 44px") diff --git a/src/components/table/columns.js b/src/components/table/columns.js index 96dea9bd..1e41aa30 100644 --- a/src/components/table/columns.js +++ b/src/components/table/columns.js @@ -1,8 +1,8 @@ import React from "react" -import styled from "styled-components" -import { Flex, TextMicro, TextSmall, getSizeBy } from "@netdata/netdata-ui" +import { Flex, TextSmall } from "@netdata/netdata-ui" import Color from "@/components/line/dimensions/color" import Name from "@/components/line/dimensions/name" +import ValueWithUnit from "@/components/line/dimensions/valueWithUnit" import { getValueByPeriod, useChart, @@ -10,7 +10,6 @@ import { useVisibleDimensionId, useLatestDisplayValue, useUnitSign, - useValueWithUnit, } from "@/components/provider" import Tooltip from "@/components/tooltip" import sanitizeId from "@/helpers/sanitizeId" @@ -103,37 +102,10 @@ export const findDimensionId = (value, 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 ( - - - {convertedValue} - - - {!!convertedUnit && ( - - {convertedUnit} - - )} - - - ) + + return } const TooltipValue = ({ id }) => { From b85840d7c0b46c3e091d09fc8fe6250f6fdb4228 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 11:50:22 +0300 Subject: [PATCH 05/14] Use netdata-ui props in popover header --- src/components/line/popover/header.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/line/popover/header.js b/src/components/line/popover/header.js index 50d5ab0b..842099d3 100644 --- a/src/components/line/popover/header.js +++ b/src/components/line/popover/header.js @@ -19,14 +19,12 @@ const HeaderContainer = styled(Flex).attrs({ const ContextText = styled(TextMicro).attrs({ "data-testid": "chartPopover-context", strong: true, + truncate: true, })` display: block; min-width: 0; width: 100%; max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; font-size: ${({ $fontSize }) => $fontSize}px; ` @@ -45,9 +43,9 @@ const TimestampCell = styled(Flex).attrs({ const SourceUnits = styled(TextMicro).attrs({ color: "textLite", "data-testid": "chartPopover-sourceUnits", + whiteSpace: "nowrap", })` flex: 0 0 auto; - white-space: nowrap; ` const getMeasuredFont = (element, fontSize) => { From 2881a0be1561ace482087c9c76d47ffb536ec020 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 11:50:37 +0300 Subject: [PATCH 06/14] Add numeric formatting coverage --- .../dygraph/tickers/numeric.test.js | 20 +++-- src/helpers/formatNumber/index.test.js | 75 +++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 src/helpers/formatNumber/index.test.js diff --git a/src/chartLibraries/dygraph/tickers/numeric.test.js b/src/chartLibraries/dygraph/tickers/numeric.test.js index 17b1b793..f8c90dba 100644 --- a/src/chartLibraries/dygraph/tickers/numeric.test.js +++ b/src/chartLibraries/dygraph/tickers/numeric.test.js @@ -155,16 +155,24 @@ describe("numericTicker", () => { units: [""], }) + const dataTicks = ticks.filter(tick => tick.v !== undefined && !tick.label_v).map(t => t.v) + expect(dataTicks).toEqual([ + 5.12000001, + 5.12000002, + 5.12000003, + 5.12000004, + 5.12000005, + 5.12000006, + 5.12000007, + 5.12000008, + 5.12000009, + ]) expect(mockFormatter).toHaveBeenCalledWith( - expect.any(Number), - expect.any(Number), + 5.12000001, + 9.99999993922529e-9, mockOpts, mockDygraph ) - - const dataTicks = ticks.filter(tick => tick.v !== undefined && !tick.label_v).map(t => t.v) - expect(dataTicks.length).toBeGreaterThan(1) - expect(dataTicks[1] - dataTicks[0]).toBeGreaterThan(0) }) it("includes anomaly SVG in first tick", () => { diff --git a/src/helpers/formatNumber/index.test.js b/src/helpers/formatNumber/index.test.js new file mode 100644 index 00000000..54b56468 --- /dev/null +++ b/src/helpers/formatNumber/index.test.js @@ -0,0 +1,75 @@ +import formatNumber, { formatRegularNumber, shouldUseExponential } from "./index" +import { makeTestChart } from "@jest/testUtilities" + +describe("formatNumber", () => { + let chart + + beforeEach(() => { + chart = makeTestChart({ + attributes: { + locale: "en-US", + }, + }).chart + }) + + it("resolves fraction digits from explicit and unit-conversion settings", () => { + const cases = [ + { + value: 1.23456, + fractionDigits: undefined, + unitsConversionFractionDigits: undefined, + expected: "1.235", + }, + { + value: 1.23456, + fractionDigits: undefined, + unitsConversionFractionDigits: 2, + expected: "1.23", + }, + { + value: 1.23456, + fractionDigits: null, + unitsConversionFractionDigits: -1, + expected: "1.2346", + }, + { + value: 1.2, + fractionDigits: 3, + unitsConversionFractionDigits: 0, + expected: "1.200", + }, + { + value: 1.23456, + fractionDigits: -1, + unitsConversionFractionDigits: 2, + expected: "1.23", + }, + ] + + cases.forEach(({ value, fractionDigits, unitsConversionFractionDigits, expected }) => { + expect(formatRegularNumber(chart, value, fractionDigits, unitsConversionFractionDigits)).toBe( + expected + ) + }) + }) + + it("keeps 18-character regular numbers and switches longer values to exponential notation", () => { + const regularValue = 12345678901234 + const exponentialValue = 123456789012345 + + expect(formatRegularNumber(chart, regularValue)).toBe("12,345,678,901,234") + expect(formatRegularNumber(chart, regularValue)).toHaveLength(18) + expect(shouldUseExponential(chart, regularValue)).toBe(false) + expect(formatNumber(chart, regularValue)).toBe("12,345,678,901,234") + + expect(formatRegularNumber(chart, exponentialValue)).toBe("123,456,789,012,345") + expect(formatRegularNumber(chart, exponentialValue)).toHaveLength(19) + expect(shouldUseExponential(chart, exponentialValue)).toBe(true) + expect(formatNumber(chart, exponentialValue)).toBe("1.235e+14") + }) + + it("trims insignificant trailing zeroes from exponential mantissas", () => { + expect(formatNumber(chart, 1000000000000000000)).toBe("1e+18") + expect(formatNumber(chart, 1200000000000000000)).toBe("1.2e+18") + }) +}) From b73b946011b7f24599fe64ce41bdf375cf6c581f Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 13:07:26 +0300 Subject: [PATCH 07/14] Fix overlapping ui issue in narrow spaces. --- src/components/line/dimensions/valueWithUnit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/line/dimensions/valueWithUnit.js b/src/components/line/dimensions/valueWithUnit.js index bc0905c2..53ca95c3 100644 --- a/src/components/line/dimensions/valueWithUnit.js +++ b/src/components/line/dimensions/valueWithUnit.js @@ -1,6 +1,6 @@ import React from "react" import styled from "styled-components" -import { Flex, TextMicro, TextSmall, getSizeBy } from "@netdata/netdata-ui" +import { Flex, TextMicro, TextSmall } from "@netdata/netdata-ui" import { useValueWithUnit } from "@/components/provider" const Container = styled(Flex).attrs({ @@ -8,7 +8,7 @@ const Container = styled(Flex).attrs({ width: "100%", })` display: grid; - grid-template-columns: minmax(0, 1fr) ${getSizeBy(5.5)}; + grid-template-columns: minmax(auto, 1fr) minmax(40px, max-content); ` const UnitCell = styled(Flex).attrs({ From e59116fbb0efe6cebdcdaf23e072b665c7fa0e5c Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 13:24:26 +0300 Subject: [PATCH 08/14] Fix dense stacked area rendering --- .../dygraph/plotters/stackedArea.js | 89 ++++++++++++++++--- .../dygraph/plotters/stackedArea.test.js | 54 +++++++++++ 2 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 src/chartLibraries/dygraph/plotters/stackedArea.test.js diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.js b/src/chartLibraries/dygraph/plotters/stackedArea.js index 089bfd60..7b1f0f1b 100644 --- a/src/chartLibraries/dygraph/plotters/stackedArea.js +++ b/src/chartLibraries/dygraph/plotters/stackedArea.js @@ -1,29 +1,98 @@ import Dygraph from "dygraphs" import { getDivergingStackBounds } from "../divergingStack" +const appendBucket = (target, bucket) => { + if (!bucket.length) return + if (bucket.length <= 6) { + target.push(...bucket) + return + } + + const indexes = new Set([0, bucket.length - 1]) + const boundaryKeys = ["baseY", "endY"] + + boundaryKeys.forEach(key => { + let minIndex = 0 + let maxIndex = 0 + + for (let index = 1; index < bucket.length; index++) { + if (bucket[index][key] < bucket[minIndex][key]) minIndex = index + if (bucket[index][key] > bucket[maxIndex][key]) maxIndex = index + } + + indexes.add(minIndex) + indexes.add(maxIndex) + }) + + Array.from(indexes) + .sort((a, b) => a - b) + .forEach(index => target.push(bucket[index])) +} + +export const reduceStackedAreaPoints = (points, width) => { + if (!Number.isFinite(width) || width <= 0 || points.length <= width * 2) return points + + const reduced = [] + let bucket = [] + let pixel = null + + points.forEach(point => { + if (!point || !Number.isFinite(point.x)) { + appendBucket(reduced, bucket) + bucket = [] + pixel = null + if (reduced[reduced.length - 1] !== null) reduced.push(null) + return + } + + const nextPixel = Math.round(point.x) + if (pixel !== null && nextPixel !== pixel) { + appendBucket(reduced, bucket) + bucket = [] + } + + pixel = nextPixel + bucket.push(point) + }) + + appendBucket(reduced, bucket) + if (reduced[reduced.length - 1] === null) reduced.pop() + + return reduced +} + const makeFillPlotter = () => plotter => { - const { drawingContext: ctx, dygraph, points, setName } = plotter + const { drawingContext: ctx, dygraph, points, plotArea, setName } = plotter const stepPlot = dygraph.getBooleanOption("stepPlot", setName) ctx.fillStyle = plotter.color ctx.globalAlpha = dygraph.getNumericOption("fillAlpha", setName) ctx.beginPath() + const renderPoints = points.map(point => { + const bounds = getDivergingStackBounds(point) + if (!bounds) return null + + const baseY = dygraph.toDomYCoord(bounds.base) + const endY = dygraph.toDomYCoord(bounds.end) + if (!Number.isFinite(point.canvasx) || !Number.isFinite(baseY) || !Number.isFinite(endY)) + return null + + return { + x: point.canvasx, + baseY, + endY, + } + }) + let previous - points.forEach(point => { - const bounds = getDivergingStackBounds(point) - if (!bounds) { + reduceStackedAreaPoints(renderPoints, plotArea.w).forEach(current => { + if (!current) { previous = null return } - const current = { - x: point.canvasx, - baseY: dygraph.toDomYCoord(bounds.base), - endY: dygraph.toDomYCoord(bounds.end), - } - if (previous) { ctx.moveTo(previous.x, previous.endY) diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.test.js b/src/chartLibraries/dygraph/plotters/stackedArea.test.js new file mode 100644 index 00000000..9decce92 --- /dev/null +++ b/src/chartLibraries/dygraph/plotters/stackedArea.test.js @@ -0,0 +1,54 @@ +import makeStackedAreaPlotter, { reduceStackedAreaPoints } from "./stackedArea" + +const makePoint = (index, count, width) => ({ + x: (index / (count - 1)) * width, + baseY: index % 31, + endY: index % 47, +}) + +describe("stacked area plotter", () => { + it("provides fill and line plotter functions to Dygraphs", () => { + const plotters = makeStackedAreaPlotter() + + expect(plotters).toHaveLength(2) + plotters.forEach(plotter => expect(typeof plotter).toBe("function")) + }) + + it("bounds high-density render points by canvas width", () => { + const width = 800 + const count = 86400 + const points = Array.from({ length: count }, (_, index) => makePoint(index, count, width)) + + const reduced = reduceStackedAreaPoints(points, width) + + expect(reduced.length).toBeLessThanOrEqual((width + 1) * 6) + expect(reduced[0]).toBe(points[0]) + expect(reduced[reduced.length - 1]).toBe(points[points.length - 1]) + }) + + it("preserves boundary extrema within the same canvas pixel", () => { + const points = [ + { x: 1.1, baseY: 5, endY: 10 }, + { x: 1.2, baseY: -20, endY: 8 }, + { x: 1.3, baseY: 4, endY: 30 }, + { x: 1.4, baseY: 7, endY: -40 }, + { x: 1.45, baseY: 6, endY: 9 }, + ] + + const reduced = reduceStackedAreaPoints(points, 1) + + expect(reduced).toEqual(points) + }) + + it("preserves gaps while reducing dense runs", () => { + const left = Array.from({ length: 20 }, (_, index) => makePoint(index, 20, 2)) + const right = Array.from({ length: 20 }, (_, index) => makePoint(index, 20, 2)) + const points = [...left, null, ...right] + + const reduced = reduceStackedAreaPoints(points, 2) + const gap = reduced.indexOf(null) + + expect(gap).toBeGreaterThan(0) + expect(gap).toBeLessThan(reduced.length - 1) + }) +}) From 78b39d4ac78c0473781efba6c3114b96381060f0 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 14:35:55 +0300 Subject: [PATCH 09/14] Add failing test for reduceStackedAreaPoints. --- .../dygraph/plotters/stackedArea.test.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.test.js b/src/chartLibraries/dygraph/plotters/stackedArea.test.js index 9decce92..edcc87b8 100644 --- a/src/chartLibraries/dygraph/plotters/stackedArea.test.js +++ b/src/chartLibraries/dygraph/plotters/stackedArea.test.js @@ -6,6 +6,17 @@ const makePoint = (index, count, width) => ({ endY: index % 47, }) +const interpolateBoundary = (points, x, key) => { + const rightIndex = points.findIndex(point => point.x >= x) + const right = points[rightIndex] + if (right.x === x) return right[key] + + const left = points[rightIndex - 1] + const progress = (x - left.x) / (right.x - left.x) + + return left[key] + (right[key] - left[key]) * progress +} + describe("stacked area plotter", () => { it("provides fill and line plotter functions to Dygraphs", () => { const plotters = makeStackedAreaPlotter() @@ -51,4 +62,28 @@ describe("stacked area plotter", () => { expect(gap).toBeGreaterThan(0) expect(gap).toBeLessThan(reduced.length - 1) }) + + it("keeps adjacent series on the same shared boundary after reduction", () => { + const xValues = [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07] + const sharedBoundary = [0, 30, 45, 60, 120, 100, 80] + const lowerBase = [0, 10, 20, 100, 40, 50, 60] + const upperEnd = [50, -100, 60, 70, 80, 200, 90] + const lower = xValues.map((x, index) => ({ + x, + baseY: lowerBase[index], + endY: sharedBoundary[index], + })) + const upper = xValues.map((x, index) => ({ + x, + baseY: sharedBoundary[index], + endY: upperEnd[index], + })) + + const reducedLower = reduceStackedAreaPoints(lower, 1) + const reducedUpper = reduceStackedAreaPoints(upper, 1) + const x = xValues[3] + + expect(interpolateBoundary(reducedLower, x, "endY")).toBe(60) + expect(interpolateBoundary(reducedUpper, x, "baseY")).toBe(60) + }) }) From 38b5203078a947cfa07e8ed32aef35167d73d101 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 14:46:50 +0300 Subject: [PATCH 10/14] Fix flaky point selection. Fix tests. --- .../dygraph/plotters/stackedArea.js | 208 ++++++++++++------ .../dygraph/plotters/stackedArea.test.js | 53 ++++- .../line/dimensions/valueWithUnit.test.js | 8 +- 3 files changed, 184 insertions(+), 85 deletions(-) diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.js b/src/chartLibraries/dygraph/plotters/stackedArea.js index 7b1f0f1b..ea8e8527 100644 --- a/src/chartLibraries/dygraph/plotters/stackedArea.js +++ b/src/chartLibraries/dygraph/plotters/stackedArea.js @@ -1,117 +1,183 @@ import Dygraph from "dygraphs" import { getDivergingStackBounds } from "../divergingStack" -const appendBucket = (target, bucket) => { - if (!bucket.length) return - if (bucket.length <= 6) { - target.push(...bucket) - return - } +const maxPointsPerPixel = 6 - const indexes = new Set([0, bucket.length - 1]) - const boundaryKeys = ["baseY", "endY"] +const getPointX = point => point?.x ?? point?.canvasx - boundaryKeys.forEach(key => { - let minIndex = 0 - let maxIndex = 0 +const getPointBounds = point => { + if (Number.isFinite(point?.baseY) && Number.isFinite(point?.endY)) { + return { base: point.baseY, end: point.endY } + } + + return getDivergingStackBounds(point) +} - for (let index = 1; index < bucket.length; index++) { - if (bucket[index][key] < bucket[minIndex][key]) minIndex = index - if (bucket[index][key] > bucket[maxIndex][key]) maxIndex = index +const getPointDeviation = (seriesPoints, referencePoints, index, firstIndex, lastIndex) => { + const firstX = getPointX(referencePoints[firstIndex]) + const lastX = getPointX(referencePoints[lastIndex]) + const currentX = getPointX(referencePoints[index]) + const indexProgress = (index - firstIndex) / (lastIndex - firstIndex) + const hasXProgress = [firstX, currentX, lastX].every(Number.isFinite) && lastX !== firstX + const progress = hasXProgress ? (currentX - firstX) / (lastX - firstX) : indexProgress + let deviation = 0 + + seriesPoints.forEach(points => { + const first = getPointBounds(points[firstIndex]) + const current = getPointBounds(points[index]) + const last = getPointBounds(points[lastIndex]) + + if (!first || !current || !last) { + deviation = Infinity + return } - indexes.add(minIndex) - indexes.add(maxIndex) + const expectedBase = first.base + (last.base - first.base) * progress + const expectedEnd = first.end + (last.end - first.end) * progress + + deviation = Math.max( + deviation, + Math.abs(current.base - expectedBase), + Math.abs(current.end - expectedEnd) + ) }) + return deviation +} + +const appendBucket = (target, bucket, seriesPoints, referencePoints) => { + if (!bucket.length) return + if (bucket.length <= maxPointsPerPixel) { + target.push(...bucket) + return + } + + const indexes = new Set([0, bucket.length - 1]) + const firstIndex = bucket[0] + const lastIndex = bucket[bucket.length - 1] + const candidates = bucket.slice(1, -1).map((index, bucketIndex) => ({ + bucketIndex: bucketIndex + 1, + deviation: getPointDeviation(seriesPoints, referencePoints, index, firstIndex, lastIndex), + })) + + candidates + .sort((a, b) => b.deviation - a.deviation || a.bucketIndex - b.bucketIndex) + .slice(0, maxPointsPerPixel - indexes.size) + .forEach(({ bucketIndex }) => indexes.add(bucketIndex)) + Array.from(indexes) .sort((a, b) => a - b) - .forEach(index => target.push(bucket[index])) + .forEach(bucketIndex => target.push(bucket[bucketIndex])) } -export const reduceStackedAreaPoints = (points, width) => { - if (!Number.isFinite(width) || width <= 0 || points.length <= width * 2) return points +export const selectStackedAreaPointIndexes = (seriesPoints, width) => { + const stackedSeriesPoints = seriesPoints.filter(points => points?.some(getPointBounds)) + if (!stackedSeriesPoints.length) return null - const reduced = [] + const referencePoints = stackedSeriesPoints.reduce((longest, points) => + points.length > longest.length ? points : longest + ) + const pointCount = referencePoints.length + if (!Number.isFinite(width) || width <= 0 || pointCount <= width * 2) return null + + const selected = [] let bucket = [] let pixel = null - points.forEach(point => { - if (!point || !Number.isFinite(point.x)) { - appendBucket(reduced, bucket) + for (let index = 0; index < pointCount; index++) { + const point = referencePoints[index] + const x = getPointX(point) + + if (!Number.isFinite(x)) { + appendBucket(selected, bucket, stackedSeriesPoints, referencePoints) bucket = [] pixel = null - if (reduced[reduced.length - 1] !== null) reduced.push(null) - return + selected.push(index) + continue } - const nextPixel = Math.round(point.x) + const nextPixel = Math.round(x) if (pixel !== null && nextPixel !== pixel) { - appendBucket(reduced, bucket) + appendBucket(selected, bucket, stackedSeriesPoints, referencePoints) bucket = [] } pixel = nextPixel - bucket.push(point) - }) + bucket.push(index) + } - appendBucket(reduced, bucket) - if (reduced[reduced.length - 1] === null) reduced.pop() + appendBucket(selected, bucket, stackedSeriesPoints, referencePoints) - return reduced + return selected } -const makeFillPlotter = () => plotter => { - const { drawingContext: ctx, dygraph, points, plotArea, setName } = plotter - const stepPlot = dygraph.getBooleanOption("stepPlot", setName) +export const reduceStackedAreaPoints = (points, selectedIndexes) => + selectedIndexes ? selectedIndexes.map(index => points[index] ?? null) : points - ctx.fillStyle = plotter.color - ctx.globalAlpha = dygraph.getNumericOption("fillAlpha", setName) - ctx.beginPath() +const makeFillPlotter = () => { + let cachedSeriesPoints + let cachedWidth + let selectedIndexes - const renderPoints = points.map(point => { - const bounds = getDivergingStackBounds(point) - if (!bounds) return null - - const baseY = dygraph.toDomYCoord(bounds.base) - const endY = dygraph.toDomYCoord(bounds.end) - if (!Number.isFinite(point.canvasx) || !Number.isFinite(baseY) || !Number.isFinite(endY)) - return null + return plotter => { + const { drawingContext: ctx, dygraph, points, plotArea, seriesIndex, setName } = plotter + const allSeriesPoints = plotter.allSeriesPoints || [points] + const stepPlot = dygraph.getBooleanOption("stepPlot", setName) - return { - x: point.canvasx, - baseY, - endY, + if (seriesIndex === 0 || cachedSeriesPoints !== allSeriesPoints || cachedWidth !== plotArea.w) { + cachedSeriesPoints = allSeriesPoints + cachedWidth = plotArea.w + selectedIndexes = selectStackedAreaPointIndexes(allSeriesPoints, plotArea.w) } - }) - let previous + ctx.fillStyle = plotter.color + ctx.globalAlpha = dygraph.getNumericOption("fillAlpha", setName) + ctx.beginPath() - reduceStackedAreaPoints(renderPoints, plotArea.w).forEach(current => { - if (!current) { - previous = null - return - } + const renderPoints = reduceStackedAreaPoints(points, selectedIndexes).map(point => { + const bounds = getDivergingStackBounds(point) + if (!bounds) return null - if (previous) { - ctx.moveTo(previous.x, previous.endY) + const baseY = dygraph.toDomYCoord(bounds.base) + const endY = dygraph.toDomYCoord(bounds.end) + if (!Number.isFinite(point.canvasx) || !Number.isFinite(baseY) || !Number.isFinite(endY)) + return null - if (stepPlot) { - ctx.lineTo(current.x, previous.endY) - ctx.lineTo(current.x, current.endY) - } else { - ctx.lineTo(current.x, current.endY) + return { + x: point.canvasx, + baseY, + endY, } + }) - ctx.lineTo(current.x, current.baseY) - ctx.lineTo(previous.x, previous.baseY) - ctx.closePath() - } + let previous - previous = current - }) + renderPoints.forEach(current => { + if (!current) { + previous = null + return + } + + if (previous) { + ctx.moveTo(previous.x, previous.endY) + + if (stepPlot) { + ctx.lineTo(current.x, previous.endY) + ctx.lineTo(current.x, current.endY) + } else { + ctx.lineTo(current.x, current.endY) + } - ctx.fill() + ctx.lineTo(current.x, current.baseY) + ctx.lineTo(previous.x, previous.baseY) + ctx.closePath() + } + + previous = current + }) + + ctx.fill() + } } const makeLinePlotter = () => plotter => { diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.test.js b/src/chartLibraries/dygraph/plotters/stackedArea.test.js index edcc87b8..b93f42f3 100644 --- a/src/chartLibraries/dygraph/plotters/stackedArea.test.js +++ b/src/chartLibraries/dygraph/plotters/stackedArea.test.js @@ -1,4 +1,7 @@ -import makeStackedAreaPlotter, { reduceStackedAreaPoints } from "./stackedArea" +import makeStackedAreaPlotter, { + reduceStackedAreaPoints, + selectStackedAreaPointIndexes, +} from "./stackedArea" const makePoint = (index, count, width) => ({ x: (index / (count - 1)) * width, @@ -30,25 +33,45 @@ describe("stacked area plotter", () => { const count = 86400 const points = Array.from({ length: count }, (_, index) => makePoint(index, count, width)) - const reduced = reduceStackedAreaPoints(points, width) + const selectedIndexes = selectStackedAreaPointIndexes([points], width) + const reduced = reduceStackedAreaPoints(points, selectedIndexes) expect(reduced.length).toBeLessThanOrEqual((width + 1) * 6) expect(reduced[0]).toBe(points[0]) expect(reduced[reduced.length - 1]).toBe(points[points.length - 1]) }) + it("keeps the canvas-width bound independent of the number of stacked series", () => { + const width = 100 + const count = 10000 + const series = Array.from({ length: 8 }, (_, seriesIndex) => + Array.from({ length: count }, (_, index) => ({ + x: (index / (count - 1)) * width, + baseY: Math.sin(index * (seriesIndex + 1)) * 10, + endY: Math.cos(index * (seriesIndex + 1)) * 10, + })) + ) + + const selectedIndexes = selectStackedAreaPointIndexes(series, width) + + expect(selectedIndexes.length).toBeLessThanOrEqual((width + 1) * 6) + }) + it("preserves boundary extrema within the same canvas pixel", () => { const points = [ - { x: 1.1, baseY: 5, endY: 10 }, - { x: 1.2, baseY: -20, endY: 8 }, - { x: 1.3, baseY: 4, endY: 30 }, - { x: 1.4, baseY: 7, endY: -40 }, - { x: 1.45, baseY: 6, endY: 9 }, + { x: 1.01, baseY: 0, endY: 0 }, + { x: 1.02, baseY: -20, endY: 1 }, + { x: 1.03, baseY: 2, endY: 30 }, + { x: 1.04, baseY: 3, endY: 4 }, + { x: 1.05, baseY: 5, endY: -40 }, + { x: 1.06, baseY: 35, endY: 6 }, + { x: 1.07, baseY: 0, endY: 0 }, ] - const reduced = reduceStackedAreaPoints(points, 1) + const selectedIndexes = selectStackedAreaPointIndexes([points], 1) + const reduced = reduceStackedAreaPoints(points, selectedIndexes) - expect(reduced).toEqual(points) + expect(reduced).toEqual([points[0], points[1], points[2], points[4], points[5], points[6]]) }) it("preserves gaps while reducing dense runs", () => { @@ -56,7 +79,8 @@ describe("stacked area plotter", () => { const right = Array.from({ length: 20 }, (_, index) => makePoint(index, 20, 2)) const points = [...left, null, ...right] - const reduced = reduceStackedAreaPoints(points, 2) + const selectedIndexes = selectStackedAreaPointIndexes([points], 2) + const reduced = reduceStackedAreaPoints(points, selectedIndexes) const gap = reduced.indexOf(null) expect(gap).toBeGreaterThan(0) @@ -79,10 +103,15 @@ describe("stacked area plotter", () => { endY: upperEnd[index], })) - const reducedLower = reduceStackedAreaPoints(lower, 1) - const reducedUpper = reduceStackedAreaPoints(upper, 1) + const selectedIndexes = selectStackedAreaPointIndexes([lower, upper], 1) + const reducedLower = reduceStackedAreaPoints(lower, selectedIndexes) + const reducedUpper = reduceStackedAreaPoints(upper, selectedIndexes) const x = xValues[3] + expect(reducedLower.map(point => point?.x)).toEqual(reducedUpper.map(point => point?.x)) + expect(reducedLower.map(point => point?.endY)).toEqual( + reducedUpper.map(point => point?.baseY) + ) expect(interpolateBoundary(reducedLower, x, "endY")).toBe(60) expect(interpolateBoundary(reducedUpper, x, "baseY")).toBe(60) }) diff --git a/src/components/line/dimensions/valueWithUnit.test.js b/src/components/line/dimensions/valueWithUnit.test.js index e74f0897..7d0ef411 100644 --- a/src/components/line/dimensions/valueWithUnit.test.js +++ b/src/components/line/dimensions/valueWithUnit.test.js @@ -44,7 +44,9 @@ describe("ValueWithUnit", () => { expect(units[0]).toHaveTextContent("B") expect(grids[1]).toHaveTextContent("1KiB") expect(units[1]).toHaveTextContent("KiB") - expect(grids[1]).toHaveStyle("grid-template-columns: minmax(0,1fr) 44px") + expect(grids[1]).toHaveStyle( + "grid-template-columns: minmax(auto,1fr) minmax(40px,max-content)" + ) expect(units[1]).toHaveStyle({ minWidth: "0px", padding: "0px 0px 0px 8px", @@ -65,7 +67,9 @@ describe("ValueWithUnit", () => { const detail = screen.getByTestId("value-unit-detail") expect(grid).toContainElement(detail) - expect(grid).toHaveStyle("grid-template-columns: minmax(0,1fr) 44px") + expect(grid).toHaveStyle( + "grid-template-columns: minmax(auto,1fr) minmax(40px,max-content)" + ) expect(detail).toHaveStyle({ margin: "4px 0px 0px", textAlign: "right", From 0e59f981012f6fe1fc85660ee063f9fc06134c94 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 14:46:57 +0300 Subject: [PATCH 11/14] v6.12.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bb7d823e..2a901b03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/charts", - "version": "6.12.1", + "version": "6.12.2", "description": "Netdata frontend SDK and chart utilities", "main": "dist/index.js", "module": "dist/es6/index.js", From e3c0efa9c25011f748c1b78e016c23266a01bcdc Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 15:02:20 +0300 Subject: [PATCH 12/14] Add failing test for wrong point calculation. --- .../dygraph/plotters/stackedArea.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.test.js b/src/chartLibraries/dygraph/plotters/stackedArea.test.js index b93f42f3..1fba7db4 100644 --- a/src/chartLibraries/dygraph/plotters/stackedArea.test.js +++ b/src/chartLibraries/dygraph/plotters/stackedArea.test.js @@ -41,6 +41,25 @@ describe("stacked area plotter", () => { expect(reduced[reduced.length - 1]).toBe(points[points.length - 1]) }) + it("uses pixel coordinates when Dygraph points include normalized and canvas x values", () => { + const width = 800 + const count = 86400 + const points = Array.from({ length: count }, (_, index) => ({ + x: index / (count - 1), + canvasx: (index / (count - 1)) * width, + baseY: index % 31, + endY: index % 47, + })) + + const selectedIndexes = selectStackedAreaPointIndexes([points], width) + const selectedPixels = new Set( + selectedIndexes.map(index => Math.round(points[index].canvasx)) + ) + + expect(selectedIndexes.length).toBeGreaterThanOrEqual(width + 1) + expect(selectedPixels.size).toBe(width + 1) + }) + it("keeps the canvas-width bound independent of the number of stacked series", () => { const width = 100 const count = 10000 From 451aff02da7ee1586c01d8314a5717a9f6020220 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 15:03:35 +0300 Subject: [PATCH 13/14] Fix stacked area point calculation. --- src/chartLibraries/dygraph/plotters/stackedArea.js | 2 +- src/chartLibraries/dygraph/plotters/stackedArea.test.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.js b/src/chartLibraries/dygraph/plotters/stackedArea.js index ea8e8527..d0364ace 100644 --- a/src/chartLibraries/dygraph/plotters/stackedArea.js +++ b/src/chartLibraries/dygraph/plotters/stackedArea.js @@ -3,7 +3,7 @@ import { getDivergingStackBounds } from "../divergingStack" const maxPointsPerPixel = 6 -const getPointX = point => point?.x ?? point?.canvasx +const getPointX = point => (Number.isFinite(point?.canvasx) ? point.canvasx : point?.x) const getPointBounds = point => { if (Number.isFinite(point?.baseY) && Number.isFinite(point?.endY)) { diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.test.js b/src/chartLibraries/dygraph/plotters/stackedArea.test.js index 1fba7db4..4bf32f45 100644 --- a/src/chartLibraries/dygraph/plotters/stackedArea.test.js +++ b/src/chartLibraries/dygraph/plotters/stackedArea.test.js @@ -57,6 +57,7 @@ describe("stacked area plotter", () => { ) expect(selectedIndexes.length).toBeGreaterThanOrEqual(width + 1) + expect(selectedIndexes.length).toBeLessThanOrEqual((width + 1) * 6) expect(selectedPixels.size).toBe(width + 1) }) From e730793436695a885a31ef6876cb5e70f756ead1 Mon Sep 17 00:00:00 2001 From: novykh Date: Mon, 13 Jul 2026 15:08:44 +0300 Subject: [PATCH 14/14] v6.12.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a901b03..b7557a10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/charts", - "version": "6.12.2", + "version": "6.12.3", "description": "Netdata frontend SDK and chart utilities", "main": "dist/index.js", "module": "dist/es6/index.js",