mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: add reference line support to charts and implement value formatting utility
This commit is contained in:
@@ -16,18 +16,29 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="space-y-3">
|
||||
<div className="flex space-x-3 items-start">
|
||||
{!props.isFormula && props.data.metricVariable && (
|
||||
<div className="bg-indigo-500 h-9 rounded w-9 min-w-9 p-3 pt-2 mt-5 font-medium text-white text-center text-sm">
|
||||
{props.data.metricVariable}
|
||||
</div>
|
||||
)}
|
||||
{props.isFormula && (
|
||||
<div className="bg-indigo-500 h-9 p-2 pt-2.5 rounded w-9 min-w-9 mt-5 font-bold text-white">
|
||||
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
{/* Variable badge row */}
|
||||
{((!props.isFormula && props.data.metricVariable) ||
|
||||
props.isFormula) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!props.isFormula && props.data.metricVariable && (
|
||||
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-xs font-semibold text-white">
|
||||
{props.data.metricVariable}
|
||||
</div>
|
||||
)}
|
||||
{props.isFormula && (
|
||||
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-white">
|
||||
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||||
Display Settings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and Description */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Title
|
||||
</label>
|
||||
@@ -40,10 +51,10 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
title: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Title..."
|
||||
placeholder="Chart title..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Description
|
||||
</label>
|
||||
@@ -56,12 +67,14 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
description: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Description..."
|
||||
placeholder="Chart description..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex-1">
|
||||
|
||||
{/* Legend and Unit */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Legend
|
||||
</label>
|
||||
@@ -74,10 +87,10 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
legend: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Legend (e.g. Response Time)"
|
||||
placeholder="e.g. Response Time"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Unit
|
||||
</label>
|
||||
@@ -90,7 +103,7 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
legendUnit: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Unit (e.g. ms)"
|
||||
placeholder="e.g. bytes, ms, %"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ import YAxisType from "Common/UI/Components/Charts/Types/YAxis/YAxisType";
|
||||
import { YAxisPrecision } from "Common/UI/Components/Charts/Types/YAxis/YAxis";
|
||||
import ChartCurve from "Common/UI/Components/Charts/Types/ChartCurve";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import ChartReferenceLineProps from "Common/UI/Components/Charts/Types/ReferenceLineProps";
|
||||
import ValueFormatter from "Common/Utils/ValueFormatter";
|
||||
|
||||
export interface ComponentProps {
|
||||
metricViewData: MetricViewData;
|
||||
@@ -39,7 +41,6 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
props.metricViewData.startAndEndDate?.startValue &&
|
||||
props.metricViewData.startAndEndDate?.endValue
|
||||
) {
|
||||
// if these are less than a day then we can use time
|
||||
const hourDifference: number = OneUptimeDate.getHoursBetweenTwoDates(
|
||||
props.metricViewData.startAndEndDate.startValue as Date,
|
||||
props.metricViewData.startAndEndDate.endValue as Date,
|
||||
@@ -69,7 +70,8 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
continue;
|
||||
}
|
||||
|
||||
let xAxisAggregationType: XAxisAggregateType = XAxisAggregateType.Average;
|
||||
let xAxisAggregationType: XAxisAggregateType =
|
||||
XAxisAggregateType.Average;
|
||||
|
||||
if (
|
||||
queryConfig.metricQueryData.filterData.aggegationType ===
|
||||
@@ -113,10 +115,6 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
const series: ChartSeries = queryConfig.getSeries(item);
|
||||
const seriesName: string = series.title;
|
||||
|
||||
//check if the series already exists if it does then add the data to the existing series
|
||||
|
||||
// if it does not exist then create a new series and add the data to it
|
||||
|
||||
const existingSeries: SeriesPoint | undefined = chartSeries.find(
|
||||
(s: SeriesPoint) => {
|
||||
return s.seriesName === seriesName;
|
||||
@@ -170,6 +168,42 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
chartType = ChartType.AREA;
|
||||
}
|
||||
|
||||
// Resolve the unit for formatting
|
||||
const metricType: MetricType | undefined = props.metricTypes.find(
|
||||
(m: MetricType) => {
|
||||
return (
|
||||
m.name === queryConfig.metricQueryData.filterData.metricName
|
||||
);
|
||||
},
|
||||
);
|
||||
const unit: string =
|
||||
queryConfig.metricAliasData?.legendUnit || metricType?.unit || "";
|
||||
|
||||
// Build reference lines from thresholds
|
||||
const referenceLines: Array<ChartReferenceLineProps> = [];
|
||||
|
||||
if (
|
||||
queryConfig.warningThreshold !== undefined &&
|
||||
queryConfig.warningThreshold !== null
|
||||
) {
|
||||
referenceLines.push({
|
||||
value: queryConfig.warningThreshold,
|
||||
label: `Warning: ${ValueFormatter.formatValue(queryConfig.warningThreshold, unit)}`,
|
||||
color: "#f59e0b", // amber
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
queryConfig.criticalThreshold !== undefined &&
|
||||
queryConfig.criticalThreshold !== null
|
||||
) {
|
||||
referenceLines.push({
|
||||
value: queryConfig.criticalThreshold,
|
||||
label: `Critical: ${ValueFormatter.formatValue(queryConfig.criticalThreshold, unit)}`,
|
||||
color: "#ef4444", // red
|
||||
});
|
||||
}
|
||||
|
||||
const chart: Chart = {
|
||||
id: index.toString(),
|
||||
type: chartType,
|
||||
@@ -197,8 +231,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
// legend is the unit of the metric
|
||||
legend: queryConfig.metricAliasData?.legendUnit || "",
|
||||
legend: unit,
|
||||
options: {
|
||||
type: YAxisType.Number,
|
||||
formatter: (value: number) => {
|
||||
@@ -206,15 +239,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
return queryConfig.yAxisValueFormatter(value);
|
||||
}
|
||||
|
||||
const metricType: MetricType | undefined =
|
||||
props.metricTypes.find((m: MetricType) => {
|
||||
return (
|
||||
m.name ===
|
||||
queryConfig.metricQueryData.filterData.metricName
|
||||
);
|
||||
});
|
||||
|
||||
return `${value} ${queryConfig.metricAliasData?.legendUnit || metricType?.unit || ""}`;
|
||||
return ValueFormatter.formatValue(value, unit);
|
||||
},
|
||||
precision: YAxisPrecision.NoDecimals,
|
||||
max: "auto",
|
||||
@@ -223,6 +248,8 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
curve: ChartCurve.MONOTONE,
|
||||
sync: true,
|
||||
referenceLines:
|
||||
referenceLines.length > 0 ? referenceLines : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -45,18 +45,8 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const getContent: GetReactElementFunction = (): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<MetricAlias
|
||||
data={props.data?.metricAliasData || defaultAliasData}
|
||||
onDataChanged={(data: MetricAliasData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({ ...props.data, metricAliasData: data });
|
||||
}
|
||||
}}
|
||||
isFormula={false}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{/* Metric query selection — always on top */}
|
||||
{props.data?.metricQueryData && (
|
||||
<MetricQuery
|
||||
data={props.data?.metricQueryData || {}}
|
||||
@@ -69,7 +59,7 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
|
||||
const previousMetricName: string | undefined =
|
||||
props.data?.metricQueryData?.filterData?.metricName?.toString();
|
||||
|
||||
// If metric changed, prefill legend and unit from MetricType
|
||||
// If metric changed, prefill all alias fields from MetricType
|
||||
if (
|
||||
selectedMetricName &&
|
||||
selectedMetricName !== previousMetricName
|
||||
@@ -88,13 +78,10 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
|
||||
metricQueryData: data,
|
||||
metricAliasData: {
|
||||
...currentAlias,
|
||||
legend: currentAlias.legend || metricType.name || "",
|
||||
legendUnit:
|
||||
currentAlias.legendUnit || metricType.unit || "",
|
||||
description:
|
||||
currentAlias.description ||
|
||||
metricType.description ||
|
||||
"",
|
||||
title: metricType.name || "",
|
||||
description: metricType.description || "",
|
||||
legend: metricType.name || "",
|
||||
legendUnit: metricType.unit || "",
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -112,7 +99,24 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
|
||||
onAttributesRetry={props.onAttributesRetry}
|
||||
/>
|
||||
)}
|
||||
<div className="flex space-x-3 mt-3">
|
||||
|
||||
{/* Display settings — title, description, legend, unit */}
|
||||
<div className="border-t border-gray-200 pt-3">
|
||||
<MetricAlias
|
||||
data={props.data?.metricAliasData || defaultAliasData}
|
||||
onDataChanged={(data: MetricAliasData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({ ...props.data, metricAliasData: data });
|
||||
}
|
||||
}}
|
||||
isFormula={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thresholds */}
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Warning Threshold
|
||||
@@ -154,8 +158,10 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
{props.onRemove && (
|
||||
<div className="-ml-3">
|
||||
<div>
|
||||
<Button
|
||||
title={"Remove"}
|
||||
onClick={() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import YAxis from "../Types/YAxis/YAxis";
|
||||
import ChartCurve from "../Types/ChartCurve";
|
||||
import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
|
||||
import DataPointUtil from "../Utils/DataPoint";
|
||||
import ChartReferenceLineProps from "../Types/ReferenceLineProps";
|
||||
|
||||
export interface ComponentProps {
|
||||
data: Array<SeriesPoint>;
|
||||
@@ -14,6 +15,7 @@ export interface ComponentProps {
|
||||
curve: ChartCurve;
|
||||
sync: boolean;
|
||||
heightInPx?: number | undefined;
|
||||
referenceLines?: Array<ChartReferenceLineProps> | undefined;
|
||||
}
|
||||
|
||||
export interface AreaInternalProps extends ComponentProps {
|
||||
@@ -75,6 +77,7 @@ const AreaChartElement: FunctionComponent<AreaInternalProps> = (
|
||||
syncid={props.sync ? props.syncid : undefined}
|
||||
yAxisWidth={60}
|
||||
onValueChange={() => {}}
|
||||
referenceLines={props.referenceLines}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { XAxis } from "../Types/XAxis/XAxis";
|
||||
import YAxis from "../Types/YAxis/YAxis";
|
||||
import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
|
||||
import DataPointUtil from "../Utils/DataPoint";
|
||||
import ChartReferenceLineProps from "../Types/ReferenceLineProps";
|
||||
|
||||
export interface ComponentProps {
|
||||
data: Array<SeriesPoint>;
|
||||
@@ -12,6 +13,7 @@ export interface ComponentProps {
|
||||
yAxis: YAxis;
|
||||
sync: boolean;
|
||||
heightInPx?: number | undefined;
|
||||
referenceLines?: Array<ChartReferenceLineProps> | undefined;
|
||||
}
|
||||
|
||||
export interface BarInternalProps extends ComponentProps {
|
||||
@@ -70,6 +72,7 @@ const BarChartElement: FunctionComponent<BarInternalProps> = (
|
||||
yAxisWidth={60}
|
||||
syncid={props.sync ? props.syncid : undefined}
|
||||
onValueChange={() => {}}
|
||||
referenceLines={props.referenceLines}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
Dot,
|
||||
Label,
|
||||
Legend as RechartsLegend,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import ChartReferenceLineProps from "../../Types/ReferenceLineProps";
|
||||
import { AxisDomain } from "recharts/types/util/types";
|
||||
|
||||
import { useOnWindowResize } from "../Utils/UseWindowOnResize";
|
||||
@@ -554,6 +556,7 @@ interface AreaChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
|
||||
customTooltip?: React.ComponentType<TooltipProps>;
|
||||
syncid?: string | undefined;
|
||||
referenceLines?: Array<ChartReferenceLineProps> | undefined;
|
||||
}
|
||||
|
||||
const AreaChart: React.ForwardRefExoticComponent<
|
||||
@@ -974,6 +977,32 @@ const AreaChart: React.ForwardRefExoticComponent<
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.referenceLines?.map(
|
||||
(
|
||||
refLine: ChartReferenceLineProps,
|
||||
refIndex: number,
|
||||
) => {
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`ref-${refIndex}`}
|
||||
y={refLine.value}
|
||||
stroke={refLine.color}
|
||||
strokeDasharray={refLine.strokeDasharray || "4 4"}
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
{refLine.label && (
|
||||
<Label
|
||||
value={refLine.label}
|
||||
position="insideTopRight"
|
||||
fill={refLine.color}
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
/>
|
||||
)}
|
||||
</ReferenceLine>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</RechartsAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
Label,
|
||||
BarChart as RechartsBarChart,
|
||||
Legend as RechartsLegend,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import ChartReferenceLineProps from "../../Types/ReferenceLineProps";
|
||||
import type { AxisDomain } from "recharts/types/util/types";
|
||||
|
||||
import {
|
||||
@@ -646,6 +648,7 @@ interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
|
||||
customTooltip?: React.ComponentType<TooltipProps>;
|
||||
syncid?: string | undefined;
|
||||
referenceLines?: Array<ChartReferenceLineProps> | undefined;
|
||||
}
|
||||
|
||||
const BarChart: React.ForwardRefExoticComponent<
|
||||
@@ -1010,6 +1013,32 @@ const BarChart: React.ForwardRefExoticComponent<
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.referenceLines?.map(
|
||||
(
|
||||
refLine: ChartReferenceLineProps,
|
||||
refIndex: number,
|
||||
) => {
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`ref-${refIndex}`}
|
||||
y={refLine.value}
|
||||
stroke={refLine.color}
|
||||
strokeDasharray={refLine.strokeDasharray || "4 4"}
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
{refLine.label && (
|
||||
<Label
|
||||
value={refLine.label}
|
||||
position="insideTopRight"
|
||||
fill={refLine.color}
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
/>
|
||||
)}
|
||||
</ReferenceLine>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
Line,
|
||||
Legend as RechartsLegend,
|
||||
LineChart as RechartsLineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import ChartReferenceLineProps from "../../Types/ReferenceLineProps";
|
||||
import { AxisDomain } from "recharts/types/util/types";
|
||||
|
||||
import { useOnWindowResize } from "../Utils/UseWindowOnResize";
|
||||
@@ -571,6 +573,7 @@ interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
|
||||
customTooltip?: React.ComponentType<TooltipProps>;
|
||||
syncid?: string | undefined;
|
||||
referenceLines?: Array<ChartReferenceLineProps> | undefined;
|
||||
}
|
||||
|
||||
const LineChart: React.ForwardRefExoticComponent<
|
||||
@@ -992,6 +995,32 @@ const LineChart: React.ForwardRefExoticComponent<
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{props.referenceLines?.map(
|
||||
(
|
||||
refLine: ChartReferenceLineProps,
|
||||
refIndex: number,
|
||||
) => {
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`ref-${refIndex}`}
|
||||
y={refLine.value}
|
||||
stroke={refLine.color}
|
||||
strokeDasharray={refLine.strokeDasharray || "4 4"}
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
{refLine.label && (
|
||||
<Label
|
||||
value={refLine.label}
|
||||
position="insideTopRight"
|
||||
fill={refLine.color}
|
||||
fontSize={11}
|
||||
fontWeight={500}
|
||||
/>
|
||||
)}
|
||||
</ReferenceLine>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import YAxis from "../Types/YAxis/YAxis";
|
||||
import ChartCurve from "../Types/ChartCurve";
|
||||
import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
|
||||
import DataPointUtil from "../Utils/DataPoint";
|
||||
import ChartReferenceLineProps from "../Types/ReferenceLineProps";
|
||||
|
||||
export interface ComponentProps {
|
||||
data: Array<SeriesPoint>;
|
||||
@@ -14,6 +15,7 @@ export interface ComponentProps {
|
||||
curve: ChartCurve;
|
||||
sync: boolean;
|
||||
heightInPx?: number | undefined;
|
||||
referenceLines?: Array<ChartReferenceLineProps> | undefined;
|
||||
}
|
||||
|
||||
export interface LineInternalProps extends ComponentProps {
|
||||
@@ -74,6 +76,7 @@ const LineChartElement: FunctionComponent<LineInternalProps> = (
|
||||
syncid={props.sync ? props.syncid : undefined}
|
||||
yAxisWidth={60}
|
||||
onValueChange={() => {}}
|
||||
referenceLines={props.referenceLines}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
6
Common/UI/Components/Charts/Types/ReferenceLineProps.ts
Normal file
6
Common/UI/Components/Charts/Types/ReferenceLineProps.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default interface ChartReferenceLineProps {
|
||||
value: number;
|
||||
label?: string | undefined;
|
||||
color: string; // CSS color, e.g. "#f59e0b" or "red"
|
||||
strokeDasharray?: string | undefined; // e.g. "4 4" for dashed
|
||||
}
|
||||
166
Common/Utils/ValueFormatter.ts
Normal file
166
Common/Utils/ValueFormatter.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// Human-friendly value formatting for metric units.
|
||||
// Converts raw values like 1048576 bytes → "1 MB", 3661 seconds → "1.02 hr", etc.
|
||||
|
||||
export interface FormattedValue {
|
||||
value: string; // e.g. "1.5"
|
||||
unit: string; // e.g. "MB"
|
||||
formatted: string; // e.g. "1.5 MB"
|
||||
}
|
||||
|
||||
type UnitThreshold = {
|
||||
threshold: number;
|
||||
unit: string;
|
||||
divisor: number;
|
||||
};
|
||||
|
||||
const byteUnits: Array<UnitThreshold> = [
|
||||
{ threshold: 1e15, unit: "PB", divisor: 1e15 },
|
||||
{ threshold: 1e12, unit: "TB", divisor: 1e12 },
|
||||
{ threshold: 1e9, unit: "GB", divisor: 1e9 },
|
||||
{ threshold: 1e6, unit: "MB", divisor: 1e6 },
|
||||
{ threshold: 1e3, unit: "KB", divisor: 1e3 },
|
||||
{ threshold: 0, unit: "B", divisor: 1 },
|
||||
];
|
||||
|
||||
const secondUnits: Array<UnitThreshold> = [
|
||||
{ threshold: 86400, unit: "d", divisor: 86400 },
|
||||
{ threshold: 3600, unit: "hr", divisor: 3600 },
|
||||
{ threshold: 60, unit: "min", divisor: 60 },
|
||||
{ threshold: 1, unit: "s", divisor: 1 },
|
||||
{ threshold: 0.001, unit: "ms", divisor: 0.001 },
|
||||
{ threshold: 0.000001, unit: "µs", divisor: 0.000001 },
|
||||
{ threshold: 0, unit: "ns", divisor: 0.000000001 },
|
||||
];
|
||||
|
||||
const millisecondUnits: Array<UnitThreshold> = [
|
||||
{ threshold: 86400000, unit: "d", divisor: 86400000 },
|
||||
{ threshold: 3600000, unit: "hr", divisor: 3600000 },
|
||||
{ threshold: 60000, unit: "min", divisor: 60000 },
|
||||
{ threshold: 1000, unit: "s", divisor: 1000 },
|
||||
{ threshold: 1, unit: "ms", divisor: 1 },
|
||||
{ threshold: 0.001, unit: "µs", divisor: 0.001 },
|
||||
{ threshold: 0, unit: "ns", divisor: 0.000001 },
|
||||
];
|
||||
|
||||
const microsecondUnits: Array<UnitThreshold> = [
|
||||
{ threshold: 1e6, unit: "s", divisor: 1e6 },
|
||||
{ threshold: 1e3, unit: "ms", divisor: 1e3 },
|
||||
{ threshold: 1, unit: "µs", divisor: 1 },
|
||||
{ threshold: 0, unit: "ns", divisor: 0.001 },
|
||||
];
|
||||
|
||||
const nanosecondUnits: Array<UnitThreshold> = [
|
||||
{ threshold: 1e9, unit: "s", divisor: 1e9 },
|
||||
{ threshold: 1e6, unit: "ms", divisor: 1e6 },
|
||||
{ threshold: 1e3, unit: "µs", divisor: 1e3 },
|
||||
{ threshold: 0, unit: "ns", divisor: 1 },
|
||||
];
|
||||
|
||||
// Maps common metric unit strings to their scaling table
|
||||
const unitTableMap: Record<string, Array<UnitThreshold>> = {
|
||||
// Byte variants
|
||||
bytes: byteUnits,
|
||||
byte: byteUnits,
|
||||
by: byteUnits,
|
||||
b: byteUnits,
|
||||
|
||||
// Second variants
|
||||
seconds: secondUnits,
|
||||
second: secondUnits,
|
||||
sec: secondUnits,
|
||||
s: secondUnits,
|
||||
|
||||
// Millisecond variants
|
||||
milliseconds: millisecondUnits,
|
||||
millisecond: millisecondUnits,
|
||||
ms: millisecondUnits,
|
||||
|
||||
// Microsecond variants
|
||||
microseconds: microsecondUnits,
|
||||
microsecond: microsecondUnits,
|
||||
us: microsecondUnits,
|
||||
µs: microsecondUnits,
|
||||
|
||||
// Nanosecond variants
|
||||
nanoseconds: nanosecondUnits,
|
||||
nanosecond: nanosecondUnits,
|
||||
ns: nanosecondUnits,
|
||||
};
|
||||
|
||||
function formatWithThresholds(
|
||||
value: number,
|
||||
thresholds: Array<UnitThreshold>,
|
||||
): FormattedValue {
|
||||
const absValue: number = Math.abs(value);
|
||||
|
||||
for (const t of thresholds) {
|
||||
if (absValue >= t.threshold) {
|
||||
const scaled: number = value / t.divisor;
|
||||
const formatted: string = formatNumber(scaled);
|
||||
return {
|
||||
value: formatted,
|
||||
unit: t.unit,
|
||||
formatted: `${formatted} ${t.unit}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use last threshold
|
||||
const last: UnitThreshold = thresholds[thresholds.length - 1]!;
|
||||
const scaled: number = value / last.divisor;
|
||||
const formatted: string = formatNumber(scaled);
|
||||
return {
|
||||
value: formatted,
|
||||
unit: last.unit,
|
||||
formatted: `${formatted} ${last.unit}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (value === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
const absValue: number = Math.abs(value);
|
||||
|
||||
if (absValue >= 100) {
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
if (absValue >= 10) {
|
||||
return (Math.round(value * 10) / 10).toString();
|
||||
}
|
||||
|
||||
return (Math.round(value * 100) / 100).toString();
|
||||
}
|
||||
|
||||
export default class ValueFormatter {
|
||||
// Format a value with a unit into a human-friendly string.
|
||||
// e.g. formatValue(1048576, "bytes") → "1 MB"
|
||||
// e.g. formatValue(3661, "seconds") → "1.02 hr"
|
||||
// e.g. formatValue(42, "%") → "42 %" (passthrough for unknown units)
|
||||
public static formatValue(value: number, unit: string): string {
|
||||
if (!unit || unit.trim() === "") {
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
const normalizedUnit: string = unit.trim().toLowerCase();
|
||||
const thresholds: Array<UnitThreshold> | undefined =
|
||||
unitTableMap[normalizedUnit];
|
||||
|
||||
if (thresholds) {
|
||||
return formatWithThresholds(value, thresholds).formatted;
|
||||
}
|
||||
|
||||
// Unknown unit — just format the number and append the unit as-is
|
||||
return `${formatNumber(value)} ${unit}`;
|
||||
}
|
||||
|
||||
// Check if a unit is one we can auto-scale (bytes, seconds, etc.)
|
||||
public static isScalableUnit(unit: string): boolean {
|
||||
if (!unit || unit.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
return unitTableMap[unit.trim().toLowerCase()] !== undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user