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.1",
"version": "6.12.3",
"description": "Netdata frontend SDK and chart utilities",
"main": "dist/index.js",
"module": "dist/es6/index.js",
Expand Down
193 changes: 164 additions & 29 deletions src/chartLibraries/dygraph/plotters/stackedArea.js
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down
138 changes: 138 additions & 0 deletions src/chartLibraries/dygraph/plotters/stackedArea.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
20 changes: 14 additions & 6 deletions src/chartLibraries/dygraph/tickers/numeric.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/drawer/correlate/columns.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading