From c9bc1c2be0e1532203a2d159cc70a10547d29fcc Mon Sep 17 00:00:00 2001 From: Manik Rana Date: Thu, 4 Jul 2024 17:31:09 +0530 Subject: [PATCH] Update histogram math (#13680) ui: Fix many edge cases of rendering a histogram --------- Signed-off-by: Manik Rana --- .../src/pages/graph/HistogramChart.tsx | 354 +++++++++++++++--- .../src/pages/graph/HistogramHelpers.ts | 126 +++++++ 2 files changed, 427 insertions(+), 53 deletions(-) create mode 100644 web/ui/react-app/src/pages/graph/HistogramHelpers.ts diff --git a/web/ui/react-app/src/pages/graph/HistogramChart.tsx b/web/ui/react-app/src/pages/graph/HistogramChart.tsx index ae171c5e4..3c6efa38b 100644 --- a/web/ui/react-app/src/pages/graph/HistogramChart.tsx +++ b/web/ui/react-app/src/pages/graph/HistogramChart.tsx @@ -2,22 +2,104 @@ import React, { FC } from 'react'; import { UncontrolledTooltip } from 'reactstrap'; import { Histogram } from '../../types/types'; import { bucketRangeString } from './DataTable'; +import { + calculateDefaultExpBucketWidth, + findMinPositive, + findMaxNegative, + findZeroAxisLeft, + showZeroAxis, + findZeroBucket, + ScaleType, +} from './HistogramHelpers'; -type ScaleType = 'linear' | 'exponential'; +interface HistogramChartProps { + histogram: Histogram; + index: number; + scale: ScaleType; +} -const HistogramChart: FC<{ histogram: Histogram; index: number; scale: ScaleType }> = ({ index, histogram, scale }) => { +const HistogramChart: FC = ({ index, histogram, scale }) => { const { buckets } = histogram; - const rangeMax = buckets ? parseFloat(buckets[buckets.length - 1][2]) : 0; - const countMax = buckets ? buckets.map((b) => parseFloat(b[3])).reduce((a, b) => Math.max(a, b)) : 0; + if (!buckets || buckets.length === 0) { + return
No data
; + } const formatter = Intl.NumberFormat('en', { notation: 'compact' }); - const positiveBuckets = buckets?.filter((b) => parseFloat(b[1]) >= 0); // we only want to show buckets with range >= 0 - const xLabelTicks = scale === 'linear' ? [0.25, 0.5, 0.75, 1] : [1]; + + // For linear scales, the count of a histogram bucket is represented by its area rather than its height. This means it considers + // both the count and the range (width) of the bucket. For this, we can set the height of the bucket proportional + // to its frequency density (fd). The fd is the count of the bucket divided by the width of the bucket. + const fds = []; + for (const bucket of buckets) { + const left = parseFloat(bucket[1]); + const right = parseFloat(bucket[2]); + const count = parseFloat(bucket[3]); + const width = right - left; + + // This happens when a user want observations of precisely zero to be included in the zero bucket + if (width === 0) { + fds.push(0); + continue; + } + fds.push(count / width); + } + const fdMax = Math.max(...fds); + + const first = buckets[0]; + const last = buckets[buckets.length - 1]; + + const rangeMax = parseFloat(last[2]); + const rangeMin = parseFloat(first[1]); + const countMax = Math.max(...buckets.map((b) => parseFloat(b[3]))); + + const defaultExpBucketWidth = calculateDefaultExpBucketWidth(last, buckets); + + const maxPositive = rangeMax > 0 ? rangeMax : 0; + const minPositive = findMinPositive(buckets); + const maxNegative = findMaxNegative(buckets); + const minNegative = parseFloat(first[1]) < 0 ? parseFloat(first[1]) : 0; + + // Calculate the borders of positive and negative buckets in the exponential scale from left to right + const startNegative = minNegative !== 0 ? -Math.log(Math.abs(minNegative)) : 0; + const endNegative = maxNegative !== 0 ? -Math.log(Math.abs(maxNegative)) : 0; + const startPositive = minPositive !== 0 ? Math.log(minPositive) : 0; + const endPositive = maxPositive !== 0 ? Math.log(maxPositive) : 0; + console.log( + 'startNegative', + startNegative, + 'endNegative', + endNegative, + 'startPositive', + startPositive, + 'endPositive', + endPositive + ); + + // Calculate the width of negative, positive, and all exponential bucket ranges on the x-axis + const xWidthNegative = endNegative - startNegative; + const xWidthPositive = endPositive - startPositive; + const xWidthTotal = xWidthNegative + defaultExpBucketWidth + xWidthPositive; + console.log('xWidthNegative', xWidthNegative, 'xWidthPositive', xWidthPositive, 'xWidthTotal', xWidthTotal); + + const zeroBucketIdx = findZeroBucket(buckets); + const zeroAxisLeft = findZeroAxisLeft( + scale, + rangeMin, + rangeMax, + minPositive, + maxNegative, + zeroBucketIdx, + xWidthNegative, + xWidthTotal, + defaultExpBucketWidth + ); + const zeroAxis = showZeroAxis(zeroAxisLeft); + return (
{[1, 0.75, 0.5, 0.25].map((i) => (
- {formatter.format(countMax * i)} + {scale === 'linear' ? '' : formatter.format(countMax * i)}
))}
@@ -31,62 +113,228 @@ const HistogramChart: FC<{ histogram: Histogram; index: number; scale: ScaleType
-
))} - {positiveBuckets?.map((b, bIdx) => { - const bucketIdx = `bucket-${index}-${bIdx}-${Math.ceil(parseFloat(b[3]) * 100)}`; - const bucketLeft = - scale === 'linear' ? (parseFloat(b[1]) / rangeMax) * 100 + '%' : (bIdx / positiveBuckets.length) * 100 + '%'; - const bucketWidth = - scale === 'linear' - ? ((parseFloat(b[2]) - parseFloat(b[1])) / rangeMax) * 100 + '%' - : 100 / positiveBuckets.length + '%'; - return ( - -
-
- - range: {bucketRangeString(b)} -
- count: {b[3]} -
-
-
- ); - })} +
+
+
+
+ + +
-
- 0 +
+ +
{formatter.format(rangeMin)}
+ {rangeMin < 0 && zeroAxis &&
0
} +
{formatter.format(rangeMax)}
+
- {xLabelTicks.map((i) => ( -
-
{formatter.format(rangeMax * i)}
-
- ))}
); }; +interface RenderHistogramProps { + buckets: [number, string, string, string][]; + scale: ScaleType; + rangeMin: number; + rangeMax: number; + index: number; + fds: number[]; + fdMax: number; + countMax: number; + defaultExpBucketWidth: number; + minPositive: number; + maxNegative: number; + startPositive: number; + startNegative: number; + xWidthNegative: number; + xWidthPositive: number; + xWidthTotal: number; +} + +const RenderHistogramBars: FC = ({ + buckets, + scale, + rangeMin, + rangeMax, + index, + fds, + fdMax, + countMax, + defaultExpBucketWidth, + minPositive, + maxNegative, + startPositive, + startNegative, + xWidthNegative, + xWidthPositive, + xWidthTotal, +}) => { + return ( + + {buckets.map((b, bIdx) => { + const left = parseFloat(b[1]); + const right = parseFloat(b[2]); + const count = parseFloat(b[3]); + const bucketIdx = `bucket-${index}-${bIdx}-${Math.ceil(parseFloat(b[3]) * 100)}`; + + const logWidth = Math.abs(Math.log(Math.abs(right)) - Math.log(Math.abs(left))); + const expBucketWidth = logWidth === 0 ? defaultExpBucketWidth : logWidth; + + let bucketWidth = ''; + let bucketLeft = ''; + let bucketHeight = ''; + + switch (scale) { + case 'linear': + bucketWidth = ((right - left) / (rangeMax - rangeMin)) * 100 + '%'; + bucketLeft = ((left - rangeMin) / (rangeMax - rangeMin)) * 100 + '%'; + if (left === 0 && right === 0) { + bucketLeft = '0%'; // do not render zero-width zero bucket + bucketWidth = '0%'; + } + bucketHeight = (fds[bIdx] / fdMax) * 100 + '%'; + break; + case 'exponential': + let adjust = 0; // if buckets are all positive/negative, we need to remove the width of the zero bucket + if (minPositive === 0 || maxNegative === 0) { + adjust = defaultExpBucketWidth; + } + bucketWidth = (expBucketWidth / (xWidthTotal - adjust)) * 100 + '%'; + if (left < 0) { + // negative buckets boundary + bucketLeft = (-(Math.log(Math.abs(left)) + startNegative) / (xWidthTotal - adjust)) * 100 + '%'; + } else { + // positive buckets boundary + bucketLeft = + ((Math.log(left) - startPositive + defaultExpBucketWidth + xWidthNegative - adjust) / + (xWidthTotal - adjust)) * + 100 + + '%'; + } + if (left < 0 && right > 0) { + // if the bucket crosses the zero axis + bucketLeft = (xWidthNegative / xWidthTotal) * 100 + '%'; + } + if (left === 0 && right === 0) { + // do not render zero width zero bucket + bucketLeft = '0%'; + bucketWidth = '0%'; + } + + bucketHeight = (count / countMax) * 100 + '%'; + break; + default: + throw new Error('Invalid scale'); + } + + console.log( + '[', + left, + ',', + right, + ']', + '\n', + 'fds[bIdx]', + fds[bIdx], + '\n', + 'fdMax', + fdMax, + '\n', + 'bucketIdx', + bucketIdx, + '\n', + 'bucketLeft', + bucketLeft, + '\n', + 'bucketWidth', + bucketWidth, + '\n', + 'bucketHeight', + bucketHeight, + '\n', + 'defaultExpBucketWidth', + defaultExpBucketWidth, + '\n', + 'expBucketWidth', + expBucketWidth, + '\n', + 'startNegative', + startNegative, + '\n', + 'startPositive', + startPositive, + '\n', + 'minPositive', + minPositive, + '\n', + 'maxNegative', + maxNegative, + 'xWidthNegative', + xWidthNegative, + '\n', + 'xWidthTotal', + xWidthTotal, + '\n', + 'xWidthPositive', + xWidthPositive, + '\n' + ); + + return ( + +
+
+ + range: {bucketRangeString(b)} +
+ count: {count} +
+
+
+ ); + })} +
+ ); +}; + export default HistogramChart; diff --git a/web/ui/react-app/src/pages/graph/HistogramHelpers.ts b/web/ui/react-app/src/pages/graph/HistogramHelpers.ts new file mode 100644 index 000000000..ae1efd308 --- /dev/null +++ b/web/ui/react-app/src/pages/graph/HistogramHelpers.ts @@ -0,0 +1,126 @@ +export type ScaleType = 'linear' | 'exponential'; + +// Calculates a default width of exponential histogram bucket ranges. If the last bucket is [0, 0], +// the width is calculated using the second to last bucket. returns error if the last bucket is [-0, 0], +export function calculateDefaultExpBucketWidth( + last: [number, string, string, string], + buckets: [number, string, string, string][] +): number { + if (parseFloat(last[2]) === 0 || parseFloat(last[1]) === 0) { + if (buckets.length > 1) { + return Math.abs( + Math.log(Math.abs(parseFloat(buckets[buckets.length - 2][2]))) - + Math.log(Math.abs(parseFloat(buckets[buckets.length - 2][1]))) + ); + } else { + throw new Error('Only one bucket in histogram ([-0, 0]). Cannot calculate defaultExpBucketWidth.'); + } + } else { + return Math.abs(Math.log(Math.abs(parseFloat(last[2]))) - Math.log(Math.abs(parseFloat(last[1])))); + } +} + +// Finds the lowest positive value from the bucket ranges +// Returns 0 if no positive values are found or if there are no buckets. +export function findMinPositive(buckets: [number, string, string, string][]) { + if (!buckets || buckets.length === 0) { + return 0; // no buckets + } + for (let i = 0; i < buckets.length; i++) { + const right = parseFloat(buckets[i][2]); + const left = parseFloat(buckets[i][1]); + + if (left > 0) { + return left; + } + if (left < 0 && right > 0) { + return right; + } + if (i === buckets.length - 1) { + if (right > 0) { + return right; + } + } + } + return 0; // all buckets are negative +} + +// Finds the lowest negative value from the bucket ranges +// Returns 0 if no negative values are found or if there are no buckets. +export function findMaxNegative(buckets: [number, string, string, string][]) { + if (!buckets || buckets.length === 0) { + return 0; // no buckets + } + for (let i = 0; i < buckets.length; i++) { + const right = parseFloat(buckets[i][2]); + const left = parseFloat(buckets[i][1]); + const prevRight = i > 0 ? parseFloat(buckets[i - 1][2]) : 0; + + if (right >= 0) { + if (i === 0) { + if (left < 0) { + return left; // return the first negative bucket + } + return 0; // all buckets are positive + } + return prevRight; // return the last negative bucket + } + } + console.log('findmaxneg returning: ', buckets[buckets.length - 1][2]); + return parseFloat(buckets[buckets.length - 1][2]); // all buckets are negative +} + +// Calculates the left position of the zero axis as a percentage string. +export function findZeroAxisLeft( + scale: ScaleType, + rangeMin: number, + rangeMax: number, + minPositive: number, + maxNegative: number, + zeroBucketIdx: number, + widthNegative: number, + widthTotal: number, + expBucketWidth: number +): string { + if (scale === 'linear') { + return ((0 - rangeMin) / (rangeMax - rangeMin)) * 100 + '%'; + } else { + if (maxNegative === 0) { + return '0%'; + } + if (minPositive === 0) { + return '100%'; + } + if (zeroBucketIdx === -1) { + // if there is no zero bucket, we must zero axis between buckets around zero + return (widthNegative / widthTotal) * 100 + '%'; + } + if ((widthNegative + 0.5 * expBucketWidth) / widthTotal > 0) { + return ((widthNegative + 0.5 * expBucketWidth) / widthTotal) * 100 + '%'; + } else { + return '0%'; + } + } +} + +// Determines if the zero axis should be shown such that the zero label does not overlap with the range labels. +// The zero axis is shown if it is between 5% and 95% of the graph. +export function showZeroAxis(zeroAxisLeft: string) { + const axisNumber = parseFloat(zeroAxisLeft.slice(0, -1)); + if (5 < axisNumber && axisNumber < 95) { + return true; + } + return false; +} + +// Finds the index of the bucket whose range includes zero +export function findZeroBucket(buckets: [number, string, string, string][]): number { + for (let i = 0; i < buckets.length; i++) { + const left = parseFloat(buckets[i][1]); + const right = parseFloat(buckets[i][2]); + if (left <= 0 && right >= 0) { + return i; + } + } + return -1; +}