diff --git a/package.json b/package.json index bb7d823e..b7557a10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/charts", - "version": "6.12.1", + "version": "6.12.3", "description": "Netdata frontend SDK and chart utilities", "main": "dist/index.js", "module": "dist/es6/index.js", diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.js b/src/chartLibraries/dygraph/plotters/stackedArea.js index 089bfd60..d0364ace 100644 --- a/src/chartLibraries/dygraph/plotters/stackedArea.js +++ b/src/chartLibraries/dygraph/plotters/stackedArea.js @@ -1,48 +1,183 @@ import Dygraph from "dygraphs" import { getDivergingStackBounds } from "../divergingStack" -const makeFillPlotter = () => plotter => { - const { drawingContext: ctx, dygraph, points, setName } = plotter - const stepPlot = dygraph.getBooleanOption("stepPlot", setName) +const maxPointsPerPixel = 6 - ctx.fillStyle = plotter.color - ctx.globalAlpha = dygraph.getNumericOption("fillAlpha", setName) - ctx.beginPath() +const getPointX = point => (Number.isFinite(point?.canvasx) ? point.canvasx : point?.x) - let previous +const getPointBounds = point => { + if (Number.isFinite(point?.baseY) && Number.isFinite(point?.endY)) { + return { base: point.baseY, end: point.endY } + } - points.forEach(point => { - const bounds = getDivergingStackBounds(point) - if (!bounds) { - previous = null + return getDivergingStackBounds(point) +} + +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 } - const current = { - x: point.canvasx, - baseY: dygraph.toDomYCoord(bounds.base), - endY: dygraph.toDomYCoord(bounds.end), + 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(bucketIndex => target.push(bucket[bucketIndex])) +} + +export const selectStackedAreaPointIndexes = (seriesPoints, width) => { + const stackedSeriesPoints = seriesPoints.filter(points => points?.some(getPointBounds)) + if (!stackedSeriesPoints.length) return null + + 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 + + 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 + selected.push(index) + continue + } + + const nextPixel = Math.round(x) + if (pixel !== null && nextPixel !== pixel) { + appendBucket(selected, bucket, stackedSeriesPoints, referencePoints) + bucket = [] } - if (previous) { - ctx.moveTo(previous.x, previous.endY) + pixel = nextPixel + bucket.push(index) + } - if (stepPlot) { - ctx.lineTo(current.x, previous.endY) - ctx.lineTo(current.x, current.endY) - } else { - ctx.lineTo(current.x, current.endY) - } + appendBucket(selected, bucket, stackedSeriesPoints, referencePoints) + + return selected +} + +export const reduceStackedAreaPoints = (points, selectedIndexes) => + selectedIndexes ? selectedIndexes.map(index => points[index] ?? null) : points - ctx.lineTo(current.x, current.baseY) - ctx.lineTo(previous.x, previous.baseY) - ctx.closePath() +const makeFillPlotter = () => { + let cachedSeriesPoints + let cachedWidth + let selectedIndexes + + return plotter => { + const { drawingContext: ctx, dygraph, points, plotArea, seriesIndex, setName } = plotter + const allSeriesPoints = plotter.allSeriesPoints || [points] + const stepPlot = dygraph.getBooleanOption("stepPlot", setName) + + if (seriesIndex === 0 || cachedSeriesPoints !== allSeriesPoints || cachedWidth !== plotArea.w) { + cachedSeriesPoints = allSeriesPoints + cachedWidth = plotArea.w + selectedIndexes = selectStackedAreaPointIndexes(allSeriesPoints, plotArea.w) } - previous = current - }) + ctx.fillStyle = plotter.color + ctx.globalAlpha = dygraph.getNumericOption("fillAlpha", setName) + ctx.beginPath() + + const renderPoints = reduceStackedAreaPoints(points, selectedIndexes).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 + + 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.lineTo(current.x, current.baseY) + ctx.lineTo(previous.x, previous.baseY) + ctx.closePath() + } + + previous = current + }) - ctx.fill() + ctx.fill() + } } const makeLinePlotter = () => plotter => { diff --git a/src/chartLibraries/dygraph/plotters/stackedArea.test.js b/src/chartLibraries/dygraph/plotters/stackedArea.test.js new file mode 100644 index 00000000..4bf32f45 --- /dev/null +++ b/src/chartLibraries/dygraph/plotters/stackedArea.test.js @@ -0,0 +1,138 @@ +import makeStackedAreaPlotter, { + reduceStackedAreaPoints, + selectStackedAreaPointIndexes, +} from "./stackedArea" + +const makePoint = (index, count, width) => ({ + x: (index / (count - 1)) * width, + baseY: index % 31, + 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() + + 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 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("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(selectedIndexes.length).toBeLessThanOrEqual((width + 1) * 6) + 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 + 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.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 selectedIndexes = selectStackedAreaPointIndexes([points], 1) + const reduced = reduceStackedAreaPoints(points, selectedIndexes) + + expect(reduced).toEqual([points[0], points[1], points[2], points[4], points[5], points[6]]) + }) + + 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 selectedIndexes = selectStackedAreaPointIndexes([points], 2) + const reduced = reduceStackedAreaPoints(points, selectedIndexes) + const gap = reduced.indexOf(null) + + 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 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/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/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 79% rename from src/components/drawer/valueWithUnit.js rename to src/components/line/dimensions/valueWithUnit.js index 7a0067dc..53ca95c3 100644 --- a/src/components/drawer/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({ @@ -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 76% rename from src/components/drawer/valueWithUnit.test.js rename to src/components/line/dimensions/valueWithUnit.test.js index e417d6c4..7d0ef411 100644 --- a/src/components/drawer/valueWithUnit.test.js +++ b/src/components/line/dimensions/valueWithUnit.test.js @@ -37,14 +37,16 @@ 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") 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", @@ -54,18 +56,20 @@ 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") + expect(grid).toHaveStyle( + "grid-template-columns: minmax(auto,1fr) minmax(40px,max-content)" + ) expect(detail).toHaveStyle({ margin: "4px 0px 0px", textAlign: "right", 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"} /> $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) => { 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 }) => { 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") + }) +}) 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 a82fa712..1658b9e2 100644 --- a/src/sdk/makeChart/index.js +++ b/src/sdk/makeChart/index.js @@ -8,8 +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 makeWeights from "./makeWeights" +import makeGetUnitSign from "./makeGetUnitSign" import getAggregateMethod from "./filters/getAggregateMethod" const themeIndex = { @@ -252,10 +251,8 @@ export default ({ makeDimensions(node, sdk) makeDataFetch(node, sdk) - makeWeights(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 }) - }) 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() - }) -})