Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@netdata/charts",
"version": "6.12.0",
"version": "6.12.1",
"description": "Netdata frontend SDK and chart utilities",
"main": "dist/index.js",
"module": "dist/es6/index.js",
Expand Down
112 changes: 112 additions & 0 deletions src/chartLibraries/dygraph/divergingStack.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import DefaultHandler from "dygraphs/src/datahandler/default"

const stackBaseKey = "netdataStackBase"
const stackEndKey = "netdataStackEnd"

export const getDivergingStackBounds = point => {
const base = point?.[stackBaseKey]
const end = point?.[stackEndKey]

return Number.isFinite(base) && Number.isFinite(end) ? { base, end } : null
}

export const findDivergingStackedPoint = (points, offsetY, toDomYCoord) => {
let closestPoint
let closestDistance = Infinity

points.forEach(point => {
const bounds = getDivergingStackBounds(point)
if (!bounds) return

const baseY = toDomYCoord(bounds.base)
const endY = toDomYCoord(bounds.end)
if (!Number.isFinite(baseY) || !Number.isFinite(endY)) return

const top = Math.min(baseY, endY)
const bottom = Math.max(baseY, endY)
const distance = offsetY < top ? top - offsetY : offsetY > bottom ? offsetY - bottom : 0

if (distance >= closestDistance) return

closestPoint = point
closestDistance = distance
})

if (!closestPoint) return {}

return {
point: closestPoint,
row: closestPoint.idx,
seriesName: closestPoint.name,
}
}

export const makeDivergingStackedDataHandler = chart => {
return class DivergingStackedDataHandler extends DefaultHandler {
constructor() {
super()

const dimensionIds = chart.getPayloadDimensionIds()
const payloadLabels = chart.getPayload().labels || []
const series = dimensionIds.map((dimensionId, index) => ({
dimensionId,
name: payloadLabels[index + 1] ?? dimensionId,
}))
const selectedDimensions = chart.getAttribute("selectedLegendDimensions")
const visibleSeries = selectedDimensions?.length
? series.filter(({ dimensionId }) => chart.isDimensionVisible(dimensionId))
: series

this.seriesNames = new Set(series.map(({ name }) => name))
this.firstStackedSeries = visibleSeries[visibleSeries.length - 1]?.name
this.positiveStack = []
this.negativeStack = []
this.pendingExtremes = null
}

getExtremeYValues(series, dateWindow, stepPlot) {
this.pendingExtremes = super.getExtremeYValues(series, dateWindow, stepPlot)
return this.pendingExtremes
}

seriesToPoints(series, setName, boundaryIdStart) {
const points = super.seriesToPoints(series, setName, boundaryIdStart)
if (!this.seriesNames.has(setName)) return points

if (setName === this.firstStackedSeries) {
this.positiveStack = []
this.negativeStack = []
}

let min = null
let max = null

points.forEach(point => {
const value = point.yval
if (!Number.isFinite(value)) {
point[stackBaseKey] = null
point[stackEndKey] = null
return
}

const stack = value < 0 ? this.negativeStack : this.positiveStack
const base = stack[point.idx] ?? 0
const end = base + value

stack[point.idx] = end
point[stackBaseKey] = base
point[stackEndKey] = end

if (min === null || end < min) min = end
if (max === null || end > max) max = end
})

if (this.pendingExtremes) {
this.pendingExtremes[0] = min
this.pendingExtremes[1] = max
}

return points
}
}
}
192 changes: 192 additions & 0 deletions src/chartLibraries/dygraph/divergingStack.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import Dygraph from "dygraphs"
import { makeHeatmapPayload, makeTestChart } from "@jest/testUtilities"
import {
findDivergingStackedPoint,
getDivergingStackBounds,
makeDivergingStackedDataHandler,
} from "./divergingStack"

