From 6412f77b7ec189c687ef66289d0ac396f84af2c6 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 15 Jul 2026 13:49:20 +0300 Subject: [PATCH 01/10] Skip redundant chart renders --- src/chartLibraries/bars/index.js | 9 +- src/chartLibraries/d3pie/index.js | 8 +- src/chartLibraries/dygraph/index.js | 32 ++-- src/chartLibraries/easyPie/index.js | 12 +- src/chartLibraries/gauge/index.js | 12 +- src/chartLibraries/groupBoxes/index.js | 9 +- src/chartLibraries/number/index.js | 9 +- src/chartLibraries/table/index.js | 9 +- src/sdk/makeChart/index.js | 25 ++- src/sdk/makeChart/makeDataFetch.js | 5 +- src/sdk/makeChart/makeDataFetch.test.js | 15 ++ src/sdk/makeChart/renderFreshness.test.js | 191 ++++++++++++++++++++++ src/sdk/makeChartUI.js | 54 +++++- src/sdk/makeChartUI.test.js | 6 +- 14 files changed, 333 insertions(+), 63 deletions(-) create mode 100644 src/sdk/makeChart/renderFreshness.test.js diff --git a/src/chartLibraries/bars/index.js b/src/chartLibraries/bars/index.js index 6750115f..4fc5059c 100644 --- a/src/chartLibraries/bars/index.js +++ b/src/chartLibraries/bars/index.js @@ -31,20 +31,17 @@ export default (sdk, chart) => { const getMinMax = () => chart.getAttribute("getValueRange")(chart) const render = () => { - chartUI.render() - const { hoverX, loaded } = chart.getAttributes() - if (!loaded) return + if (!loaded) return false const { data } = chart.getPayload() const row = hoverX ? chart.getClosestRow(hoverX[0]) : data.length - 1 const rowData = data[row] - if (!Array.isArray(rowData)) return + if (!Array.isArray(rowData)) return chartUI.render() - chartUI.render() const [min, max] = getMinMax() if (min !== prevMin || max !== prevMax) { @@ -54,7 +51,9 @@ export default (sdk, chart) => { prevMin = min prevMax = max + chartUI.render() chartUI.trigger("rendered") + return true } const unmount = () => { diff --git a/src/chartLibraries/d3pie/index.js b/src/chartLibraries/d3pie/index.js index f91372d6..c5190e48 100644 --- a/src/chartLibraries/d3pie/index.js +++ b/src/chartLibraries/d3pie/index.js @@ -63,11 +63,9 @@ export default (sdk, chart) => { const getMinMax = () => chart.getAttribute("getValueRange")(chart) const render = () => { - chartUI.render() - const { hoverX, loaded } = chart.getAttributes() - if (!pie || !loaded) return + if (!pie || !loaded) return false const { data } = chart.getPayload() @@ -100,8 +98,6 @@ export default (sdk, chart) => { prevMin = min prevMax = max - chartUI.render() - pie.options.data.content = values.length ? values : [ @@ -117,7 +113,9 @@ export default (sdk, chart) => { reMake() }) + chartUI.render() chartUI.trigger("rendered") + return true } const unmount = () => { diff --git a/src/chartLibraries/dygraph/index.js b/src/chartLibraries/dygraph/index.js index 5f0a4a23..52e8eb40 100644 --- a/src/chartLibraries/dygraph/index.js +++ b/src/chartLibraries/dygraph/index.js @@ -189,6 +189,7 @@ export default (sdk, chart) => { ? 0 : chart.getAttribute("unitsConversionFractionDigits")[0], }) + chartUI.render() }), chart.onAttributeChange("staticValueRange", staticValueRange => { if (!staticValueRange) { @@ -324,9 +325,10 @@ export default (sdk, chart) => { const { chartType, includeZero, enabledXAxis, enabledYAxis, yAxisLabelWidth, stepPlot } = chart.getAttributes() - const plotter = stepPlot && chartType === "line" - ? plotterByChartType.default - : plotterByChartType[chartType] || plotterByChartType.default + const plotter = + stepPlot && chartType === "line" + ? plotterByChartType.default + : plotterByChartType[chartType] || plotterByChartType.default const { strokeWidth, @@ -343,14 +345,14 @@ export default (sdk, chart) => { } = optionsByChartType[chartType] || optionsByChartType.default const yAxisLabelFormatter = makeYAxisLabelFormatter(labels) - const yTicker = makeYTicker - ? makeYTicker({ - labels: chart.getVisibleHeatmapIds?.() || chart.getVisibleDimensionIds(), - scale: chart.getHeatmapScale?.(), - secondsAsTime: chart.getAttribute("secondsAsTime"), - units: chart.getVisibleDimensionIds().map(id => chart.getDimensionUnit(id)), - }) - : null + const yTicker = makeYTicker + ? makeYTicker({ + labels: chart.getVisibleHeatmapIds?.() || chart.getVisibleDimensionIds(), + scale: chart.getHeatmapScale?.(), + secondsAsTime: chart.getAttribute("secondsAsTime"), + units: chart.getVisibleDimensionIds().map(id => chart.getDimensionUnit(id)), + }) + : null const { selectedLegendDimensions } = chart.getAttributes() const dimensionIds = chart.getPayloadDimensionIds() @@ -476,12 +478,10 @@ export default (sdk, chart) => { const getDygraph = () => dygraph const render = () => { - if (!dygraph) return + if (!dygraph) return false const { highlighting, panning, processing } = chart.getAttributes() - if (highlighting || panning || processing) return - - chartUI.render() + if (highlighting || panning || processing) return false dygraph.updateOptions({ ...makeDataOptions(), @@ -495,7 +495,9 @@ export default (sdk, chart) => { ...makeSparklineOptions(), }) + chartUI.render() chartUI.trigger("rendered") + return true } const getPreceded = () => { diff --git a/src/chartLibraries/easyPie/index.js b/src/chartLibraries/easyPie/index.js index 5b1fa04f..5814f026 100644 --- a/src/chartLibraries/easyPie/index.js +++ b/src/chartLibraries/easyPie/index.js @@ -85,27 +85,23 @@ export default (sdk, chart) => { const getMinMax = () => chart.getAttribute("getValueRange")(chart) const render = () => { - chartUI.render() - const { hoverX, loaded } = chart.getAttributes() - if (!easyPie || !loaded) return + if (!easyPie || !loaded) return false const { data } = chart.getPayload() - if (data?.length === undefined) return + if (data?.length === undefined) return false const row = hoverX ? chart.getClosestRow(hoverX[0]) : data.length - 1 const rowData = data[row] - if (!Array.isArray(rowData)) return + if (!Array.isArray(rowData)) return chartUI.render() const [, ...rows] = rowData const value = rows.reduce((acc, v = 0) => acc + v, 0) let [min, max] = getMinMax() - chartUI.render() - const percentage = ((value - min) / (max - min)) * 100 easyPie.update(percentage) @@ -117,7 +113,9 @@ export default (sdk, chart) => { prevMin = min prevMax = max + chartUI.render() chartUI.trigger("rendered") + return true } const unmount = () => { diff --git a/src/chartLibraries/gauge/index.js b/src/chartLibraries/gauge/index.js index c98e827b..dfc287fb 100644 --- a/src/chartLibraries/gauge/index.js +++ b/src/chartLibraries/gauge/index.js @@ -128,20 +128,18 @@ export default (sdk, chart) => { const getMinMax = () => chart.getAttribute("getValueRange")(chart) const render = () => { - chartUI.render() - const { hoverX, loaded } = chart.getAttributes() - if (!gauge || !loaded) return + if (!gauge || !loaded) return false const { data } = chart.getPayload() - if (data?.length === undefined) return + if (data?.length === undefined) return false const row = hoverX ? chart.getClosestRow(hoverX[0]) : data.length - 1 const rowData = data[row] - if (!Array.isArray(rowData)) return + if (!Array.isArray(rowData)) return chartUI.render() const [, ...rows] = rowData @@ -156,12 +154,12 @@ export default (sdk, chart) => { prevMin = min prevMax = max - chartUI.render() - const percentage = Math.max(Math.min(((value - min) / (max - min)) * 100, 99.999), 0.001) gauge.set(percentage) + chartUI.render() chartUI.trigger("rendered") + return true } const unmount = () => { diff --git a/src/chartLibraries/groupBoxes/index.js b/src/chartLibraries/groupBoxes/index.js index 345dcf7c..0183cf09 100644 --- a/src/chartLibraries/groupBoxes/index.js +++ b/src/chartLibraries/groupBoxes/index.js @@ -12,6 +12,7 @@ export default (sdk, chart) => { let initialized = false const mount = () => { + chartUI.mount() updateGroupBox() chartUI.trigger("resize") @@ -27,14 +28,15 @@ export default (sdk, chart) => { const unmount = () => { if (offs) offs() offs = null + chartUI.unmount() } const updateGroupBox = ({ force = false } = {}) => { - if (initialized && !force && !chart.consumePayload()) return + if (initialized && !force && !chart.consumePayload()) return false const payload = chart.getPayload() - if (!payload.data.length) return + if (!payload.data.length) return false groupBoxData = payload @@ -42,7 +44,9 @@ export default (sdk, chart) => { initialized = true + chartUI.render() chartUI.trigger("groupBoxChanged", groupBoxData) + return true } const getGroupBox = () => groupBoxData @@ -72,6 +76,7 @@ export default (sdk, chart) => { chartUI.render() chartUI.trigger("rendered") + return true } const instance = { diff --git a/src/chartLibraries/number/index.js b/src/chartLibraries/number/index.js index 6750115f..4fc5059c 100644 --- a/src/chartLibraries/number/index.js +++ b/src/chartLibraries/number/index.js @@ -31,20 +31,17 @@ export default (sdk, chart) => { const getMinMax = () => chart.getAttribute("getValueRange")(chart) const render = () => { - chartUI.render() - const { hoverX, loaded } = chart.getAttributes() - if (!loaded) return + if (!loaded) return false const { data } = chart.getPayload() const row = hoverX ? chart.getClosestRow(hoverX[0]) : data.length - 1 const rowData = data[row] - if (!Array.isArray(rowData)) return + if (!Array.isArray(rowData)) return chartUI.render() - chartUI.render() const [min, max] = getMinMax() if (min !== prevMin || max !== prevMax) { @@ -54,7 +51,9 @@ export default (sdk, chart) => { prevMin = min prevMax = max + chartUI.render() chartUI.trigger("rendered") + return true } const unmount = () => { diff --git a/src/chartLibraries/table/index.js b/src/chartLibraries/table/index.js index fde80b65..90fb8810 100644 --- a/src/chartLibraries/table/index.js +++ b/src/chartLibraries/table/index.js @@ -29,20 +29,17 @@ export default (sdk, chart) => { } const render = () => { - chartUI.render() - const { hoverX, loaded } = chart.getAttributes() - if (!loaded) return + if (!loaded) return false const { data } = chart.getPayload() const row = hoverX ? chart.getClosestRow(hoverX[0]) : data.length - 1 const rowData = data[row] - if (!Array.isArray(rowData)) return + if (!Array.isArray(rowData)) return chartUI.render() - chartUI.render() const min = chart.getAttribute("min") const max = chart.getAttribute("max") @@ -54,7 +51,9 @@ export default (sdk, chart) => { prevMin = min prevMax = max + chartUI.render() chartUI.trigger("rendered") + return true } const unmount = () => { diff --git a/src/sdk/makeChart/index.js b/src/sdk/makeChart/index.js index 1658b9e2..ee2e5785 100644 --- a/src/sdk/makeChart/index.js +++ b/src/sdk/makeChart/index.js @@ -53,6 +53,13 @@ export default ({ } let uiInstances = {} + let renderRevision = 0 + + node.getRenderRevision = () => renderRevision + node.invalidateRender = () => { + renderRevision += 1 + return renderRevision + } node.getUpdateEvery = () => { if (!node) return @@ -135,9 +142,21 @@ export default ({ uiInstances[uiName] = newUi } - const render = executeLatest.add(() => - Object.keys(uiInstances).forEach(uiName => uiInstances[uiName].render()) - ) + const render = executeLatest.add(() => { + if (!uiInstances) return + + Object.keys(uiInstances).forEach(uiName => { + const chartUI = uiInstances[uiName] + if (!chartUI?.render) return + + if (chartUI.renderIfStale) { + chartUI.renderIfStale(chartUI.render) + return + } + + chartUI.render() + }) + }) node.on("render", render) unitConversion(node) diff --git a/src/sdk/makeChart/makeDataFetch.js b/src/sdk/makeChart/makeDataFetch.js index 42fd1d85..6d0f6010 100644 --- a/src/sdk/makeChart/makeDataFetch.js +++ b/src/sdk/makeChart/makeDataFetch.js @@ -283,7 +283,10 @@ export default chart => { const prevPayload = payload payload = nextPayload - if (chart) chart.trigger("payloadChanged", nextPayload, prevPayload) + if (chart) { + chart.invalidateRender() + chart.trigger("payloadChanged", nextPayload, prevPayload) + } return true } diff --git a/src/sdk/makeChart/makeDataFetch.test.js b/src/sdk/makeChart/makeDataFetch.test.js index 2db3bf9d..a5a23f91 100644 --- a/src/sdk/makeChart/makeDataFetch.test.js +++ b/src/sdk/makeChart/makeDataFetch.test.js @@ -53,6 +53,7 @@ describe("makeDataFetch", () => { updateDimensions: jest.fn(), startAutofetch: jest.fn(), backoff: jest.fn(), + invalidateRender: jest.fn(), invalidateClosestRowCache: jest.fn(), getFilteredNodeIds: jest.fn(() => []), getUnits: jest.fn(() => "value"), @@ -226,4 +227,18 @@ describe("makeDataFetch", () => { expect(chart.getAttribute("liveAnchor")).toBe(1002000) }) + + it("invalidates rendering only when a successful payload becomes current", async () => { + const { chart } = makeTestChart() + const initialRevision = chart.getRenderRevision() + + await chart.fetch() + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(chart.getRenderRevision()).toBe(initialRevision + 1) + + chart.consumePayload() + + expect(chart.getRenderRevision()).toBe(initialRevision + 1) + }) }) diff --git a/src/sdk/makeChart/renderFreshness.test.js b/src/sdk/makeChart/renderFreshness.test.js new file mode 100644 index 00000000..4cf9981a --- /dev/null +++ b/src/sdk/makeChart/renderFreshness.test.js @@ -0,0 +1,191 @@ +import { makeTestChart } from "@jest/testUtilities" +import makeChartUI from "@/sdk/makeChartUI" + +const flushRender = () => new Promise(resolve => setTimeout(resolve, 10)) + +const makeCountingUI = (sdk, chart, { fail = false, defer = false } = {}) => { + const chartUI = makeChartUI(sdk, chart) + let attempts = 0 + let shouldFail = fail + let shouldDefer = defer + + const render = () => { + attempts += 1 + + if (shouldFail) { + shouldFail = false + throw new Error("render failed") + } + + if (shouldDefer) { + shouldDefer = false + return false + } + + chartUI.render() + return true + } + + return { + ...chartUI, + render, + getAttempts: () => attempts, + } +} + +describe("chart render freshness", () => { + it("coalesces render requests and skips every request after the UI becomes fresh", async () => { + const { sdk, chart } = makeTestChart() + const chartUI = makeCountingUI(sdk, chart) + chart.setUI(chartUI) + chartUI.mount(document.createElement("div")) + + chart.trigger("render") + chart.trigger("render") + await flushRender() + + expect(chartUI.getAttempts()).toBe(1) + + chart.trigger("render") + await flushRender() + + expect(chartUI.getAttempts()).toBe(1) + }) + + it("does not redraw when only the live clock advances", async () => { + const { sdk, chart } = makeTestChart({ attributes: { after: -300, liveAnchor: null } }) + const chartUI = makeCountingUI(sdk, chart) + chart.setUI(chartUI) + chartUI.mount(document.createElement("div")) + + chart.trigger("render") + await flushRender() + + sdk.getRoot().setAttribute("fetchAt", Date.now() + 1000) + chart.trigger("render") + await flushRender() + + expect(chartUI.getAttempts()).toBe(1) + }) + + it("renders every UI once after shared data invalidation", async () => { + const { sdk, chart } = makeTestChart() + const defaultUI = makeCountingUI(sdk, chart) + const secondaryUI = makeCountingUI(sdk, chart) + chart.setUI(defaultUI) + chart.setUI(secondaryUI, "secondary") + defaultUI.mount(document.createElement("div")) + secondaryUI.mount(document.createElement("div")) + + chart.trigger("render") + await flushRender() + + chart.invalidateRender() + chart.trigger("render") + chart.trigger("render") + await flushRender() + + expect(defaultUI.getAttempts()).toBe(2) + expect(secondaryUI.getAttempts()).toBe(2) + }) + + it("invalidates one UI for a size or presentation change without redrawing the others", async () => { + const { sdk, chart } = makeTestChart() + const defaultUI = makeCountingUI(sdk, chart) + const secondaryUI = makeCountingUI(sdk, chart) + chart.setUI(defaultUI) + chart.setUI(secondaryUI, "secondary") + defaultUI.mount(document.createElement("div")) + secondaryUI.mount(document.createElement("div")) + + chart.trigger("render") + await flushRender() + + secondaryUI.invalidateRender() + chart.trigger("render") + await flushRender() + + expect(defaultUI.getAttempts()).toBe(1) + expect(secondaryUI.getAttempts()).toBe(2) + }) + + it("does not consume revisions while unmounted and renders once after remount", async () => { + const { sdk, chart } = makeTestChart() + const chartUI = makeCountingUI(sdk, chart) + chart.setUI(chartUI) + + chart.trigger("render") + await flushRender() + + expect(chartUI.getAttempts()).toBe(0) + + chartUI.mount(document.createElement("div")) + chart.trigger("render") + await flushRender() + + expect(chartUI.getAttempts()).toBe(1) + + chartUI.unmount() + chart.invalidateRender() + chart.trigger("render") + await flushRender() + + expect(chartUI.getAttempts()).toBe(1) + + chartUI.mount(document.createElement("div")) + chart.trigger("render") + await flushRender() + + expect(chartUI.getAttempts()).toBe(2) + }) + + it("keeps a deferred or failed render stale for retry", () => { + const { sdk, chart } = makeTestChart() + const deferredUI = makeCountingUI(sdk, chart, { defer: true }) + const failingUI = makeCountingUI(sdk, chart, { fail: true }) + deferredUI.mount(document.createElement("div")) + failingUI.mount(document.createElement("div")) + + expect(deferredUI.renderIfStale(deferredUI.render)).toBe(false) + expect(deferredUI.isRenderStale()).toBe(true) + expect(deferredUI.renderIfStale(deferredUI.render)).toBe(true) + expect(deferredUI.isRenderStale()).toBe(false) + + expect(() => failingUI.renderIfStale(failingUI.render)).toThrow("render failed") + expect(failingUI.isRenderStale()).toBe(true) + expect(failingUI.renderIfStale(failingUI.render)).toBe(true) + expect(failingUI.isRenderStale()).toBe(false) + }) + + it("invalidates the UI when its rendered target is mounted or unmounted", () => { + const { sdk, chart } = makeTestChart() + const chartUI = makeChartUI(sdk, chart) + + chartUI.render() + expect(chartUI.isRenderStale()).toBe(false) + + chartUI.mount(document.createElement("div")) + expect(chartUI.isRenderStale()).toBe(true) + + chartUI.render() + chartUI.unmount() + expect(chartUI.isRenderStale()).toBe(true) + }) + + it("tracks visible-dimension changes only while the UI is mounted", () => { + const { sdk, chart } = makeTestChart() + const chartUI = makeChartUI(sdk, chart) + + chartUI.mount(document.createElement("div")) + chartUI.render() + chart.trigger("visibleDimensionsChanged") + + expect(chartUI.isRenderStale()).toBe(true) + + chartUI.unmount() + chartUI.render() + chart.trigger("visibleDimensionsChanged") + + expect(chartUI.isRenderStale()).toBe(false) + }) +}) diff --git a/src/sdk/makeChartUI.js b/src/sdk/makeChartUI.js index e2898bed..cfca3fcb 100644 --- a/src/sdk/makeChartUI.js +++ b/src/sdk/makeChartUI.js @@ -1,15 +1,53 @@ import makeListeners from "@/helpers/makeListeners" -import makeExecuteLatest from "@/helpers/makeExecuteLatest" export default (sdk, chart) => { const listeners = makeListeners() - const executeLatest = makeExecuteLatest() let element = null + let mounted = false let renderedAt = chart.getDateWindow()[1] + let uiRenderRevision = 0 + let renderedUIRevision = -1 + let renderedChartRevision = -1 + let offVisibleDimensionsChanged = null + + const getChartRenderRevision = () => chart.getRenderRevision?.() ?? 0 + + const invalidateRender = () => { + uiRenderRevision += 1 + return uiRenderRevision + } + + const isRenderStale = () => + renderedUIRevision !== uiRenderRevision || renderedChartRevision !== getChartRenderRevision() + + const render = () => { + renderedAt = chart.getDateWindow()[1] + renderedUIRevision = uiRenderRevision + renderedChartRevision = getChartRenderRevision() + return true + } + + const renderIfStale = callback => { + if (!mounted || !isRenderStale()) return false + + const rendered = callback() + if (rendered === false) return false + + render() + return true + } + + const listenForVisibleDimensions = () => { + if (offVisibleDimensionsChanged) return + offVisibleDimensionsChanged = chart.on("visibleDimensionsChanged", invalidateRender) + } const mount = el => { element = el + mounted = true + invalidateRender() + listenForVisibleDimensions() sdk.trigger("mountChartUI", chart) chart.trigger("mountChartUI") @@ -19,14 +57,13 @@ export default (sdk, chart) => { sdk.trigger("unmountChartUI", chart) chart.trigger("unmountChartUI") listeners.offAll() + offVisibleDimensionsChanged?.() + offVisibleDimensionsChanged = null element = null - if (executeLatest) executeLatest.clear() + mounted = false + invalidateRender() } - const render = () => (renderedAt = chart.getDateWindow()[1]) - - chart.on("visibleDimensionsChanged", executeLatest.add(render)) - const getRenderedAt = () => renderedAt const getElement = () => element @@ -42,6 +79,9 @@ export default (sdk, chart) => { mount, unmount, render, + renderIfStale, + invalidateRender, + isRenderStale, getRenderedAt, getElement, getChartWidth, diff --git a/src/sdk/makeChartUI.test.js b/src/sdk/makeChartUI.test.js index e0298de0..c5693a8f 100644 --- a/src/sdk/makeChartUI.test.js +++ b/src/sdk/makeChartUI.test.js @@ -90,7 +90,11 @@ describe("makeChartUI", () => { expect(typeof chartUI.trigger).toBe("function") }) - it("registers for chart events", () => { + it("registers for chart events while mounted", () => { + expect(mockChart.on).not.toHaveBeenCalled() + + chartUI.mount(mockElement) + expect(mockChart.on).toHaveBeenCalledWith("visibleDimensionsChanged", expect.any(Function)) }) }) From 66dbaaad11eedcb933377b2b4bd448fe8bbc57da Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 15 Jul 2026 13:54:20 +0300 Subject: [PATCH 02/10] Skip unused Dygraphs legend plugin --- src/chartLibraries/dygraph/index.js | 3 +- src/chartLibraries/dygraph/makeDygraph.js | 18 +++++ .../dygraph/makeDygraph.test.js | 78 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/chartLibraries/dygraph/makeDygraph.js create mode 100644 src/chartLibraries/dygraph/makeDygraph.test.js diff --git a/src/chartLibraries/dygraph/index.js b/src/chartLibraries/dygraph/index.js index 52e8eb40..7764009f 100644 --- a/src/chartLibraries/dygraph/index.js +++ b/src/chartLibraries/dygraph/index.js @@ -18,6 +18,7 @@ import makeHoverX from "./hoverX" import makeOverlays from "./overlays" import crosshair from "./crosshair" import { makeDivergingStackedDataHandler } from "./divergingStack" +import makeDygraphWithoutLegend from "./makeDygraph" const touchEvents = ["touchstart", "touchmove", "touchend"] @@ -47,7 +48,7 @@ export default (sdk, chart) => { const isEmpty = attributes.outOfLimits || data.length === 0 - dygraph = new Dygraph(element, isEmpty ? [[0]] : data, { + dygraph = makeDygraphWithoutLegend(element, isEmpty ? [[0]] : data, { // timingName: chart.getId(), legend: "never", showLabelsOnHighlight: false, diff --git a/src/chartLibraries/dygraph/makeDygraph.js b/src/chartLibraries/dygraph/makeDygraph.js new file mode 100644 index 00000000..d17c9fb2 --- /dev/null +++ b/src/chartLibraries/dygraph/makeDygraph.js @@ -0,0 +1,18 @@ +import Dygraph from "dygraphs" + +const makeDygraphWithoutLegend = (...args) => { + const plugins = Dygraph.PLUGINS + const Legend = Dygraph.Plugins?.Legend + + if (!Array.isArray(plugins) || !Legend) return new Dygraph(...args) + + Dygraph.PLUGINS = plugins.filter(Plugin => Plugin !== Legend) + + try { + return new Dygraph(...args) + } finally { + Dygraph.PLUGINS = plugins + } +} + +export default makeDygraphWithoutLegend diff --git a/src/chartLibraries/dygraph/makeDygraph.test.js b/src/chartLibraries/dygraph/makeDygraph.test.js new file mode 100644 index 00000000..f78c5b2c --- /dev/null +++ b/src/chartLibraries/dygraph/makeDygraph.test.js @@ -0,0 +1,78 @@ +import Dygraph from "dygraphs" +import makeDygraphWithoutLegend from "./makeDygraph" + +const originalPlugins = Dygraph.PLUGINS +const instances = [] + +const createElement = () => { + const element = document.createElement("div") + Object.defineProperties(element, { + clientWidth: { value: 800 }, + clientHeight: { value: 400 }, + }) + element.style.padding = "0px" + document.body.appendChild(element) + return element +} + +const createDygraph = (options = {}) => { + const element = createElement() + + const instance = makeDygraphWithoutLegend(element, [[0, 1]], { + labels: ["time", "value"], + legend: "never", + ...options, + }) + + instances.push(instance) + return instance +} + +describe("makeDygraph without the built-in legend", () => { + afterEach(() => { + instances.splice(0).forEach(instance => instance.destroy()) + document.body.replaceChildren() + Dygraph.PLUGINS = originalPlugins + }) + + it("omits only the unused built-in legend plugin", () => { + const instance = createDygraph() + const activePluginTypes = instance.plugins_.map(({ plugin }) => plugin.constructor) + + expect(activePluginTypes).toEqual( + originalPlugins.filter(Plugin => Plugin !== Dygraph.Plugins.Legend) + ) + expect(Dygraph.PLUGINS).toBe(originalPlugins) + }) + + it("preserves plugins supplied for the chart instance", () => { + const customPlugin = { + activate: () => ({}), + } + const instance = createDygraph({ plugins: [customPlugin] }) + + expect(instance.plugins_.some(({ plugin }) => plugin === customPlugin)).toBe(true) + expect(Dygraph.PLUGINS).toBe(originalPlugins) + }) + + it("restores the global plugin registry when construction fails", () => { + class FailingPlugin { + activate() { + throw new Error("plugin failed") + } + } + + const configuredPlugins = [...originalPlugins, FailingPlugin] + Dygraph.PLUGINS = configuredPlugins + + const element = createElement() + + expect(() => + makeDygraphWithoutLegend(element, [[0, 1]], { + labels: ["time", "value"], + legend: "never", + }) + ).toThrow("plugin failed") + expect(Dygraph.PLUGINS).toBe(configuredPlugins) + }) +}) From 4faf94c1d0283f4b6d2a09e51fc3c19eb13efea4 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 15 Jul 2026 14:08:35 +0300 Subject: [PATCH 03/10] Add high-cardinality chart regression stories --- src/highCardinalityChart.stories.js | 382 +++++++++++++++++++++++ src/highCardinalityChart.stories.test.js | 21 ++ 2 files changed, 403 insertions(+) create mode 100644 src/highCardinalityChart.stories.js create mode 100644 src/highCardinalityChart.stories.test.js diff --git a/src/highCardinalityChart.stories.js b/src/highCardinalityChart.stories.js new file mode 100644 index 00000000..38c9fec7 --- /dev/null +++ b/src/highCardinalityChart.stories.js @@ -0,0 +1,382 @@ +import React, { useEffect, useMemo, useRef, useState } from "react" +import Line from "@/components/line" +import makeDefaultSDK from "./makeDefaultSDK" + +const contextId = "storybook.high_cardinality" +const baseTimestamp = Date.UTC(2026, 0, 1) +const updateEvery = 5 +const fixtureCache = new Map() +let chartSequence = 0 + +const sdk = makeDefaultSDK({ + attributes: { + contextScope: [contextId], + theme: "dark", + designFlavour: "default", + filterToolboxMode: "fixed", + navigation: "pan", + expandable: false, + }, +}) + +const getNow = () => globalThis.performance?.now?.() ?? Date.now() +const pad = (value, length) => String(value).padStart(length, "0") +const getValue = (dimensionIndex, pointIndex) => + ((dimensionIndex * 7919 + pointIndex * 104729) % 100000) / 100 + +const makeFixture = ({ dimensionCount, points, fullMetadata }) => { + const startedAt = getNow() + const idLength = String(dimensionCount).length + const ids = new Array(dimensionCount) + const priorities = new Array(dimensionCount) + const aggregated = new Array(dimensionCount) + const units = new Array(dimensionCount) + const min = new Array(dimensionCount) + const max = new Array(dimensionCount) + const avg = new Array(dimensionCount) + const arp = new Array(dimensionCount) + const con = new Array(dimensionCount) + const dimensions = new Array(dimensionCount) + + for (let index = 0; index < dimensionCount; index += 1) { + const id = `metric_${pad(dimensionCount - index, idLength)}` + const average = getValue(index, Math.floor(points / 2)) + + ids[index] = id + priorities[index] = index + aggregated[index] = 1 + units[index] = "value" + min[index] = 0 + max[index] = 1000 + avg[index] = average + arp[index] = 0 + con[index] = (index % 100) + 1 + dimensions[index] = { + id, + pri: index, + ds: { sl: 1, qr: 1 }, + sts: { min: 0, max: 1000, avg: average, arp: 0, con: con[index] }, + } + } + + const rows = new Array(points) + for (let pointIndex = 0; pointIndex < points; pointIndex += 1) { + const row = new Array(dimensionCount + 1) + row[0] = baseTimestamp + pointIndex * updateEvery * 1000 + + for (let dimensionIndex = 0; dimensionIndex < dimensionCount; dimensionIndex += 1) + row[dimensionIndex + 1] = getValue(dimensionIndex, pointIndex) + + rows[pointIndex] = row + } + + const metadataCount = fullMetadata ? dimensionCount : 0 + const nodes = new Array(metadataCount) + const instances = new Array(metadataCount) + for (let index = 0; index < metadataCount; index += 1) { + const suffix = pad(index, idLength) + const stats = { min: 0, max: 1000, avg: getValue(index, points - 1), con: 1 } + + nodes[index] = { + mg: `machine_${suffix}`, + nd: `node_${suffix}`, + nm: `node ${suffix}`, + ni: index, + is: { sl: 1, qr: 1 }, + ds: { sl: 1, qr: 1 }, + sts: stats, + } + instances[index] = { + id: `instance_${suffix}`, + ni: index, + ds: { sl: 1, qr: 1 }, + sts: stats, + } + } + + const dimensionStats = { min, max, avg, arp, con } + const firstTimestamp = rows[0][0] + const lastTimestamp = rows[rows.length - 1][0] + const payload = { + api: 2, + versions: {}, + summary: { + nodes, + contexts: [ + { + id: contextId, + ds: { sl: dimensionCount, qr: dimensionCount }, + sts: { min: 0, max: 1000, avg: 500, con: 100 }, + }, + ], + instances, + dimensions, + labels: [], + alerts: [], + }, + totals: { + contexts: { sl: 1 }, + nodes: { sl: metadataCount }, + instances: { sl: metadataCount }, + dimensions: { sl: dimensionCount }, + label_key_values: {}, + }, + functions: [], + result: { + labels: ["time", ...ids], + point: { value: 0 }, + data: rows, + }, + db: { + tiers: 1, + update_every: updateEvery, + first_entry: Math.floor(firstTimestamp / 1000), + last_entry: Math.floor(lastTimestamp / 1000), + units: ["value"], + dimensions: { + ids, + units, + sts: dimensionStats, + }, + per_tier: [], + }, + view: { + title: `${dimensionCount.toLocaleString()} deterministic dimensions`, + update_every: updateEvery, + after: Math.floor(firstTimestamp / 1000), + before: Math.floor(lastTimestamp / 1000), + units: ["value"], + chart_type: "line", + dimensions: { + grouped_by: ["dimension"], + ids, + names: ids, + units, + priorities, + aggregated, + sts: dimensionStats, + }, + min: 0, + max: 1000, + }, + } + + return { + payload, + generationMs: getNow() - startedAt, + } +} + +const getFixture = options => { + const key = `${options.dimensionCount}:${options.points}:${options.fullMetadata}` + if (!fixtureCache.has(key)) fixtureCache.set(key, makeFixture(options)) + return fixtureCache.get(key) +} + +const makeChart = ({ payload, dimensionsSort, legend }) => { + chartSequence += 1 + const chart = sdk.makeChart({ + getChart: async () => payload, + attributes: { + id: `high-cardinality-${chartSequence}`, + contextScope: [contextId], + after: payload.view.after, + before: payload.view.before, + points: payload.result.data.length, + dimensionsSort, + legend, + expandable: false, + }, + }) + + sdk.appendChild(chart) + return chart +} + +const wait = duration => new Promise(resolve => setTimeout(resolve, duration)) + +const variants = { + full: { fullMetadata: true, hasHeader: true, hasFooter: true, hasFilters: true, legend: true }, + withoutNidl: { + fullMetadata: true, + hasHeader: true, + hasFooter: true, + hasFilters: false, + legend: true, + }, + withoutLegend: { + fullMetadata: true, + hasHeader: true, + hasFooter: true, + hasFilters: true, + legend: false, + }, + graphOnlyFullMetadata: { + fullMetadata: true, + hasHeader: false, + hasFooter: false, + hasFilters: false, + legend: false, + }, + graphOnlyMinimalMetadata: { + fullMetadata: false, + hasHeader: false, + hasFooter: false, + hasFilters: false, + legend: false, + }, +} + +const HighCardinalityChart = ({ variant, dimensionCount, points, dimensionsSort }) => { + const config = variants[variant] + const fixture = useMemo( + () => getFixture({ dimensionCount, points, fullMetadata: config.fullMetadata }), + [dimensionCount, points, config.fullMetadata] + ) + const chart = useMemo( + () => makeChart({ payload: fixture.payload, dimensionsSort, legend: config.legend }), + [fixture, dimensionsSort, config.legend] + ) + const renderedCount = useRef(0) + const runId = useRef(0) + const [loaded, setLoaded] = useState(chart.getAttribute("loaded")) + const [ready, setReady] = useState(false) + const [renders, setRenders] = useState(0) + const [running, setRunning] = useState(false) + const [result, setResult] = useState(null) + + useEffect(() => { + let active = true + + renderedCount.current = 0 + setLoaded(chart.getAttribute("loaded")) + setReady(false) + setRenders(0) + setResult(null) + + const syncReady = () => { + const nextLoaded = chart.getAttribute("loaded") + + setLoaded(nextLoaded) + setReady(Boolean(nextLoaded && !chart.getUI().isRenderStale())) + } + const scheduleReady = () => queueMicrotask(() => active && syncReady()) + const offRendered = chart.getUI().on("rendered", () => { + renderedCount.current += 1 + setRenders(renderedCount.current) + syncReady() + }) + const offLoaded = chart.onAttributeChange("loaded", scheduleReady) + const offMounted = chart.on("mountChartUI", scheduleReady) + + scheduleReady() + + return () => { + active = false + runId.current += 1 + offRendered() + offLoaded() + offMounted() + chart.destroy() + } + }, [chart]) + + const testUnchangedRenders = async () => { + const currentRun = runId.current + 1 + runId.current = currentRun + const before = renderedCount.current + + setRunning(true) + setResult(null) + + for (let index = 0; index < 20; index += 1) { + chart.trigger("render") + await wait(25) + if (runId.current !== currentRun) return + } + + await wait(25) + if (runId.current !== currentRun) return + + setResult({ requested: 20, actual: renderedCount.current - before }) + setRunning(false) + } + + return ( +
+
+

High-cardinality chart anatomy

+
+
Dimensions
+
{dimensionCount.toLocaleString()}
+
Points
+
{points.toLocaleString()}
+
Nodes and instances
+
{config.fullMetadata ? dimensionCount.toLocaleString() : "not included"}
+
Fixture generation
+
{fixture.generationMs.toFixed(1)} ms
+
Actual SDK redraws observed
+
{renders.toLocaleString()}
+
+ + {result && ( +

+ Requested {result.requested}; actual redraws {result.actual}. +

+ )} +
+ +
+ ) +} + +const Story = variant => args => + +export const Full = Story("full") +export const WithoutNidl = Story("withoutNidl") +export const WithoutLegend = Story("withoutLegend") +export const GraphOnlyFullMetadata = Story("graphOnlyFullMetadata") +export const GraphOnlyMinimalMetadata = Story("graphOnlyMinimalMetadata") + +export default { + title: "Performance/High-cardinality chart anatomy", + component: HighCardinalityChart, + parameters: { + netdataTheme: "dark", + }, + args: { + dimensionCount: 10000, + points: 60, + dimensionsSort: "valueDesc", + }, + argTypes: { + dimensionCount: { + control: { type: "select" }, + options: [100, 1000, 5000, 10000], + }, + points: { + control: { type: "select" }, + options: [2, 60, 297], + }, + dimensionsSort: { + control: { type: "select" }, + options: ["default", "nameAsc", "valueDesc"], + }, + variant: { + table: { disable: true }, + }, + }, +} diff --git a/src/highCardinalityChart.stories.test.js b/src/highCardinalityChart.stories.test.js new file mode 100644 index 00000000..2b124b63 --- /dev/null +++ b/src/highCardinalityChart.stories.test.js @@ -0,0 +1,21 @@ +import React from "react" +import { screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { renderWithProviders } from "@jest/testUtilities" +import { GraphOnlyMinimalMetadata } from "./highCardinalityChart.stories" + +describe("high-cardinality chart story", () => { + it("renders generated data and skips unchanged cross-tick render requests", async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + const button = screen.getByRole("button", { name: "Request 20 unchanged renders" }) + await waitFor(() => expect(button.disabled).toBe(false)) + + await user.click(button) + + expect(await screen.findByText("Requested 20; actual redraws 0.")).toBeTruthy() + }) +}) From a4eb71ae509a4fa377cb565a9826ba9ec5cd93e8 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 15 Jul 2026 14:23:06 +0300 Subject: [PATCH 04/10] Prevent nested Dygraphs redraws --- src/chartLibraries/dygraph/index.js | 28 +++++++++++++++------ src/chartLibraries/dygraph/index.test.js | 32 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/chartLibraries/dygraph/index.js b/src/chartLibraries/dygraph/index.js index 7764009f..2b64ff42 100644 --- a/src/chartLibraries/dygraph/index.js +++ b/src/chartLibraries/dygraph/index.js @@ -32,6 +32,18 @@ export default (sdk, chart) => { let resizeObserver = null let overlays = null let executeLatest = null + let applyingOptions = false + + const updateOptions = options => { + const blockRedraw = applyingOptions + applyingOptions = true + + try { + return dygraph.updateOptions(options, blockRedraw) + } finally { + applyingOptions = blockRedraw + } + } const mount = element => { if (dygraph) return @@ -162,15 +174,15 @@ export default (sdk, chart) => { chart.onAttributeChange("theme", (nextTheme, prevTheme) => { element.classList.remove(prevTheme) element.classList.add(nextTheme) - dygraph.updateOptions(makeThemingOptions()) + updateOptions(makeThemingOptions()) }), chart.onAttributeChange("chartType", () => { if (chart.getAttribute("processing")) return - dygraph.updateOptions(makeChartTypeOptions()) + updateOptions(makeChartTypeOptions()) }), chart.onAttributeChange("unitsConversionPrefix", () => { - dygraph.updateOptions({ + updateOptions({ ...makeChartTypeOptions(), digitsAfterDecimal: chart.getAttribute("unitsConversionFractionDigits")[0] < 0 @@ -181,7 +193,7 @@ export default (sdk, chart) => { chart.onAttributeChange("selectedLegendDimensions", () => { if (chart.getAttribute("processing")) return - dygraph.updateOptions({ + updateOptions({ ...makeVisibilityOptions(), ...makeColorOptions(), ...makeChartTypeOptions(), @@ -194,20 +206,20 @@ export default (sdk, chart) => { }), chart.onAttributeChange("staticValueRange", staticValueRange => { if (!staticValueRange) { - dygraph.updateOptions({ + updateOptions({ valueRange: attributes.getValueRange(chart, { dygraph: true }), }) return } const [min, max] = staticValueRange - dygraph.updateOptions({ + updateOptions({ valueRange: isHeatmap(attributes.chartType) ? [Math.ceil(min), Math.ceil(max)] : attributes.getValueRange(chart, { dygraph: true }), }) }), - chart.onAttributeChange("timezone", () => dygraph.updateOptions({})), + chart.onAttributeChange("timezone", () => updateOptions({})), ].filter(Boolean) overlays.toggle() @@ -484,7 +496,7 @@ export default (sdk, chart) => { const { highlighting, panning, processing } = chart.getAttributes() if (highlighting || panning || processing) return false - dygraph.updateOptions({ + updateOptions({ ...makeDataOptions(), ...makeVisibilityOptions(), ...makeColorOptions(), diff --git a/src/chartLibraries/dygraph/index.test.js b/src/chartLibraries/dygraph/index.test.js index 8e41686d..6ae0951f 100644 --- a/src/chartLibraries/dygraph/index.test.js +++ b/src/chartLibraries/dygraph/index.test.js @@ -128,6 +128,38 @@ describe("dygraphChart", () => { expect(() => instance.render()).not.toThrow() }) + it("blocks a nested option redraw while preserving the outer redraw", () => { + const { sdk, chart } = makeTestChart({ + attributes: { + highlighting: false, + panning: false, + processing: false, + }, + }) + + chart.getPayload = () => ({ + data: [[1617946860000, 10]], + labels: ["time", "value"], + }) + chart.getDateWindow = () => [1617946860000, 1617947750000] + chart.formatXAxis = x => x.toString() + chart.getThemeAttribute = () => "#333" + chart.getUnitSign = () => "" + + const instance = dygraphChart(sdk, chart) + instance.mount(document.createElement("div")) + mockDygraph.updateOptions.mockClear() + mockDygraph.updateOptions.mockImplementationOnce(() => { + chart.updateAttribute("unitsConversionPrefix", ["Ki"]) + }) + + instance.render() + + expect(mockDygraph.updateOptions).toHaveBeenCalledTimes(2) + expect(mockDygraph.updateOptions.mock.calls[0][1]).toBe(false) + expect(mockDygraph.updateOptions.mock.calls[1][1]).toBe(true) + }) + it("skips render when highlighting, panning, or processing", () => { const { sdk, chart } = makeTestChart({ attributes: { From 4d860a031df97fb4e35ccd28aa8f92b19135048a Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 15 Jul 2026 14:35:30 +0300 Subject: [PATCH 05/10] Scale grouped context metadata processing --- src/sdk/makeChart/camelizePayload.js | 49 +++++++++----------- src/sdk/makeChart/camelizePayload.test.js | 54 +++++++++++++++++++++++ 2 files changed, 74 insertions(+), 29 deletions(-) diff --git a/src/sdk/makeChart/camelizePayload.js b/src/sdk/makeChart/camelizePayload.js index c3604b9a..18b57b36 100644 --- a/src/sdk/makeChart/camelizePayload.js +++ b/src/sdk/makeChart/camelizePayload.js @@ -69,6 +69,7 @@ const getStsByContext = (groups, units, dimensions, contextsArray) => { if (!Array.isArray(contextsArray) || !contextsArray.length) return [[], {}] const unitsByKey = {} + const groupedByContext = groups.includes("context") const regex = new RegExp( groups.reduce((s, g) => { @@ -91,40 +92,30 @@ const getStsByContext = (groups, units, dimensions, contextsArray) => { const [, ctx] = match - if (ctx && dimensions.sts) { - stsByCtx[ctx] = stsByCtx[ctx] || { min: Infinity, max: -Infinity } - stsByCtx[ctx].min = - stsByCtx[ctx].min > dimensions.sts.min[index] - ? dimensions.sts.min[index] - : stsByCtx[ctx].min - stsByCtx[ctx].max = - stsByCtx[ctx].max < dimensions.sts.max[index] - ? dimensions.sts.max[index] - : stsByCtx[ctx].max + if (ctx) { + if ( + groupedByContext && + !Object.prototype.hasOwnProperty.call(unitsByKey, ctx) && + dimensions.units + ) + unitsByKey[ctx] = dimensions.units[index] + + if (dimensions.sts) { + stsByCtx[ctx] = stsByCtx[ctx] || { min: Infinity, max: -Infinity } + stsByCtx[ctx].min = + stsByCtx[ctx].min > dimensions.sts.min[index] + ? dimensions.sts.min[index] + : stsByCtx[ctx].min + stsByCtx[ctx].max = + stsByCtx[ctx].max < dimensions.sts.max[index] + ? dimensions.sts.max[index] + : stsByCtx[ctx].max + } } return ctx || contextsArray[0].id }) - if (groups.includes("context")) { - contextsArray.forEach(ctx => { - const regex = new RegExp( - groups.reduce((s, g) => { - s = s + (s ? "," : "") - s = s + ("context" === g ? ctx.id : ".*") - - return s - }, "") - ) - - const dimIndex = dimensions.ids.findIndex(id => regex.test(id)) - - if (dimIndex === -1) return - - unitsByKey[ctx.id] = dimensions.units[dimIndex] - }) - } - return [ dimensionContexts, contextsArray.reduce((h, ctx) => { diff --git a/src/sdk/makeChart/camelizePayload.test.js b/src/sdk/makeChart/camelizePayload.test.js index bb10b501..a48357d3 100644 --- a/src/sdk/makeChart/camelizePayload.test.js +++ b/src/sdk/makeChart/camelizePayload.test.js @@ -309,4 +309,58 @@ describe("camelizePayload", () => { expect(result.dimensions).toHaveProperty("dim1") expect(result.dimensionIds).toContain("dim1") }) + + it("derives grouped context units and statistics in one dimension pass", () => { + const dimensionCount = 100 + let dimensionReads = 0 + const ids = new Proxy( + Array.from({ length: dimensionCount }, (_, index) => `node-${index},context.${index}`), + { + get(target, property, receiver) { + if (typeof property === "string" && /^\d+$/.test(property)) dimensionReads++ + return Reflect.get(target, property, receiver) + }, + } + ) + const contexts = Array.from({ length: dimensionCount }, (_, index) => ({ + id: `context.${index}`, + sts: { min: -1, max: -1 }, + })) + const payload = { + summary: { contexts }, + view: { + chart_type: "line", + units: "count", + dimensions: { + grouped_by: ["node", "context"], + ids, + units: Array.from({ length: dimensionCount }, (_, index) => `unit-${index}`), + sts: { + min: Array.from({ length: dimensionCount }, (_, index) => index), + max: Array.from({ length: dimensionCount }, (_, index) => index + 1), + }, + }, + }, + result: { + data: [], + labels: [], + point: { value: 0 }, + }, + } + + const result = camelizePayload(payload) + + expect(result.viewDimensions.contexts).toEqual(contexts.map(context => context.id)) + expect(result.unitsStsByContext["context.0"]).toEqual({ + min: 0, + max: 1, + units: "unit-0", + }) + expect(result.unitsStsByContext["context.99"]).toEqual({ + min: 99, + max: 100, + units: "unit-99", + }) + expect(dimensionReads).toBeLessThan(dimensionCount * 3) + }) }) From 72ced9faa2b632306d1f61f0174c7b9acf3f983f Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 15 Jul 2026 14:45:16 +0300 Subject: [PATCH 06/10] Bound dimension sort accessor work --- src/sdk/makeChart/makeDimensions.js | 126 ++++++++++++----------- src/sdk/makeChart/makeDimensions.test.js | 65 ++++++++++-- 2 files changed, 122 insertions(+), 69 deletions(-) diff --git a/src/sdk/makeChart/makeDimensions.js b/src/sdk/makeChart/makeDimensions.js index 6d10abae..5201045a 100644 --- a/src/sdk/makeChart/makeDimensions.js +++ b/src/sdk/makeChart/makeDimensions.js @@ -13,8 +13,6 @@ import groupBy from "lodash/groupBy" import isEmpty from "lodash/isEmpty" import { getPointValue } from "./getPointValue" -const noop = () => {} - export default (chart, sdk) => { let prevDimensionIds = [] let dimensionsById = {} @@ -41,97 +39,105 @@ export default (chart, sdk) => { return [...(viewDimensions?.ids || [])] } - const withSortByNameFallback = - (cb = noop, fallbackCb = noop) => - (a, b) => - cb(a, b) || - fallbackCb(a, b) || - chart.getDimensionName(a).localeCompare(chart.getDimensionName(b), undefined, { - sensitivity: "accent", - ignorePunctuation: true, - }) + const sortDimensionIds = ({ + getIds = chart.getPayloadDimensionIds, + getPrimary, + getSecondary, + descending = false, + namesDescending = false, + } = {}) => { + const dimensions = getIds().map(id => ({ + id, + name: chart.getDimensionName(id), + primary: getPrimary?.(id), + secondary: getSecondary?.(id), + })) + const direction = descending ? -1 : 1 + + dimensions.sort((a, b) => { + const primary = getPrimary ? direction * (a.primary - b.primary) : 0 + if (primary) return primary + + const secondary = getSecondary ? direction * (a.secondary - b.secondary) : 0 + if (secondary) return secondary + + return namesDescending + ? b.name.localeCompare(a.name) + : a.name.localeCompare(b.name, undefined, { + sensitivity: "accent", + ignorePunctuation: true, + }) + }) + + return dimensions.map(({ id }) => id) + } const bySortMethod = { default: (getIds = chart.getPayloadDimensionIds) => - getIds().sort( - withSortByNameFallback( - (a, b) => chart.getDimensionPriority(a) - chart.getDimensionPriority(b) - ) - ), - nameAsc: (getIds = chart.getPayloadDimensionIds) => getIds().sort(withSortByNameFallback()), + sortDimensionIds({ getIds, getPrimary: id => chart.getDimensionPriority(id) }), + nameAsc: (getIds = chart.getPayloadDimensionIds) => sortDimensionIds({ getIds }), nameDesc: (getIds = chart.getPayloadDimensionIds) => - getIds().sort((a, b) => chart.getDimensionName(b).localeCompare(chart.getDimensionName(a))), + sortDimensionIds({ getIds, namesDescending: true }), valueDesc: (getIds = chart.getPayloadDimensionIds, x) => { const { data } = chart.getPayload() x = x || data.length - 1 - return getIds().sort( - withSortByNameFallback( - (a, b) => chart.getDimensionValue(b, x) - chart.getDimensionValue(a, x) - ) - ) + return sortDimensionIds({ + getIds, + getPrimary: id => chart.getDimensionValue(id, x), + descending: true, + }) }, valueAsc: (getIds = chart.getPayloadDimensionIds, x) => { const { data } = chart.getPayload() x = x || data.length - 1 - return getIds().sort( - withSortByNameFallback( - (a, b) => chart.getDimensionValue(a, x) - chart.getDimensionValue(b, x) - ) - ) + return sortDimensionIds({ + getIds, + getPrimary: id => chart.getDimensionValue(id, x), + }) }, anomalyDesc: (getIds = chart.getPayloadDimensionIds, x) => { const { all } = chart.getPayload() x = x || all.length - 1 - return getIds().sort( - withSortByNameFallback( - (a, b) => - chart.getDimensionValue(b, x, { valueKey: "arp" }) - - chart.getDimensionValue(a, x, { valueKey: "arp" }), - (a, b) => chart.getDimensionValue(b, x) - chart.getDimensionValue(a, x) - ) - ) + return sortDimensionIds({ + getIds, + getPrimary: id => chart.getDimensionValue(id, x, { valueKey: "arp" }), + getSecondary: id => chart.getDimensionValue(id, x), + descending: true, + }) }, anomalyAsc: (getIds = chart.getPayloadDimensionIds, x) => { const { all } = chart.getPayload() x = x || all.length - 1 - return getIds().sort( - withSortByNameFallback( - (a, b) => - chart.getDimensionValue(a, x, { valueKey: "arp" }) - - chart.getDimensionValue(b, x, { valueKey: "arp" }), - (a, b) => chart.getDimensionValue(a, x) - chart.getDimensionValue(b, x) - ) - ) + return sortDimensionIds({ + getIds, + getPrimary: id => chart.getDimensionValue(id, x, { valueKey: "arp" }), + getSecondary: id => chart.getDimensionValue(id, x), + }) }, annotationsDesc: (getIds = chart.getPayloadDimensionIds, x) => { const { all } = chart.getPayload() x = x || all.length - 1 - return getIds().sort( - withSortByNameFallback( - (a, b) => - chart.getDimensionValue(b, x, { valueKey: "pa" }) - - chart.getDimensionValue(a, x, { valueKey: "pa" }), - (a, b) => chart.getDimensionValue(b, x) - chart.getDimensionValue(a, x) - ) - ) + return sortDimensionIds({ + getIds, + getPrimary: id => chart.getDimensionValue(id, x, { valueKey: "pa" }), + getSecondary: id => chart.getDimensionValue(id, x), + descending: true, + }) }, annotationsAsc: (getIds = chart.getPayloadDimensionIds, x) => { const { all } = chart.getPayload() x = x || all.length - 1 - return getIds().sort( - withSortByNameFallback( - (a, b) => - chart.getDimensionValue(a, x, { valueKey: "pa" }) - - chart.getDimensionValue(b, x, { valueKey: "pa" }), - (a, b) => chart.getDimensionValue(a, x) - chart.getDimensionValue(b, x) - ) - ) + return sortDimensionIds({ + getIds, + getPrimary: id => chart.getDimensionValue(id, x, { valueKey: "pa" }), + getSecondary: id => chart.getDimensionValue(id, x), + }) }, } diff --git a/src/sdk/makeChart/makeDimensions.test.js b/src/sdk/makeChart/makeDimensions.test.js index 9790cc4e..73fbab7a 100644 --- a/src/sdk/makeChart/makeDimensions.test.js +++ b/src/sdk/makeChart/makeDimensions.test.js @@ -245,6 +245,61 @@ describe("makeDimensions", () => { expect(() => mockChart.sortDimensions()).not.toThrow() }) + it("resolves each dimension value once per value sort", () => { + const ids = Array.from({ length: 100 }, (_, index) => `dimension-${index}`) + const values = Object.fromEntries(ids.map((id, index) => [id, ids.length - index])) + mockChart.getPayloadDimensionIds = jest.fn(() => [...ids]) + mockChart.getDimensionValue = jest.fn(id => values[id]) + mockChart.getDimensionName = jest.fn(id => id) + mockChart.getAttribute.mockImplementation(key => { + if (key === "dimensionsSort") return "valueDesc" + if (key === "selectedLegendDimensions") return [] + return null + }) + + mockChart.sortDimensions() + + expect(mockChart.getDimensionValue).toHaveBeenCalledTimes(ids.length) + expect(mockChart.getDimensionName).toHaveBeenCalledTimes(ids.length) + expect(mockChart.getVisibleDimensionIds()).toEqual(ids) + }) + + it("preserves value, secondary value, and name fallback ordering", () => { + const ids = ["low", "name-last", "name-first", "high"] + const anomalyRates = { low: 1, "name-last": 2, "name-first": 2, high: 2 } + const values = { low: 100, "name-last": 50, "name-first": 50, high: 200 } + const names = { + low: "Low", + "name-last": "Zulu", + "name-first": "Alpha", + high: "High", + } + mockChart.getPayloadDimensionIds = jest.fn(() => [...ids]) + mockChart.getDimensionName = jest.fn(id => names[id]) + mockChart.getDimensionValue = jest.fn((id, _x, options) => + options?.valueKey === "arp" ? anomalyRates[id] : values[id] + ) + mockChart.getAttribute.mockImplementation(key => { + if (key === "dimensionsSort") return "anomalyDesc" + if (key === "selectedLegendDimensions") return [] + return null + }) + + mockChart.sortDimensions() + + expect(mockChart.getVisibleDimensionIds()).toEqual(["high", "name-first", "name-last", "low"]) + const callsByValue = mockChart.getDimensionValue.mock.calls.reduce( + (calls, [id, , options]) => { + const key = `${options?.valueKey || "value"}:${id}` + calls.set(key, (calls.get(key) || 0) + 1) + return calls + }, + new Map() + ) + expect([...callsByValue.values()].every(count => count === 1)).toBe(true) + expect(mockChart.getDimensionValue).toHaveBeenCalledTimes(ids.length * 2) + }) + it("sorts by anomaly descending", () => { mockChart.getAttribute.mockImplementation(key => { if (key === "dimensionsSort") return "anomalyDesc" @@ -904,15 +959,7 @@ describe("makeDimensions heatmap bucket ordering", () => { }) it("crops prefixed heatmap edges using raw bucket values", async () => { - const ids = [ - "bucket_0", - "bucket_1", - "bucket_2", - "bucket_3", - "bucket_4", - "bucket_5", - "bucket_6", - ] + const ids = ["bucket_0", "bucket_1", "bucket_2", "bucket_3", "bucket_4", "bucket_5", "bucket_6"] const chart = makeHeatmapChart(ids) await loadHeatmapPayload(chart, ids, [ From 8e04d6acbdba92892a55a323c7b0f98347c0afec Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 15 Jul 2026 14:51:16 +0300 Subject: [PATCH 07/10] Bound result statistics aggregation --- src/sdk/makeChart/camelizePayload.js | 19 ++++++++++----- src/sdk/makeChart/camelizePayload.test.js | 28 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/sdk/makeChart/camelizePayload.js b/src/sdk/makeChart/camelizePayload.js index 18b57b36..8fae0634 100644 --- a/src/sdk/makeChart/camelizePayload.js +++ b/src/sdk/makeChart/camelizePayload.js @@ -4,19 +4,23 @@ import { getAlias } from "@/helpers/units" import normalizeSelectedInstances from "@/helpers/normalizeSelectedInstances" import { getPointValue } from "./getPointValue" -const transformDataRow = (row, point, labels, byDimension) => { +const transformDataRow = (row, point, labels, statsByIndex, byDimension) => { const values = new Array(row.length + 2) values[0] = row[0] for (let index = 1; index < row.length; index++) { const value = getPointValue(row[index], point) - const label = labels[index] - let stats = byDimension[label] + let stats = statsByIndex[index] values[index] = value if (!stats) { - stats = { min: Infinity, max: -Infinity } - byDimension[label] = stats + const label = labels[index] + stats = byDimension[label] + if (!stats) { + stats = { min: Infinity, max: -Infinity } + byDimension[label] = stats + } + statsByIndex[index] = stats } if (value <= stats.min) stats.min = value if (value >= stats.max) stats.max = value @@ -44,7 +48,10 @@ const buildTree = (h, keys, id) => { const transformResult = result => { const all = result.data const byDimension = {} - const data = all.map(row => transformDataRow(row, result.point, result.labels, byDimension)) + const statsByIndex = new Array(result.labels.length) + const data = all.map(row => + transformDataRow(row, result.point, result.labels, statsByIndex, byDimension) + ) const tree = result.labels.reduce((h, id, i) => { if (i === 0) return h diff --git a/src/sdk/makeChart/camelizePayload.test.js b/src/sdk/makeChart/camelizePayload.test.js index a48357d3..a66ba758 100644 --- a/src/sdk/makeChart/camelizePayload.test.js +++ b/src/sdk/makeChart/camelizePayload.test.js @@ -71,6 +71,34 @@ describe("camelizePayload", () => { expect(result.all[2][dimensionCount][0]).toBe(dimensionCount + 1) }) + it("resolves each result label once while aggregating many rows", () => { + const dimensionCount = 100 + const rowCount = 100 + let labelReads = 0 + const labels = new Proxy( + ["time", ...Array.from({ length: dimensionCount }, (_, index) => `d${index}`)], + { + get(target, property, receiver) { + if (typeof property === "string" && /^\d+$/.test(property)) labelReads++ + return Reflect.get(target, property, receiver) + }, + } + ) + const data = Array.from({ length: rowCount }, (_, rowIndex) => [ + rowIndex, + ...Array.from({ length: dimensionCount }, (_, dimensionIndex) => rowIndex + dimensionIndex), + ]) + const payload = { + result: { data, labels, point: { value: 0 } }, + } + + const result = camelizePayload(payload).result + + expect(result.byDimension.d0).toEqual({ min: 0, max: 99 }) + expect(result.byDimension.d99).toEqual({ min: 99, max: 198 }) + expect(labelReads).toBeLessThan(labels.length * 4) + }) + it("handles empty payload", () => { const payload = { result: { From 00e85542a9825bb5a31876bf331345cbd87358be Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Wed, 15 Jul 2026 15:02:45 +0300 Subject: [PATCH 08/10] Refresh chart values only for new payloads --- src/components/provider/selectors.js | 4 +- src/components/provider/selectors.test.js | 51 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/components/provider/selectors.js b/src/components/provider/selectors.js index 1e233833..b0ff48d6 100644 --- a/src/components/provider/selectors.js +++ b/src/components/provider/selectors.js @@ -485,7 +485,7 @@ export const useLatestRowValue = (options = {}) => { return unregister( chart.onAttributeChange("hoverX", () => setState(getValue())), chart.on("dimensionChanged", () => setState(getValue())), - chart.on("render", () => setState(getValue())) + chart.on("successFetch", () => setState(getValue())) ) }, [chart]) @@ -621,7 +621,7 @@ export const useValue = ( chart.onAttributeChange("hoverX", () => setState(getValue())), chart.on("dimensionChanged", () => setState(getValue())), chart.onAttributeChange(`${unitsKey}Conversion`, () => setState(getValue())), - chart.on("render", () => setState(getValue())) + chart.on("successFetch", () => setState(getValue())) ) }, [chart, id, valueKey, period, unitsKey, abs, allowNull]) diff --git a/src/components/provider/selectors.test.js b/src/components/provider/selectors.test.js index 3a6ae7b8..edc5bd4b 100644 --- a/src/components/provider/selectors.test.js +++ b/src/components/provider/selectors.test.js @@ -8,6 +8,7 @@ import { useTitle, useName, useIsMinimal, + useLatestRowValue, useLatestDisplayValue, useLatestDisplayValueWithUnit, useLatestValue, @@ -309,6 +310,56 @@ describe("Chart Provider Selectors", () => { }) describe("display values", () => { + it("refreshes latest values for successful fetches instead of render requests", () => { + jest.useFakeTimers() + const { chart } = makeTestChart() + jest.spyOn(chart, "getPayload").mockReturnValue({ all: [[0, 11]], data: [[0, 11]] }) + jest.spyOn(chart, "isDimensionVisible").mockReturnValue(true) + const getDimensionValue = jest.spyOn(chart, "getDimensionValue").mockReturnValue(11) + + const { result } = renderHookWithChart(() => useLatestValue("dim1"), { chart }) + + expect(result.current).toBe(11) + getDimensionValue.mockClear() + + act(() => { + for (let i = 0; i < 20; i++) chart.trigger("render") + jest.runOnlyPendingTimers() + }) + + expect(getDimensionValue).not.toHaveBeenCalled() + + act(() => chart.trigger("successFetch")) + + expect(getDimensionValue).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) + + it("refreshes complete latest rows for successful fetches instead of render requests", () => { + jest.useFakeTimers() + const { chart } = makeTestChart() + jest.spyOn(chart, "getPayload").mockReturnValue({ all: [[0, 11]], data: [[0, 11]] }) + jest.spyOn(chart, "getVisibleDimensionIds").mockReturnValue(["dim1"]) + const getDimensionValue = jest.spyOn(chart, "getDimensionValue").mockReturnValue(11) + jest.spyOn(chart, "selectDimensionColor").mockReturnValue("#123456") + + const { result } = renderHookWithChart(() => useLatestRowValue(), { chart }) + + act(() => { + for (let i = 0; i < 20; i++) chart.trigger("render") + jest.runOnlyPendingTimers() + }) + + expect(result.current).toBeNull() + expect(getDimensionValue).not.toHaveBeenCalled() + + act(() => chart.trigger("successFetch")) + + expect(result.current).toEqual([{ label: "dim1", value: 11, color: "#123456" }]) + expect(getDimensionValue).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) + it("keeps magnitude retrieval separate from signed atomic presentation", async () => { const payload = makeHeatmapPayload(["bytes"], [[-1536]]) payload.view.chart_type = "line" From 5e92c7d1e89e721f4a7779474c933f4ca087faae Mon Sep 17 00:00:00 2001 From: novykh Date: Wed, 15 Jul 2026 17:03:32 +0300 Subject: [PATCH 09/10] Fix scale setting labels. --- src/components/toolbox/settings/numberFormat.js | 6 ++++-- .../toolbox/settings/numberFormat.test.js | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/components/toolbox/settings/numberFormat.test.js diff --git a/src/components/toolbox/settings/numberFormat.js b/src/components/toolbox/settings/numberFormat.js index 56e5775c..c7566f31 100644 --- a/src/components/toolbox/settings/numberFormat.js +++ b/src/components/toolbox/settings/numberFormat.js @@ -1,7 +1,7 @@ import React, { useMemo, useState, useEffect } from "react" import { Flex, TextSmall, TextInput, Select } from "@netdata/netdata-ui" import { useAttributeValue, useChart } from "@/components/provider" -import { getScales, isScalable } from "@/helpers/units" +import { getScales, getUnitConfig, isScalable } from "@/helpers/units" import conversableUnits, { keys as conversableKeys } from "@/helpers/units/conversableUnits" const NumberFormat = () => { @@ -39,9 +39,11 @@ const NumberFormat = () => { }) } else { const [scaleKeys] = getScales(selectedUnit) + const config = getUnitConfig(selectedUnit) + const base = config.base_unit ?? selectedUnit scaleKeys.forEach(key => { const prefix = key === "1" ? "" : key - const unitLabel = `${prefix} ${selectedUnit}` + const unitLabel = `${prefix} ${base}`.trim() options.push({ value: key, label: unitLabel }) }) } diff --git a/src/components/toolbox/settings/numberFormat.test.js b/src/components/toolbox/settings/numberFormat.test.js new file mode 100644 index 00000000..fa1a01f2 --- /dev/null +++ b/src/components/toolbox/settings/numberFormat.test.js @@ -0,0 +1,17 @@ +import React from "react" +import "@testing-library/jest-dom" +import { screen } from "@testing-library/react" +import { renderWithChart } from "@jest/testUtilities" +import NumberFormat from "./numberFormat" + +it("labels scale options with the base unit, not the incoming prefixed unit", async () => { + const { user } = renderWithChart(, { + attributes: { units: ["KiBy/s"], desiredUnits: ["auto"] }, + }) + + await user.click(screen.getByRole("combobox")) + + expect(screen.getByText("Ki By/s")).toBeInTheDocument() + expect(screen.getByText("Mi By/s")).toBeInTheDocument() + expect(screen.queryByText("Ki KiBy/s")).not.toBeInTheDocument() +}) From 7e3468d5403297333fb21a4db9c14409809655ed Mon Sep 17 00:00:00 2001 From: novykh Date: Wed, 15 Jul 2026 17:03:34 +0300 Subject: [PATCH 10/10] v6.12.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fab4dba9..e07ce918 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netdata/charts", - "version": "6.12.4", + "version": "6.12.5", "description": "Netdata frontend SDK and chart utilities", "main": "dist/index.js", "module": "dist/es6/index.js",