const loadStackedPayload = async (chart, ids, rows, labels = ids) => {
const payload = makeHeatmapPayload(ids, rows)

payload.view.chart_type = "stacked"
payload.view.dimensions.grouped_by = []
payload.view.dimensions.names = labels
payload.result.labels = ["time", ...labels]

chart.doneFetch(payload)
await new Promise(resolve => setTimeout(resolve, 0))
}

const makeDygraph = chart => {
const element = document.createElement("div")
Object.defineProperties(element, {
clientWidth: { value: 800 },
clientHeight: { value: 400 },
})
element.style.padding = "0px"
document.body.appendChild(element)

const dimensionIds = chart.getPayloadDimensionIds()
const selectedDimensions = chart.getAttribute("selectedLegendDimensions")
const visibility = [
...dimensionIds.map(selectedDimensions.length ? chart.isDimensionVisible : () => true),
true,
true,
]

return new Dygraph(element, chart.getPayload().data, {
labels: chart.getPayload().labels,
visibility,
dataHandler: makeDivergingStackedDataHandler(chart),
})
}

const gather = dygraph => dygraph.gatherDatasets_(dygraph.rolledSeries_, null)

describe("diverging stacked data handler", () => {
const dygraphs = []

afterEach(() => {
dygraphs.forEach(dygraph => dygraph.destroy())
dygraphs.length = 0
document.body.innerHTML = ""
})

const createChart = async (ids, rows, attributes = {}, labels = ids) => {
const { chart } = makeTestChart({
attributes: {
chartType: "stacked",
groupBy: [],
selectedLegendDimensions: [],
...attributes,
},
})

await loadStackedPayload(chart, ids, rows, labels)

const dygraph = makeDygraph(chart)
dygraphs.push(dygraph)

return { chart, dygraph }
}

it("stacks positive and negative values independently at every point", async () => {
const { chart, dygraph } = await createChart(
["positive", "negative", "crossing"],
[
[2, -1, 0.5],
[-2, 1, -0.25],
]
)
const rawData = chart.getPayload().data.map(row => [...row])
const { points, extremes } = gather(dygraph)

expect(points[1].map(getDivergingStackBounds)).toEqual([
{ base: 0.5, end: 2.5 },
{ base: -0.25, end: -2.25 },
])
expect(points[2].map(getDivergingStackBounds)).toEqual([
{ base: 0, end: -1 },
{ base: 0, end: 1 },
])
expect(points[3].map(getDivergingStackBounds)).toEqual([
{ base: 0, end: 0.5 },
{ base: 0, end: -0.25 },
])
expect(extremes.positive).toEqual([-2.25, 2.5])
expect(extremes.negative).toEqual([-1, 1])
expect(extremes.crossing).toEqual([-0.25, 0.5])
expect(chart.getPayload().data).toEqual(rawData)
})

it("preserves the existing reverse series order for positive stacks", async () => {
const { dygraph } = await createChart(["top", "middle", "bottom"], [[2, 3, 4]])
const { points } = gather(dygraph)

expect(getDivergingStackBounds(points[3][0])).toEqual({ base: 0, end: 4 })
expect(getDivergingStackBounds(points[2][0])).toEqual({ base: 4, end: 7 })
expect(getDivergingStackBounds(points[1][0])).toEqual({ base: 7, end: 9 })
})

it("preserves the existing reverse series order for negative stacks", async () => {
const { dygraph } = await createChart(["top", "middle", "bottom"], [[-2, -3, -4]])
const { points } = gather(dygraph)

expect(getDivergingStackBounds(points[3][0])).toEqual({ base: 0, end: -4 })
expect(getDivergingStackBounds(points[2][0])).toEqual({ base: -4, end: -7 })
expect(getDivergingStackBounds(points[1][0])).toEqual({ base: -7, end: -9 })
})

it("maps display labels to dimension ids by column", async () => {
const ids = ['{"PROTOCOL":"6"}', '{"PROTOCOL":"17"}']
const labels = ["Protocol=TCP", "Protocol=UDP"]
const { dygraph } = await createChart(ids, [[2, 4]], {}, labels)
const { points } = gather(dygraph)

expect(getDivergingStackBounds(points[2][0])).toEqual({ base: 0, end: 4 })
expect(getDivergingStackBounds(points[1][0])).toEqual({ base: 4, end: 6 })
})

it("maps a tile sparkline label to its synthetic dimension", async () => {
const { dygraph } = await createChart(
["source dimension"],
[[5]],
{ sparkline: true },
["selected"]
)
const { points } = gather(dygraph)

expect(getDivergingStackBounds(points[1][0])).toEqual({ base: 0, end: 5 })
})

it("leaves null values as gaps without changing adjacent stack totals", async () => {
const { dygraph } = await createChart(["top", "gap", "bottom"], [[2, null, 4]])
const { points } = gather(dygraph)

expect(getDivergingStackBounds(points[3][0])).toEqual({ base: 0, end: 4 })
expect(getDivergingStackBounds(points[2][0])).toBeNull()
expect(getDivergingStackBounds(points[1][0])).toEqual({ base: 4, end: 6 })
})

it("rebases a selected dimension at zero", async () => {
const ids = ["top-id", "middle-id", "bottom-id"]
const labels = ["top", "middle", "bottom"]
const { chart, dygraph } = await createChart(ids, [[2, 3, 4]], {}, labels)
chart.updateAttribute("selectedLegendDimensions", ["middle"])
await new Promise(resolve => setTimeout(resolve, 0))

dygraph.updateOptions({
visibility: [...ids.map(chart.isDimensionVisible), true, true],
dataHandler: makeDivergingStackedDataHandler(chart),
})

const { points } = gather(dygraph)
expect(points[1]).toBeUndefined()
expect(getDivergingStackBounds(points[2][0])).toEqual({ base: 0, end: 3 })
expect(points[3]).toBeUndefined()
})

it("resets accumulators for every zoom-range gather", async () => {
const { dygraph } = await createChart(["top", "bottom"], [[2, 4]])

const first = gather(dygraph)
const second = gather(dygraph)

expect(getDivergingStackBounds(first.points[1][0])).toEqual({ base: 4, end: 6 })
expect(getDivergingStackBounds(second.points[1][0])).toEqual({ base: 4, end: 6 })
})

it("finds the signed band under the cursor", async () => {
const { dygraph } = await createChart(
["positive", "negative", "crossing"],
[[2, -1, 0.5]]
)
const { points } = gather(dygraph)
const rowPoints = points.slice(1, 4).map(series => series[0])
const toDomYCoord = value => 100 - value * 10

expect(findDivergingStackedPoint(rowPoints, 105, toDomYCoord).seriesName).toBe("negative")
expect(findDivergingStackedPoint(rowPoints, 98, toDomYCoord).seriesName).toBe("crossing")
})
})
18 changes: 5 additions & 13 deletions src/chartLibraries/dygraph/hoverX.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import getOffsets from "@/helpers/eventOffset"
import { isHeatmap } from "@/helpers/heatmap"
import { findDivergingStackedPoint } from "./divergingStack"

const shouldFindMaxValue = {
stacked: true,
Expand Down Expand Up @@ -45,20 +46,11 @@ export default chartUI => {
}

if (shouldFindMaxValue[chartUI.chart.getAttribute("chartType")]) {
let closestPoint = _dygraph.findStackedPoint(offsetX, offsetY)
const closestPoint = findDivergingStackedPoint(points, offsetY, value =>
_dygraph.toDomYCoord(value)
)

if (closestPoint.point?.canvasy > offsetY) {
closestPoint = points.reduce((max, p) => (p.yval > max.yval ? p : max), {
yval: -Infinity,
})

closestPoint = {
point: closestPoint,
row: closestPoint.idx,
seriesName: closestPoint.name,
}
}
return closestPoint
return closestPoint.seriesName ? closestPoint : _dygraph.findClosestPoint(offsetX, offsetY)
}

return _dygraph.findClosestPoint(offsetX, offsetY)
Expand Down
Loading